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:
2026-05-31 20:19:21 +02:00
co-authored by Claude Opus 4.8
parent d7be5535d5
commit 0c7a6e86d5
16 changed files with 865 additions and 22 deletions
+7
View File
@@ -119,6 +119,12 @@ class _RootShellState extends State<_RootShell> {
return null;
},
),
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
onInvoke: (_) {
widget.services.quickOpen.open();
return null;
},
),
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusNextSlot();
@@ -150,6 +156,7 @@ class _RootShellState extends State<_RootShell> {
children: [
const Positioned.fill(child: RootLayout()),
const ClidePalette(),
const QuickOpenOverlay(),
const Positioned.fill(child: _WelcomeOverlay()),
],
),
+4 -22
View File
@@ -211,20 +211,9 @@ class _FileRow extends StatelessWidget {
}
void _openFile(BuildContext context, String path) {
final kernel = ClideKernel.of(context);
if (path.toLowerCase().endsWith('.md')) {
// Route .md files to the markdown reader panel via the kernel MessageBus.
kernel.messages.publish('builtin.markdown', 'selection', {'path': path});
} else {
// editor.open is a daemon-side IPC handler (lib/src/daemon/
// editor_commands.dart), not a kernel command. Fire the request
// and let the editor extension's controller pick up the
// editor.active-changed / editor.opened event — no need to await
// or handle the response here.
unawaited(
kernel.ipc.request('editor.open', args: {'path': path}),
);
}
// Shared routing (T-187): .md → markdown reader, else editor.open;
// records the open in RecentFilesService for quick-open (T-51).
openWorkspaceFile(ClideKernel.of(context), path);
}
}
@@ -285,14 +274,7 @@ class _FilteredFileRow extends StatelessWidget {
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ClideTappable(
onTap: () {
final kernel = ClideKernel.of(context);
if (entry.path.toLowerCase().endsWith('.md')) {
kernel.messages.publish('builtin.markdown', 'selection', {'path': entry.path});
} else {
unawaited(kernel.ipc.request('editor.open', args: {'path': entry.path}));
}
},
onTap: () => openWorkspaceFile(ClideKernel.of(context), entry.path),
builder: (context, hovered, _) => Container(
color: hovered ? tokens.sidebarItemHover : null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
+3
View File
@@ -29,6 +29,7 @@ export 'src/keymap/keymap_service.dart';
export 'src/keymap/when_clause.dart';
export 'src/dialog.dart';
export 'src/extensions_manager.dart';
export 'src/file_open.dart';
export 'src/files.dart';
export 'src/focus.dart';
export 'src/i18n/catalog_loader.dart';
@@ -39,6 +40,8 @@ export 'src/notify.dart';
export 'src/os.dart';
export 'src/panels/arrangement.dart';
export 'src/project.dart';
export 'src/quick_open.dart';
export 'src/recent_files.dart';
export 'src/scheduler.dart';
export 'src/text_zoom.dart';
export 'src/secrets.dart';
+12
View File
@@ -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();
+23
View File
@@ -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}));
}
}
+27
View File
@@ -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(),
+152
View File
@@ -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;
}
+35
View File
@@ -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();
}
}
+318
View File
@@ -0,0 +1,318 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
/// Quick-open file finder overlay (T-51). A fuzzy file picker, distinct
/// from the command palette: it loads the workspace file list via
/// `files.walk`, filters with a subsequence fuzzy match, shows recents
/// when the query is empty, and opens the selection via
/// [openWorkspaceFile] (`.md` → markdown reader, else editor).
///
/// Mirrors `ClidePalette`'s structure and keymap-driven navigation —
/// the overlay publishes the `quickOpen.open` scope flag and binds the
/// `quickOpen.*` intents while open.
class QuickOpenOverlay extends StatefulWidget {
const QuickOpenOverlay({super.key});
@override
State<QuickOpenOverlay> createState() => _QuickOpenOverlayState();
}
class _QuickOpenOverlayState extends State<QuickOpenOverlay> {
final _input = TextEditingController();
final _focus = FocusNode(debugLabel: 'QuickOpenOverlay.input');
final _itemKeys = <int, GlobalKey>{};
QuickOpenController? _quickOpen;
KeymapService? _keymap;
KernelServices? _services;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final kernel = ClideKernel.of(context);
_services = kernel;
if (!identical(_quickOpen, kernel.quickOpen)) {
_quickOpen?.removeListener(_onChanged);
_quickOpen = kernel.quickOpen;
_quickOpen!.addListener(_onChanged);
_syncFromController();
}
_keymap = kernel.keymap;
final isOpen = _quickOpen?.isOpen ?? false;
_keymap?.setScopeFlag('quickOpen.open', isOpen);
if (isOpen) {
_ensureFilesLoaded();
if (!_focus.hasFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && (_quickOpen?.isOpen ?? false)) _focus.requestFocus();
});
}
}
}
@override
void dispose() {
_quickOpen?.removeListener(_onChanged);
_keymap?.clearScopeFlag('quickOpen.open');
_input.dispose();
_focus.dispose();
super.dispose();
}
bool _loadedForThisOpen = false;
void _onChanged() {
final isOpen = _quickOpen?.isOpen ?? false;
_keymap?.setScopeFlag('quickOpen.open', isOpen);
if (isOpen) {
_ensureFilesLoaded();
_focus.requestFocus();
} else {
_loadedForThisOpen = false;
}
_syncFromController();
}
void _syncFromController() {
final f = _quickOpen?.filter ?? '';
if (_input.text != f) {
_input.value = TextEditingValue(
text: f,
selection: TextSelection.collapsed(offset: f.length),
);
}
}
/// Fetch the workspace file list once per open via `files.walk`.
Future<void> _ensureFilesLoaded() async {
if (_loadedForThisOpen) return;
_loadedForThisOpen = true;
final services = _services;
final controller = _quickOpen;
if (services == null || controller == null) return;
controller.setLoading(true);
try {
final res = await services.ipc.request('files.walk', args: const {});
if (!res.ok || !controller.isOpen) return;
final raw = res.data['files'];
final files = raw is List ? raw.map((e) => '$e').toList() : <String>[];
controller.setFiles(files, truncated: res.data['truncated'] == true);
} catch (_) {
// Leave the list empty; recents still show on empty query.
} finally {
controller.setLoading(false);
}
}
Object? _selectNext(QuickOpenSelectNextIntent _) {
_quickOpen?.selectNext();
_scrollSelectedIntoView();
return null;
}
Object? _selectPrev(QuickOpenSelectPreviousIntent _) {
_quickOpen?.selectPrevious();
_scrollSelectedIntoView();
return null;
}
Object? _accept(QuickOpenAcceptIntent _) {
_openSelected();
return null;
}
Object? _dismiss(DismissIntent _) {
_quickOpen?.close();
return null;
}
void _openSelected() {
final controller = _quickOpen;
final services = _services;
if (controller == null || services == null) return;
final path = controller.selectedPath;
controller.close();
_input.clear();
if (path != null) openWorkspaceFile(services, path);
}
void _scrollSelectedIntoView() {
final idx = _quickOpen?.selectedIndex;
if (idx == null) return;
final ctx = _itemKeys[idx]?.currentContext;
if (ctx == null) return;
Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 120), alignment: 0.5);
}
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.quickOpen,
builder: (ctx, _) {
final controller = kernel.quickOpen;
if (!controller.isOpen) return const SizedBox.shrink();
final results = controller.filtered();
final selected = controller.selectedIndex;
final emptyQuery = controller.filter.trim().isEmpty;
return Positioned(
top: 60,
left: 0,
right: 0,
child: Center(
child: Actions(
actions: <Type, Action<Intent>>{
QuickOpenSelectNextIntent: CallbackAction<QuickOpenSelectNextIntent>(onInvoke: _selectNext),
QuickOpenSelectPreviousIntent: CallbackAction<QuickOpenSelectPreviousIntent>(onInvoke: _selectPrev),
QuickOpenAcceptIntent: CallbackAction<QuickOpenAcceptIntent>(onInvoke: _accept),
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _dismiss),
},
child: Container(
width: 480,
constraints: const BoxConstraints(maxHeight: 360),
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(color: tokens.shadowAmbient, blurRadius: 12, offset: const Offset(0, 4)),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: EditableText(
controller: _input,
focusNode: _focus,
style: TextStyle(
fontFamily: clideMonoFamily,
fontSize: clideFontMono,
color: tokens.dropdownForeground,
),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus,
maxLines: 1,
onChanged: controller.setFilter,
onSubmitted: (_) => _openSelected(),
),
),
if (emptyQuery && results.isEmpty)
_Hint(controller.isLoading ? 'Loading files…' : 'No recent files', tokens)
else if (results.isEmpty)
_Hint('No matching files', tokens)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: results.length,
itemBuilder: (ctx, i) {
final path = results[i];
final key = _itemKeys.putIfAbsent(i, () => GlobalKey());
return _QuickOpenItem(
key: key,
path: path,
highlighted: i == selected,
onTap: () {
controller.close();
_input.clear();
openWorkspaceFile(kernel, path);
},
);
},
),
),
if (controller.truncated)
_Hint('Results limited — large workspace', tokens),
],
),
),
),
),
);
},
);
}
}
class _Hint extends StatelessWidget {
const _Hint(this.text, this.tokens);
final String text;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: ClideText(text, fontSize: clideFontCaption, color: tokens.globalTextMuted),
);
}
class _QuickOpenItem extends StatefulWidget {
const _QuickOpenItem({
super.key,
required this.path,
required this.highlighted,
required this.onTap,
});
final String path;
final bool highlighted;
final VoidCallback onTap;
@override
State<_QuickOpenItem> createState() => _QuickOpenItemState();
}
class _QuickOpenItemState extends State<_QuickOpenItem> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final selected = widget.highlighted;
// Split the basename from its directory so the filename reads first.
final slash = widget.path.lastIndexOf('/');
final name = slash < 0 ? widget.path : widget.path.substring(slash + 1);
final dir = slash < 0 ? '' : widget.path.substring(0, slash);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
color: selected
? tokens.listItemSelectedBackground
: _hover
? tokens.listItemHoverBackground
: null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
ClideText(
name,
color: selected ? tokens.listItemSelectedForeground : tokens.listItemForeground,
),
const SizedBox(width: 8),
Expanded(
child: ClideText(
dir,
fontSize: clideFontCaption,
fontFamily: clideMonoFamily,
color: tokens.globalTextMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}
+1
View File
@@ -28,6 +28,7 @@ export 'src/clide_surface.dart';
export 'src/clide_tab_bar.dart';
export 'src/multitab_controller.dart';
export 'src/multitab_pane.dart';
export 'src/quick_open_overlay.dart';
export 'src/clide_tappable.dart';
export 'src/clide_text.dart';
export 'src/clide_tooltip.dart';