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