add quick-open fuzzy file finder (Ctrl/Cmd+P)
A file picker overlay over the whole workspace, distinct from the command palette. QuickOpenController holds the file list + a subsequence fuzzy filter; the overlay loads the list via files.walk on open, shows RecentFilesService entries on an empty query, and opens the selection through a shared openWorkspaceFile helper (.md → markdown reader bus, else editor.open) that the files panel now also routes through, so recents stay in sync from every open site. Bound to ctrl+p / meta+p with `when: !palette.open` so it never collides with the palette's ctrl+p navigation; in-overlay arrows/enter/ escape reuse the palette's keymap-driven model via quickOpen.* intents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,8 @@ import 'package:clide/kernel/src/os.dart';
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/quick_open.dart';
|
||||
import 'package:clide/kernel/src/recent_files.dart';
|
||||
import 'package:clide/kernel/src/scheduler.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
@@ -49,6 +51,8 @@ class KernelServices {
|
||||
required this.arrangement,
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.quickOpen,
|
||||
required this.recentFiles,
|
||||
required this.keybindings,
|
||||
required this.clipboard,
|
||||
required this.files,
|
||||
@@ -79,6 +83,8 @@ class KernelServices {
|
||||
final LayoutArrangement arrangement;
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final QuickOpenController quickOpen;
|
||||
final RecentFilesService recentFiles;
|
||||
final KeybindingResolver keybindings;
|
||||
final ClideClipboard clipboard;
|
||||
final FileServices files;
|
||||
@@ -140,6 +146,8 @@ class KernelServices {
|
||||
final keymap = KeymapService(settings: settings, appDir: appDir);
|
||||
await keymap.load();
|
||||
final palette = PaletteController(commands);
|
||||
final recentFiles = RecentFilesService();
|
||||
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
final notify = Notifications();
|
||||
@@ -219,6 +227,8 @@ class KernelServices {
|
||||
arrangement: arrangement,
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
quickOpen: quickOpen,
|
||||
recentFiles: recentFiles,
|
||||
keybindings: keybindings,
|
||||
clipboard: clipboard,
|
||||
files: files,
|
||||
@@ -248,6 +258,8 @@ class KernelServices {
|
||||
arrangement.dispose();
|
||||
commands.dispose();
|
||||
palette.dispose();
|
||||
quickOpen.dispose();
|
||||
recentFiles.dispose();
|
||||
i18n.dispose();
|
||||
notify.dispose();
|
||||
dialog.dispose();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/// The single dispatch point for opening a workspace file the way
|
||||
/// clide routes file activations (T-51 / T-187):
|
||||
/// * `.md` paths → the markdown reader, via the kernel MessageBus;
|
||||
/// * every other path → the editor, via the `editor.open` IPC verb.
|
||||
///
|
||||
/// Records the open in [KernelServices.recentFiles] so the quick-open
|
||||
/// overlay's empty-query state reflects it. Shared by the files panel
|
||||
/// and the quick-open overlay so the routing stays in one place.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
|
||||
void openWorkspaceFile(KernelServices services, String path) {
|
||||
if (path.isEmpty) return;
|
||||
services.recentFiles.push(path);
|
||||
if (path.toLowerCase().endsWith('.md')) {
|
||||
services.messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
} else {
|
||||
unawaited(services.ipc.request('editor.open', args: {'path': path}));
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,29 @@ class PaletteAcceptIntent extends Intent {
|
||||
const PaletteAcceptIntent();
|
||||
}
|
||||
|
||||
// -- Quick open -------------------------------------------------------------
|
||||
|
||||
/// Open the quick-open file finder (fuzzy file picker), distinct from
|
||||
/// the command palette.
|
||||
class QuickOpenIntent extends Intent {
|
||||
const QuickOpenIntent();
|
||||
}
|
||||
|
||||
/// Highlight the next quick-open result.
|
||||
class QuickOpenSelectNextIntent extends Intent {
|
||||
const QuickOpenSelectNextIntent();
|
||||
}
|
||||
|
||||
/// Highlight the previous quick-open result.
|
||||
class QuickOpenSelectPreviousIntent extends Intent {
|
||||
const QuickOpenSelectPreviousIntent();
|
||||
}
|
||||
|
||||
/// Open the highlighted quick-open result.
|
||||
class QuickOpenAcceptIntent extends Intent {
|
||||
const QuickOpenAcceptIntent();
|
||||
}
|
||||
|
||||
// -- Text scale -------------------------------------------------------------
|
||||
|
||||
class TextScaleIncreaseIntent extends Intent {
|
||||
@@ -95,6 +118,10 @@ final Map<String, Intent Function()> builtinIntents = {
|
||||
'palette.selectNext': () => const PaletteSelectNextIntent(),
|
||||
'palette.selectPrevious': () => const PaletteSelectPreviousIntent(),
|
||||
'palette.accept': () => const PaletteAcceptIntent(),
|
||||
'quickOpen.open': () => const QuickOpenIntent(),
|
||||
'quickOpen.selectNext': () => const QuickOpenSelectNextIntent(),
|
||||
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
||||
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
||||
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
||||
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
||||
'text.scaleReset': () => const TextScaleResetIntent(),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/// State for the quick-open overlay (T-51): the workspace file list,
|
||||
/// the fuzzy filter, and the highlighted row. Pure state — the overlay
|
||||
/// widget loads the file list (via `files.walk`) and drives the actual
|
||||
/// open. Mirrors [PaletteController]'s shape so the overlay can reuse
|
||||
/// the palette's interaction model.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class QuickOpenController extends ChangeNotifier {
|
||||
QuickOpenController({required this.recentPaths});
|
||||
|
||||
/// Supplies the empty-query suggestions (most-recent-first). Injected
|
||||
/// as a callback so the controller stays decoupled from the recents
|
||||
/// service itself.
|
||||
final List<String> Function() recentPaths;
|
||||
|
||||
/// Cap on rendered results for a non-empty query — keeps the list
|
||||
/// widget bounded on large repos.
|
||||
static const int resultCap = 200;
|
||||
|
||||
bool _open = false;
|
||||
String _filter = '';
|
||||
int _selectedIndex = 0;
|
||||
List<String> _files = const [];
|
||||
bool _loading = false;
|
||||
bool _truncated = false;
|
||||
|
||||
bool get isOpen => _open;
|
||||
String get filter => _filter;
|
||||
bool get isLoading => _loading;
|
||||
|
||||
/// True when the underlying `files.walk` hit its cap — the file list
|
||||
/// is incomplete and the UI should say so.
|
||||
bool get truncated => _truncated;
|
||||
|
||||
/// Highlighted index, clamped into the current result list.
|
||||
int get selectedIndex {
|
||||
final n = filtered().length;
|
||||
if (n == 0) return 0;
|
||||
return _selectedIndex.clamp(0, n - 1);
|
||||
}
|
||||
|
||||
void open() {
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
_filter = '';
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
_filter = '';
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggle() => _open ? close() : open();
|
||||
|
||||
/// Toggle the loading indicator while the widget fetches the file list.
|
||||
void setLoading(bool value) {
|
||||
if (_loading == value) return;
|
||||
_loading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Install the workspace file list (from `files.walk`).
|
||||
void setFiles(List<String> files, {bool truncated = false}) {
|
||||
_files = files;
|
||||
_truncated = truncated;
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setFilter(String f) {
|
||||
if (_filter == f) return;
|
||||
_filter = f;
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectNext() {
|
||||
final n = filtered().length;
|
||||
if (n < 2) return;
|
||||
_selectedIndex = (selectedIndex + 1) % n;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectPrevious() {
|
||||
final n = filtered().length;
|
||||
if (n < 2) return;
|
||||
_selectedIndex = (selectedIndex - 1 + n) % n;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// The path currently highlighted, or null when the result list is
|
||||
/// empty.
|
||||
String? get selectedPath {
|
||||
final list = filtered();
|
||||
if (list.isEmpty) return null;
|
||||
return list[selectedIndex];
|
||||
}
|
||||
|
||||
/// The visible result list. An empty query shows recents; otherwise a
|
||||
/// subsequence fuzzy match over the file paths, ranked best-first and
|
||||
/// capped at [resultCap].
|
||||
List<String> filtered() {
|
||||
if (_filter.trim().isEmpty) return recentPaths();
|
||||
final q = _filter.toLowerCase().trim();
|
||||
final scored = <_Scored>[];
|
||||
for (final p in _files) {
|
||||
final s = _fuzzyScore(p.toLowerCase(), q);
|
||||
if (s != null) scored.add(_Scored(p, s));
|
||||
}
|
||||
scored.sort((a, b) {
|
||||
final c = a.score.compareTo(b.score); // lower is better
|
||||
if (c != 0) return c;
|
||||
return a.path.length.compareTo(b.path.length);
|
||||
});
|
||||
return [for (final s in scored.take(resultCap)) s.path];
|
||||
}
|
||||
}
|
||||
|
||||
class _Scored {
|
||||
_Scored(this.path, this.score);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// A bounded, most-recent-first list of repo-relative file paths opened
|
||||
/// this session. Backs the quick-open overlay's empty-query state
|
||||
/// (T-51). In-memory only — recents reset per app run, matching the
|
||||
/// session-scoped "recently opened within a workspace" convention.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RecentFilesService extends ChangeNotifier {
|
||||
RecentFilesService({this.cap = 20});
|
||||
|
||||
/// Maximum number of paths retained; the oldest fall off the end.
|
||||
final int cap;
|
||||
|
||||
final List<String> _paths = [];
|
||||
|
||||
/// Most-recent-first snapshot of the retained paths.
|
||||
List<String> get paths => List.unmodifiable(_paths);
|
||||
|
||||
/// Record [path] as the most-recently opened file: moves an existing
|
||||
/// entry to the front (no duplicates) and trims to [cap].
|
||||
void push(String path) {
|
||||
if (path.isEmpty) return;
|
||||
_paths.remove(path);
|
||||
_paths.insert(0, path);
|
||||
if (_paths.length > cap) _paths.removeRange(cap, _paths.length);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (_paths.isEmpty) return;
|
||||
_paths.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user