fuzzy-match + recency-rank the command palette

Completes the command-palette acceptance: the filter is now a
subsequence fuzzy match (was substring), and recently-invoked commands
float to the top and break score ties. The subsequence matcher is
extracted to a shared fuzzy helper so the palette and quick-open file
finder use one implementation instead of a private copy each.

Pinned commands and cross-session recency persistence are left as a
follow-up (they need a pin affordance + settings storage).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-06 09:40:56 +02:00
co-authored by Claude
parent 968c3dbfa8
commit 0e85f7f5e7
8 changed files with 225 additions and 29 deletions
+31
View File
@@ -329,3 +329,34 @@ Acceptance:
- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream.
Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', NULL, '2026-06-06 07:31:06', '2026-06-06 07:31:06', '2026-06-06 07:31:06', NULL, '1aa3d2ea3fd0501d699c5a26d87ac4eb', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-250', 'status', 'backlog', 'ready', NULL, '2026-06-06 07:32:57', '2026-06-06 07:32:57', '2026-06-06 07:32:57', NULL, 'ab80a8820d40ede74f9eeed3eda250d6', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'description', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).
DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '725ba044d75722c460c7869a2249c386', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'status', 'ready', 'done', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '121337ddae4598659f07bbe16c1ddcc3', 1) ON CONFLICT(hash) DO NOTHING;
+53
View File
@@ -731,3 +731,56 @@ Acceptance:
- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream.
Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:31:06', NULL, 'e555fe45be44f3a22e30005370aa6ec0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-250', 'bug', NULL, 'Permission-mode badge + Ctrl-M cycle don''t update the status bar', 'Symptom: the permission-mode badge in the Claude status line does nothing visible when clicked, and Ctrl/Cmd+M (cycle mode, T-226) likewise appears to do nothing. Either the mode isn''t actually changing, or — more likely — it changes in the Claude process but the status-bar tracker is never updated.
Likely root cause (confirmed by reading the code): the cycle is fire-and-forget with no status feedback.
- Composer Ctrl/Cmd+M -> widget.onCycleMode -> _ClaudePaneState._cycleMode (lib/builtin/claude/src/claude_pane.dart:302) -> s.setPermissionMode(nextSafePermissionMode(...)).
- The badge click path (_ModeBadge.onCycle) routes to the same _cycleMode, so both symptoms share one cause.
- StreamJsonSession.setPermissionMode (lib/builtin/claude/src/stream_json_session.dart:671) only writes a `set_permission_mode` control_request to the process. It does NOT optimistically update `_status` nor emit on `_statusCtl`.
- `_status.permissionMode` is only ever set from a `system/init` event via `_statusFromEvent` (stream_json_session.dart:567-571). A set_permission_mode control_request returns a control_response, which is not folded into status so `_status` never re-emits, the badge label (claude_pane.dart:105-122, driven by `_status` over statusStream) is stale, and the UI looks dead.
So the status bar / badge value is not updated on change matching the reported hypothesis.
Fix direction:
- Optimistically merge the new mode into `_status` and emit on `_statusCtl` immediately after writing the control_request (mirror how other state transitions surface), and/or handle the `control_response` for set_permission_mode and reconcile from it.
- Separately verify the control_request is actually honored by the Claude process (the ''they don''t work'' branch) if the response reports failure, surface it rather than silently assuming success.
Acceptance:
- Clicking the badge and pressing Ctrl/Cmd+M both visibly cycle default -> accept-edits -> plan -> default in the status line.
- The displayed mode reflects the session''s actual mode (reconciled from the response / next init), not just an optimistic guess.
- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream.
Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'ready', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:32:57', NULL, 'b40e943095a1e40ec8e868b392fd41c1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'task', 'T-4', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget no layout shift on palette open).
DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', 'ready', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-06-06 07:40:43', NULL, 'b0194b050cfb6e5dec567933287c186f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'task', 'T-4', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget no layout shift on palette open).
DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', 'done', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-06-06 07:40:43', NULL, '0a837620f95c93871c8dcbde152adffc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-251', 'task', 'T-4', 'Command palette: pinned commands + cross-session recency persistence (T-23 follow-up)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '3d69fb0be99289aa0d9f9bdd48f02aae', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+2
View File
@@ -100,6 +100,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- Command palette (⌘⇧P) now fuzzy-matches command titles (subsequence, not
just substring) and floats recently-used commands to the top. (T-23)
- ⌘K now opens a **Settings** modal instead of a theme-only picker. Its first
(currently only) section is Appearance — base themes, sorted, with a High
contrast toggle for `-hc` siblings — matching the status-bar switcher. (T-238)
+47 -6
View File
@@ -1,5 +1,6 @@
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/fuzzy.dart';
import 'package:flutter/foundation.dart';
class PaletteController extends ChangeNotifier {
@@ -11,6 +12,12 @@ class PaletteController extends ChangeNotifier {
String _filter = '';
int _selectedIndex = 0;
/// Recently-invoked command ids, most-recent-first. Floats recent commands
/// to the top of the list (empty query) and breaks fuzzy-score ties in their
/// favour. In-session only. Capped so it can't grow unbounded.
final List<String> _recent = [];
static const int _recentCap = 20;
bool get isOpen => _open;
String get filter => _filter;
@@ -74,17 +81,51 @@ class PaletteController extends ChangeNotifier {
await invoke(list[selectedIndex].command);
}
/// The visible command list. Empty query → every command with recents
/// floated to the top (most-recent-first), the rest in registry order. A
/// non-empty query → a subsequence fuzzy match over each command's title
/// (or id), best-score first; ties break toward recents, then alphabetical.
List<CommandContribution> filtered() {
if (_filter.isEmpty) return _registry.all.toList();
final q = _filter.toLowerCase();
return _registry.all.where((c) {
final haystack = (c.title ?? c.command).toLowerCase();
return haystack.contains(q);
}).toList();
final all = _registry.all.toList();
final recentRank = {for (var i = 0; i < _recent.length; i++) _recent[i]: i};
final order = {for (var i = 0; i < all.length; i++) all[i].command: i};
int rank(String cmd) => recentRank[cmd] ?? (1 << 30);
final q = _filter.trim().toLowerCase();
if (q.isEmpty) {
all.sort((a, b) {
final c = rank(a.command).compareTo(rank(b.command));
// Non-recents (equal rank) keep registry order.
return c != 0 ? c : order[a.command]!.compareTo(order[b.command]!);
});
return all;
}
final scored = <({CommandContribution cmd, int score})>[];
for (final c in all) {
final s = fuzzyScore((c.title ?? c.command).toLowerCase(), q);
if (s != null) scored.add((cmd: c, score: s));
}
scored.sort((a, b) {
final byScore = a.score.compareTo(b.score);
if (byScore != 0) return byScore;
final byRecent = rank(a.cmd.command).compareTo(rank(b.cmd.command));
if (byRecent != 0) return byRecent;
return (a.cmd.title ?? a.cmd.command).toLowerCase().compareTo((b.cmd.title ?? b.cmd.command).toLowerCase());
});
return [for (final s in scored) s.cmd];
}
Future<void> invoke(String command) async {
_recordRecent(command);
close();
await _registry.execute(command);
}
void _recordRecent(String command) {
_recent
..remove(command)
..insert(0, command);
if (_recent.length > _recentCap) _recent.removeRange(_recentCap, _recent.length);
}
}
+27
View File
@@ -0,0 +1,27 @@
/// Subsequence fuzzy matching, shared by the command palette and the
/// quick-open file finder. Pure Dart (no Flutter) so it's reusable across
/// isolates and trivially unit-testable.
library;
/// Returns null when [query]'s characters don't appear in order within
/// [text]; otherwise a score where **lower is better** — contiguous, early
/// matches score best; gaps between matched chars and a late first match add
/// penalty. Callers that want case-insensitive matching should lowercase both
/// arguments first.
int? fuzzyScore(String text, String query) {
if (query.isEmpty) return 0;
var ti = 0;
var qi = 0;
var score = 0;
int? last;
while (ti < text.length && qi < query.length) {
if (text.codeUnitAt(ti) == query.codeUnitAt(qi)) {
score += last == null ? ti : (ti - last - 1);
last = ti;
qi++;
}
ti++;
}
if (qi != query.length) return null;
return score;
}
+2 -23
View File
@@ -5,6 +5,7 @@
/// the palette's interaction model.
library;
import 'package:clide/kernel/src/fuzzy.dart';
import 'package:flutter/foundation.dart';
class QuickOpenController extends ChangeNotifier {
@@ -111,7 +112,7 @@ class QuickOpenController extends ChangeNotifier {
final q = _filter.toLowerCase().trim();
final scored = <_Scored>[];
for (final p in _files) {
final s = _fuzzyScore(p.toLowerCase(), q);
final s = fuzzyScore(p.toLowerCase(), q);
if (s != null) scored.add(_Scored(p, s));
}
scored.sort((a, b) {
@@ -128,25 +129,3 @@ class _Scored {
final String path;
final int score;
}
/// Subsequence fuzzy match. Returns null when [query]'s characters
/// don't appear in order within [text]; otherwise a score where lower
/// is better — contiguous, early matches score best (gaps and a late
/// start add penalty).
int? _fuzzyScore(String text, String query) {
if (query.isEmpty) return 0;
var ti = 0;
var qi = 0;
var score = 0;
int? last;
while (ti < text.length && qi < query.length) {
if (text.codeUnitAt(ti) == query.codeUnitAt(qi)) {
score += last == null ? ti : (ti - last - 1);
last = ti;
qi++;
}
ti++;
}
if (qi != query.length) return null;
return score;
}
@@ -57,5 +57,33 @@ void main() {
expect(palette.isOpen, false);
expect(palette.filter, '');
});
test('fuzzy subsequence match — non-contiguous chars (not substring)', () {
// "gc" / "gp" aren't substrings of the titles, but are subsequences.
palette.setFilter('gc');
expect(palette.filtered().map((c) => c.command), contains('git.commit'));
palette.setFilter('gp');
expect(palette.filtered().map((c) => c.command), contains('git.push'));
// Garbage that isn't a subsequence matches nothing.
palette.setFilter('zzz');
expect(palette.filtered(), isEmpty);
});
test('recency floats invoked commands to the top on an empty filter', () async {
expect(palette.filtered().first.command, 'git.commit'); // registry order
await palette.invoke('theme.pick');
expect(palette.filtered().first.command, 'theme.pick');
await palette.invoke('git.push');
expect(palette.filtered().map((c) => c.command).take(2).toList(), ['git.push', 'theme.pick']);
});
test('recency breaks fuzzy-score ties', () async {
// "git" matches both git.* titles with an equal score; invoking push
// floats it above commit among the equal matches.
await palette.invoke('git.push');
palette.setFilter('git');
final order = palette.filtered().map((c) => c.command).toList();
expect(order.indexOf('git.push'), lessThan(order.indexOf('git.commit')));
});
});
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:clide/kernel/src/fuzzy.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('fuzzyScore', () {
test('empty query scores 0 (matches anything)', () {
expect(fuzzyScore('anything', ''), 0);
});
test('returns null when query is not a subsequence', () {
expect(fuzzyScore('git commit', 'xyz'), isNull);
expect(fuzzyScore('abc', 'abcd'), isNull); // query longer / extra char
});
test('matches non-contiguous subsequences', () {
expect(fuzzyScore('git commit', 'gc'), isNotNull);
expect(fuzzyScore('theme pick', 'tp'), isNotNull);
});
test('lower score is better: contiguous + early beats gappy + late', () {
final contiguousEarly = fuzzyScore('abcxxxx', 'abc')!; // match at 0,1,2
final gappyLate = fuzzyScore('xxxxabc', 'abc')!; // starts at 4
expect(contiguousEarly, lessThan(gappyLate));
final tight = fuzzyScore('ab', 'ab')!; // adjacent
final spread = fuzzyScore('axb', 'ab')!; // a gap between a and b
expect(tight, lessThan(spread));
});
test('case sensitivity is the caller\'s responsibility', () {
expect(fuzzyScore('GIT', 'git'), isNull); // differing case → no match
expect(fuzzyScore('GIT'.toLowerCase(), 'git'), isNotNull);
});
});
}