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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user