From 0c7a6e86d5eba1643a06e85eab7be18b49c8bb1b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 31 May 2026 20:19:21 +0200 Subject: [PATCH] add quick-open fuzzy file finder (Ctrl/Cmd+P) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 4 + assets/keymaps/default.yaml | 23 ++ lib/app.dart | 7 + lib/builtin/files/src/file_tree_view.dart | 26 +- lib/kernel/kernel.dart | 3 + lib/kernel/src/facade.dart | 12 + lib/kernel/src/file_open.dart | 23 ++ lib/kernel/src/keymap/intents.dart | 27 ++ lib/kernel/src/quick_open.dart | 152 +++++++++++ lib/kernel/src/recent_files.dart | 35 +++ lib/widgets/src/quick_open_overlay.dart | 318 ++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + test/kernel/quick_open_test.dart | 100 +++++++ test/kernel/recent_files_test.dart | 56 ++++ test/kernel/src/keymap/intents_test.dart | 7 + test/widgets/quick_open_overlay_test.dart | 93 +++++++ 16 files changed, 865 insertions(+), 22 deletions(-) create mode 100644 lib/kernel/src/file_open.dart create mode 100644 lib/kernel/src/quick_open.dart create mode 100644 lib/kernel/src/recent_files.dart create mode 100644 lib/widgets/src/quick_open_overlay.dart create mode 100644 test/kernel/quick_open_test.dart create mode 100644 test/kernel/recent_files_test.dart create mode 100644 test/widgets/quick_open_overlay_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ff64d588..773bf451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Quick-open file finder (Ctrl/Cmd+P): a fuzzy file picker overlay over the + whole workspace, separate from the command palette. Empty query lists recent + files; Enter opens `.md` in the markdown reader and other files in the editor. + (T-51) - Workspace ignore now follows the `ignore_files:` list in `.pql/config.yaml` (ordered, later-wins, per D-4) instead of a hardcoded `.gitignore` + `.clideignore` pair — the single ignore knob clide owns. (T-52) diff --git a/assets/keymaps/default.yaml b/assets/keymaps/default.yaml index 172fb5b3..22915bc9 100644 --- a/assets/keymaps/default.yaml +++ b/assets/keymaps/default.yaml @@ -53,6 +53,29 @@ bindings: keys: escape when: palette.open + # -- Quick open (fuzzy file finder) ----------------------------------- + # ctrl+p / meta+p are free while the palette is closed; the palette + # only claims ctrl+p for selectPrevious `when: palette.open`, so this + # is conflict-free. Arrow/enter/escape inside the overlay are handled + # locally by the widget. + - intent: quickOpen.open + keys: [ctrl+p, meta+p] + when: "!palette.open" + # Nav stays on arrows/ctrl+n (not ctrl+p — that's the open chord and + # would collide while the overlay is up). + - intent: quickOpen.selectNext + keys: [down, ctrl+n] + when: quickOpen.open + - intent: quickOpen.selectPrevious + keys: up + when: quickOpen.open + - intent: quickOpen.accept + keys: enter + when: quickOpen.open + - intent: dismiss + keys: escape + when: quickOpen.open + # -- Text scale ------------------------------------------------------- # On most layouts `+` is `shift+equal`; we bind both so users who # think of it as Ctrl+Plus and users who hit Ctrl+= both work. diff --git a/lib/app.dart b/lib/app.dart index 5a556dc7..6b151b1c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -119,6 +119,12 @@ class _RootShellState extends State<_RootShell> { return null; }, ), + QuickOpenIntent: CallbackAction( + onInvoke: (_) { + widget.services.quickOpen.open(); + return null; + }, + ), FocusNextPanelIntent: CallbackAction( 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()), ], ), diff --git a/lib/builtin/files/src/file_tree_view.dart b/lib/builtin/files/src/file_tree_view.dart index 764dbce5..42a04f3a 100644 --- a/lib/builtin/files/src/file_tree_view.dart +++ b/lib/builtin/files/src/file_tree_view.dart @@ -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), diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 0550c284..4ff2a0ae 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -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'; diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 4b3cb582..a2414dae 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -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(); diff --git a/lib/kernel/src/file_open.dart b/lib/kernel/src/file_open.dart new file mode 100644 index 00000000..39dce2cd --- /dev/null +++ b/lib/kernel/src/file_open.dart @@ -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})); + } +} diff --git a/lib/kernel/src/keymap/intents.dart b/lib/kernel/src/keymap/intents.dart index 6a834bbc..ec2d5362 100644 --- a/lib/kernel/src/keymap/intents.dart +++ b/lib/kernel/src/keymap/intents.dart @@ -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 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(), diff --git a/lib/kernel/src/quick_open.dart b/lib/kernel/src/quick_open.dart new file mode 100644 index 00000000..424fdc3c --- /dev/null +++ b/lib/kernel/src/quick_open.dart @@ -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 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 _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 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 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; +} diff --git a/lib/kernel/src/recent_files.dart b/lib/kernel/src/recent_files.dart new file mode 100644 index 00000000..e0cee0fd --- /dev/null +++ b/lib/kernel/src/recent_files.dart @@ -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 _paths = []; + + /// Most-recent-first snapshot of the retained paths. + List 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(); + } +} diff --git a/lib/widgets/src/quick_open_overlay.dart b/lib/widgets/src/quick_open_overlay.dart new file mode 100644 index 00000000..fad4b99e --- /dev/null +++ b/lib/widgets/src/quick_open_overlay.dart @@ -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 createState() => _QuickOpenOverlayState(); +} + +class _QuickOpenOverlayState extends State { + final _input = TextEditingController(); + final _focus = FocusNode(debugLabel: 'QuickOpenOverlay.input'); + final _itemKeys = {}; + + 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 _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() : []; + 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: >{ + QuickOpenSelectNextIntent: CallbackAction(onInvoke: _selectNext), + QuickOpenSelectPreviousIntent: CallbackAction(onInvoke: _selectPrev), + QuickOpenAcceptIntent: CallbackAction(onInvoke: _accept), + DismissIntent: CallbackAction(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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index a38e2aa6..68c6ce0a 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/test/kernel/quick_open_test.dart b/test/kernel/quick_open_test.dart new file mode 100644 index 00000000..5c24d6e6 --- /dev/null +++ b/test/kernel/quick_open_test.dart @@ -0,0 +1,100 @@ +/// Unit tests for [QuickOpenController] — state, fuzzy ranking, recents +/// fallback, and selection wrapping (T-51). +library; + +import 'package:clide/kernel/src/quick_open.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late List recents; + QuickOpenController make() => QuickOpenController(recentPaths: () => recents); + + setUp(() => recents = ['recent_a.dart', 'recent_b.dart']); + + test('open/close/toggle track isOpen and reset the filter', () { + final c = make(); + expect(c.isOpen, isFalse); + c.open(); + expect(c.isOpen, isTrue); + c.setFilter('main'); + c.close(); + expect(c.isOpen, isFalse); + expect(c.filter, isEmpty); + c.toggle(); + expect(c.isOpen, isTrue); + }); + + test('empty query shows recents', () { + final c = make()..open(); + c.setFiles(['lib/main.dart', 'lib/app.dart']); + expect(c.filtered(), recents); + expect(c.selectedPath, 'recent_a.dart'); + }); + + test('non-empty query fuzzy-matches the file list, not recents', () { + final c = make()..open(); + c.setFiles(['lib/main.dart', 'lib/app.dart', 'README.md']); + c.setFilter('main'); + expect(c.filtered(), contains('lib/main.dart')); + expect(c.filtered(), isNot(contains('README.md'))); + }); + + test('subsequence match ranks contiguous/early hits above scattered', () { + final c = make()..open(); + c.setFiles(['x/abc_extra.dart', 'abc.dart', 'a_b_c.dart']); + c.setFilter('abc'); + // 'abc.dart' (contiguous, earliest) should rank first. + expect(c.filtered().first, 'abc.dart'); + }); + + test('non-matching query yields an empty result list', () { + final c = make()..open(); + c.setFiles(['lib/main.dart']); + c.setFilter('zzzzz'); + expect(c.filtered(), isEmpty); + expect(c.selectedPath, isNull); + }); + + test('selectNext/Previous wrap around the result list', () { + final c = make()..open(); + c.setFiles(['a.dart', 'b.dart', 'c.dart']); + c.setFilter('dart'); // matches all three + expect(c.selectedIndex, 0); + c.selectPrevious(); // wraps to last + expect(c.selectedIndex, c.filtered().length - 1); + c.selectNext(); // wraps back to 0 + expect(c.selectedIndex, 0); + }); + + test('selection is a no-op with fewer than two results', () { + final c = make()..open(); + c.setFiles(['only.dart']); + c.setFilter('only'); + c.selectNext(); + expect(c.selectedIndex, 0); + }); + + test('setFiles records truncation', () { + final c = make()..open(); + c.setFiles(['a.dart'], truncated: true); + expect(c.truncated, isTrue); + }); + + test('result list is capped at resultCap', () { + final c = make()..open(); + c.setFiles([for (var i = 0; i < QuickOpenController.resultCap + 50; i++) 'f$i.dart']); + c.setFilter('dart'); + expect(c.filtered(), hasLength(QuickOpenController.resultCap)); + }); + + test('setLoading toggles the flag and notifies', () { + final c = make(); + var n = 0; + c.addListener(() => n++); + c.setLoading(true); + expect(c.isLoading, isTrue); + expect(n, 1); + c.setLoading(true); // no change → no notify + expect(n, 1); + }); +} diff --git a/test/kernel/recent_files_test.dart b/test/kernel/recent_files_test.dart new file mode 100644 index 00000000..68b2e3ed --- /dev/null +++ b/test/kernel/recent_files_test.dart @@ -0,0 +1,56 @@ +/// Unit tests for [RecentFilesService] — the session-scoped recent +/// files list backing quick-open's empty-query state (T-51). +library; + +import 'package:clide/kernel/src/recent_files.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('push records most-recent-first', () { + final r = RecentFilesService(); + r.push('a.dart'); + r.push('b.dart'); + expect(r.paths, ['b.dart', 'a.dart']); + }); + + test('push de-duplicates and moves an existing entry to the front', () { + final r = RecentFilesService(); + r.push('a.dart'); + r.push('b.dart'); + r.push('a.dart'); + expect(r.paths, ['a.dart', 'b.dart']); + }); + + test('trims to the cap, dropping the oldest', () { + final r = RecentFilesService(cap: 2); + r.push('a'); + r.push('b'); + r.push('c'); + expect(r.paths, ['c', 'b']); + }); + + test('empty path is ignored', () { + final r = RecentFilesService(); + r.push(''); + expect(r.paths, isEmpty); + }); + + test('clear empties the list and notifies once', () { + final r = RecentFilesService(); + var notifications = 0; + r.addListener(() => notifications++); + r.push('a'); + r.clear(); + expect(r.paths, isEmpty); + expect(notifications, 2); + // A second clear on an empty list is a no-op (no extra notify). + r.clear(); + expect(notifications, 2); + }); + + test('paths is an unmodifiable snapshot', () { + final r = RecentFilesService(); + r.push('a'); + expect(() => r.paths.add('b'), throwsUnsupportedError); + }); +} diff --git a/test/kernel/src/keymap/intents_test.dart b/test/kernel/src/keymap/intents_test.dart index e7c51daa..a01eee09 100644 --- a/test/kernel/src/keymap/intents_test.dart +++ b/test/kernel/src/keymap/intents_test.dart @@ -28,6 +28,13 @@ void main() { expect(parseIntentId('palette.accept'), isA()); }); + test('returns the quickOpen.* intents', () { + expect(parseIntentId('quickOpen.open'), isA()); + expect(parseIntentId('quickOpen.selectNext'), isA()); + expect(parseIntentId('quickOpen.selectPrevious'), isA()); + expect(parseIntentId('quickOpen.accept'), isA()); + }); + test('returns the text.scale* intents', () { expect(parseIntentId('text.scaleIncrease'), isA()); expect(parseIntentId('text.scaleDecrease'), isA()); diff --git a/test/widgets/quick_open_overlay_test.dart b/test/widgets/quick_open_overlay_test.dart new file mode 100644 index 00000000..3d5e6657 --- /dev/null +++ b/test/widgets/quick_open_overlay_test.dart @@ -0,0 +1,93 @@ +/// Widget tests for [QuickOpenOverlay] — loads the file list via the +/// stubbed `files.walk`, routes a selection through [openWorkspaceFile] +/// (.md → markdown reader bus, else editor.open), and records recents +/// (T-51). +library; + +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + f.ipc.stub('files.walk', (_) async => IpcResponse.ok(id: '1', data: const { + 'files': ['lib/main.dart', 'lib/app.dart', 'README.md'], + 'truncated': false, + })); + f.ipc.stub('editor.open', (args) async => IpcResponse.ok(id: '1', data: {'path': args['path']})); + }); + tearDown(() => f.dispose()); + + testWidgets('hidden until opened, then loads files and filters', (tester) async { + await tester.pumpWidget(harness(f, const QuickOpenOverlay())); + expect(find.byType(EditableText), findsNothing); + + f.services.quickOpen.open(); + await pumpAsync(tester); + // File list loaded from files.walk. + expect(f.services.quickOpen.truncated, isFalse); + + await tester.enterText(find.byType(EditableText), 'app'); + await pumpAsync(tester); + expect(find.text('app.dart'), findsOneWidget); + expect(find.text('main.dart'), findsNothing); + }); + + testWidgets('tapping a non-md result opens the editor and records a recent', (tester) async { + String? opened; + f.ipc.stub('editor.open', (args) async { + opened = args['path'] as String?; + return IpcResponse.ok(id: '1', data: {'path': args['path']}); + }); + await tester.pumpWidget(harness(f, const QuickOpenOverlay())); + f.services.quickOpen.open(); + await pumpAsync(tester); + await tester.enterText(find.byType(EditableText), 'main'); + await pumpAsync(tester); + + await tester.tap(find.text('main.dart')); + await pumpAsync(tester); + + expect(opened, 'lib/main.dart'); + expect(f.services.quickOpen.isOpen, isFalse); + expect(f.services.recentFiles.paths, contains('lib/main.dart')); + }); + + testWidgets('tapping an md result publishes to the markdown reader bus', (tester) async { + final published = []; + final sub = f.services.messages + .subscribe(publisher: 'builtin.markdown', channel: 'selection') + .listen(published.add); + addTearDown(sub.cancel); + + await tester.pumpWidget(harness(f, const QuickOpenOverlay())); + f.services.quickOpen.open(); + await pumpAsync(tester); + await tester.enterText(find.byType(EditableText), 'readme'); + await pumpAsync(tester); + + await tester.tap(find.text('README.md')); + await pumpAsync(tester); + + expect(published, hasLength(1)); + expect(published.first.data['path'], 'README.md'); + expect(f.services.recentFiles.paths, contains('README.md')); + }); + + testWidgets('empty query shows recents once any file has been opened', (tester) async { + f.services.recentFiles.push('lib/app.dart'); + await tester.pumpWidget(harness(f, const QuickOpenOverlay())); + f.services.quickOpen.open(); + await pumpAsync(tester); + // Empty query → recents listed. + expect(find.text('app.dart'), findsOneWidget); + }); +}