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
+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;
}