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:
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### 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`
|
- Workspace ignore now follows the `ignore_files:` list in `.pql/config.yaml`
|
||||||
(ordered, later-wins, per D-4) instead of a hardcoded `.gitignore` +
|
(ordered, later-wins, per D-4) instead of a hardcoded `.gitignore` +
|
||||||
`.clideignore` pair — the single ignore knob clide owns. (T-52)
|
`.clideignore` pair — the single ignore knob clide owns. (T-52)
|
||||||
|
|||||||
@@ -53,6 +53,29 @@ bindings:
|
|||||||
keys: escape
|
keys: escape
|
||||||
when: palette.open
|
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 -------------------------------------------------------
|
# -- Text scale -------------------------------------------------------
|
||||||
# On most layouts `+` is `shift+equal`; we bind both so users who
|
# 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.
|
# think of it as Ctrl+Plus and users who hit Ctrl+= both work.
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ class _RootShellState extends State<_RootShell> {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
|
||||||
|
onInvoke: (_) {
|
||||||
|
widget.services.quickOpen.open();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||||
onInvoke: (_) {
|
onInvoke: (_) {
|
||||||
widget.services.focus.focusNextSlot();
|
widget.services.focus.focusNextSlot();
|
||||||
@@ -150,6 +156,7 @@ class _RootShellState extends State<_RootShell> {
|
|||||||
children: [
|
children: [
|
||||||
const Positioned.fill(child: RootLayout()),
|
const Positioned.fill(child: RootLayout()),
|
||||||
const ClidePalette(),
|
const ClidePalette(),
|
||||||
|
const QuickOpenOverlay(),
|
||||||
const Positioned.fill(child: _WelcomeOverlay()),
|
const Positioned.fill(child: _WelcomeOverlay()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -211,20 +211,9 @@ class _FileRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openFile(BuildContext context, String path) {
|
void _openFile(BuildContext context, String path) {
|
||||||
final kernel = ClideKernel.of(context);
|
// Shared routing (T-187): .md → markdown reader, else editor.open;
|
||||||
if (path.toLowerCase().endsWith('.md')) {
|
// records the open in RecentFilesService for quick-open (T-51).
|
||||||
// Route .md files to the markdown reader panel via the kernel MessageBus.
|
openWorkspaceFile(ClideKernel.of(context), path);
|
||||||
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}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,14 +274,7 @@ class _FilteredFileRow extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
return ClideTappable(
|
return ClideTappable(
|
||||||
onTap: () {
|
onTap: () => openWorkspaceFile(ClideKernel.of(context), entry.path),
|
||||||
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}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
builder: (context, hovered, _) => Container(
|
builder: (context, hovered, _) => Container(
|
||||||
color: hovered ? tokens.sidebarItemHover : null,
|
color: hovered ? tokens.sidebarItemHover : null,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export 'src/keymap/keymap_service.dart';
|
|||||||
export 'src/keymap/when_clause.dart';
|
export 'src/keymap/when_clause.dart';
|
||||||
export 'src/dialog.dart';
|
export 'src/dialog.dart';
|
||||||
export 'src/extensions_manager.dart';
|
export 'src/extensions_manager.dart';
|
||||||
|
export 'src/file_open.dart';
|
||||||
export 'src/files.dart';
|
export 'src/files.dart';
|
||||||
export 'src/focus.dart';
|
export 'src/focus.dart';
|
||||||
export 'src/i18n/catalog_loader.dart';
|
export 'src/i18n/catalog_loader.dart';
|
||||||
@@ -39,6 +40,8 @@ export 'src/notify.dart';
|
|||||||
export 'src/os.dart';
|
export 'src/os.dart';
|
||||||
export 'src/panels/arrangement.dart';
|
export 'src/panels/arrangement.dart';
|
||||||
export 'src/project.dart';
|
export 'src/project.dart';
|
||||||
|
export 'src/quick_open.dart';
|
||||||
|
export 'src/recent_files.dart';
|
||||||
export 'src/scheduler.dart';
|
export 'src/scheduler.dart';
|
||||||
export 'src/text_zoom.dart';
|
export 'src/text_zoom.dart';
|
||||||
export 'src/secrets.dart';
|
export 'src/secrets.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/arrangement.dart';
|
||||||
import 'package:clide/kernel/src/panels/registry.dart';
|
import 'package:clide/kernel/src/panels/registry.dart';
|
||||||
import 'package:clide/kernel/src/project.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/scheduler.dart';
|
||||||
import 'package:clide/kernel/src/secrets.dart';
|
import 'package:clide/kernel/src/secrets.dart';
|
||||||
import 'package:clide/kernel/src/settings.dart';
|
import 'package:clide/kernel/src/settings.dart';
|
||||||
@@ -49,6 +51,8 @@ class KernelServices {
|
|||||||
required this.arrangement,
|
required this.arrangement,
|
||||||
required this.commands,
|
required this.commands,
|
||||||
required this.palette,
|
required this.palette,
|
||||||
|
required this.quickOpen,
|
||||||
|
required this.recentFiles,
|
||||||
required this.keybindings,
|
required this.keybindings,
|
||||||
required this.clipboard,
|
required this.clipboard,
|
||||||
required this.files,
|
required this.files,
|
||||||
@@ -79,6 +83,8 @@ class KernelServices {
|
|||||||
final LayoutArrangement arrangement;
|
final LayoutArrangement arrangement;
|
||||||
final CommandRegistry commands;
|
final CommandRegistry commands;
|
||||||
final PaletteController palette;
|
final PaletteController palette;
|
||||||
|
final QuickOpenController quickOpen;
|
||||||
|
final RecentFilesService recentFiles;
|
||||||
final KeybindingResolver keybindings;
|
final KeybindingResolver keybindings;
|
||||||
final ClideClipboard clipboard;
|
final ClideClipboard clipboard;
|
||||||
final FileServices files;
|
final FileServices files;
|
||||||
@@ -140,6 +146,8 @@ class KernelServices {
|
|||||||
final keymap = KeymapService(settings: settings, appDir: appDir);
|
final keymap = KeymapService(settings: settings, appDir: appDir);
|
||||||
await keymap.load();
|
await keymap.load();
|
||||||
final palette = PaletteController(commands);
|
final palette = PaletteController(commands);
|
||||||
|
final recentFiles = RecentFilesService();
|
||||||
|
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
|
||||||
final clipboard = ClideClipboard();
|
final clipboard = ClideClipboard();
|
||||||
final files = FileServices(events);
|
final files = FileServices(events);
|
||||||
final notify = Notifications();
|
final notify = Notifications();
|
||||||
@@ -219,6 +227,8 @@ class KernelServices {
|
|||||||
arrangement: arrangement,
|
arrangement: arrangement,
|
||||||
commands: commands,
|
commands: commands,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
|
quickOpen: quickOpen,
|
||||||
|
recentFiles: recentFiles,
|
||||||
keybindings: keybindings,
|
keybindings: keybindings,
|
||||||
clipboard: clipboard,
|
clipboard: clipboard,
|
||||||
files: files,
|
files: files,
|
||||||
@@ -248,6 +258,8 @@ class KernelServices {
|
|||||||
arrangement.dispose();
|
arrangement.dispose();
|
||||||
commands.dispose();
|
commands.dispose();
|
||||||
palette.dispose();
|
palette.dispose();
|
||||||
|
quickOpen.dispose();
|
||||||
|
recentFiles.dispose();
|
||||||
i18n.dispose();
|
i18n.dispose();
|
||||||
notify.dispose();
|
notify.dispose();
|
||||||
dialog.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();
|
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 -------------------------------------------------------------
|
// -- Text scale -------------------------------------------------------------
|
||||||
|
|
||||||
class TextScaleIncreaseIntent extends Intent {
|
class TextScaleIncreaseIntent extends Intent {
|
||||||
@@ -95,6 +118,10 @@ final Map<String, Intent Function()> builtinIntents = {
|
|||||||
'palette.selectNext': () => const PaletteSelectNextIntent(),
|
'palette.selectNext': () => const PaletteSelectNextIntent(),
|
||||||
'palette.selectPrevious': () => const PaletteSelectPreviousIntent(),
|
'palette.selectPrevious': () => const PaletteSelectPreviousIntent(),
|
||||||
'palette.accept': () => const PaletteAcceptIntent(),
|
'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.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
||||||
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
||||||
'text.scaleReset': () => const TextScaleResetIntent(),
|
'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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ export 'src/clide_surface.dart';
|
|||||||
export 'src/clide_tab_bar.dart';
|
export 'src/clide_tab_bar.dart';
|
||||||
export 'src/multitab_controller.dart';
|
export 'src/multitab_controller.dart';
|
||||||
export 'src/multitab_pane.dart';
|
export 'src/multitab_pane.dart';
|
||||||
|
export 'src/quick_open_overlay.dart';
|
||||||
export 'src/clide_tappable.dart';
|
export 'src/clide_tappable.dart';
|
||||||
export 'src/clide_text.dart';
|
export 'src/clide_text.dart';
|
||||||
export 'src/clide_tooltip.dart';
|
export 'src/clide_tooltip.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<String> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ void main() {
|
|||||||
expect(parseIntentId('palette.accept'), isA<PaletteAcceptIntent>());
|
expect(parseIntentId('palette.accept'), isA<PaletteAcceptIntent>());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns the quickOpen.* intents', () {
|
||||||
|
expect(parseIntentId('quickOpen.open'), isA<QuickOpenIntent>());
|
||||||
|
expect(parseIntentId('quickOpen.selectNext'), isA<QuickOpenSelectNextIntent>());
|
||||||
|
expect(parseIntentId('quickOpen.selectPrevious'), isA<QuickOpenSelectPreviousIntent>());
|
||||||
|
expect(parseIntentId('quickOpen.accept'), isA<QuickOpenAcceptIntent>());
|
||||||
|
});
|
||||||
|
|
||||||
test('returns the text.scale* intents', () {
|
test('returns the text.scale* intents', () {
|
||||||
expect(parseIntentId('text.scaleIncrease'), isA<TextScaleIncreaseIntent>());
|
expect(parseIntentId('text.scaleIncrease'), isA<TextScaleIncreaseIntent>());
|
||||||
expect(parseIntentId('text.scaleDecrease'), isA<TextScaleDecreaseIntent>());
|
expect(parseIntentId('text.scaleDecrease'), isA<TextScaleDecreaseIntent>());
|
||||||
|
|||||||
@@ -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 = <Message>[];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user