feat(vim): ex command-line overlay (:w :q :wq :x :e :N, ZZ) (T-407)
Under the Vim preset, `:` opens a transient one-line ex overlay running a fixed v1 table; ZZ runs :wq directly. Completes the last built child of the T-403 cross-pane vim layer (T-405 part 2 gt/gT still open). - ExLineController + parseExCommand grammar + editor-targeted executors (lib/kernel/src/ex_line.dart); the overlay (lib/widgets/src/ex_line_overlay .dart) reuses the quick-open chrome, mounts in the root_shell Stack, and publishes the exline.open scope flag. Unknown commands flash + stay open; with no active buffer every command no-ops (2026-06-13 decision). - :q closes the active tab via editor.close on its id — the registry promotes the next buffer and the split self-collapses on the last (2026-06-12 decision); :w/:wq/:x/ZZ save (+close) the active buffer. - :e <path> seeds quick-open (new QuickOpenController.open(seed:)); :N adds the editor.goto-line IPC/CLI verb (reuses _offsetForLine). Goto needs caret sync: EditorController now handles editor.selection-changed and the editor view moves the caret on a selection-only change. - `:` and ZZ are typed intents; the editor matcher and PaneKeyNav now bubble unhandled typed intents to the app-root Actions, so they fire from any focus. vim.yaml binds `:`, ZZ (shift+z shift+z), and Esc-dismiss. Tests: parser/controller/executors, editor.goto-line daemon tests, selection-changed (controller + view), full overlay widget test. make test green; analyze + format clean. Also files T-441 (drop bold from the ticket-id card label) and T-442 (sub-agent renders as 3 cards instead of one bundle) under the T-276 UI epic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
case 'editor.selection-changed':
|
||||
// An external setSelection moved the caret server-side (find-in-files
|
||||
// line jump, ex-line `:N` goto — T-407). Mirror it onto the active
|
||||
// buffer so the view's caret follows. Skipped while our own local edits
|
||||
// are in flight — their echo already carries the authoritative caret.
|
||||
final id = e.data['id'] as String?;
|
||||
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
|
||||
final sel = e.data['selection'];
|
||||
if (sel is Map) {
|
||||
_selection = Selection.fromJson(sel.cast<String, Object?>());
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
case 'editor.settings-changed':
|
||||
// A source (e.g. a saved .editorconfig) re-resolved the buffer's
|
||||
// settings. Refresh the active buffer's copy so indent/ruler update.
|
||||
|
||||
@@ -108,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
|
||||
final c = _controller!;
|
||||
_syncTabs(c);
|
||||
_text.updatePath(c.activePath);
|
||||
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
|
||||
if (c.content != _lastRemoteContent) {
|
||||
_lastRemoteContent = c.content;
|
||||
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.value = TextEditingValue(text: c.content, selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
} else if (sel != _text.value.selection) {
|
||||
// Selection-only change from an external setSelection (ex-line `:N` goto,
|
||||
// find-in-files line jump on the already-active buffer) — content is
|
||||
// unchanged, so move just the caret. The focused field scrolls it into
|
||||
// view (T-407).
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.value = _text.value.copyWith(selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
}
|
||||
setState(() {}); // tab/title refresh
|
||||
}
|
||||
@@ -274,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
|
||||
}
|
||||
|
||||
void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) {
|
||||
if (intent is! InvokeCommandIntent) return;
|
||||
if (intent is! InvokeCommandIntent) {
|
||||
// A typed app intent the matcher fired (e.g. the ex-line `:` open or ZZ).
|
||||
// The editor only owns editor.vim.* / mode commands; bubble anything else
|
||||
// to the app-root Actions so it reaches its global handler (T-407).
|
||||
Actions.maybeInvoke(context, intent);
|
||||
return;
|
||||
}
|
||||
final id = intent.commandId;
|
||||
if (!id.startsWith('editor.vim.')) {
|
||||
// Mode change (vim.mode.*) or any other command.
|
||||
|
||||
@@ -34,6 +34,7 @@ export 'src/keymap/pane_key_nav.dart';
|
||||
export 'src/keymap/sequence_matcher.dart';
|
||||
export 'src/keymap/when_clause.dart';
|
||||
export 'src/dialog.dart';
|
||||
export 'src/ex_line.dart';
|
||||
export 'src/extensions_manager.dart';
|
||||
export 'src/file_open.dart';
|
||||
export 'src/files.dart';
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/// State + grammar + execution for the Vim ex command-line overlay (T-407).
|
||||
///
|
||||
/// `:` (under `vim.normal`) opens a transient one-line prompt that runs a
|
||||
/// small, fixed table of ex commands (`:w` `:q` `:wq` `:x` `:e <path>` `:N`).
|
||||
/// It is NOT a vim *mode* — it's an overlay with its own `exline.open` scope
|
||||
/// flag, dismissed with Esc, exactly the deferral `vim_mode_service.dart`
|
||||
/// always named. The controller mirrors [QuickOpenController]'s open/close
|
||||
/// shape so the overlay can reuse the quick-open chrome.
|
||||
///
|
||||
/// The command grammar ([parseExCommand]) is a pure switch — no parser, no
|
||||
/// vimscript. Execution ([exWriteActive] etc.) goes through the editor IPC
|
||||
/// verbs (the daemon is the source of truth for buffer state), so every ex
|
||||
/// command is editor-targeted and no-ops when no buffer is active (the
|
||||
/// 2026-06-13 decision on T-407).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart' show IpcResponse;
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ExLineController extends ChangeNotifier {
|
||||
bool _open = false;
|
||||
String _input = '';
|
||||
// Bumped each time an unknown command is rejected so the overlay can flash
|
||||
// without closing. A monotonic nonce (not a bool) keeps repeated rejections
|
||||
// individually observable by a listener.
|
||||
int _invalidNonce = 0;
|
||||
|
||||
bool get isOpen => _open;
|
||||
String get input => _input;
|
||||
|
||||
/// Increments whenever a typed command is rejected ([flashInvalid]); the
|
||||
/// overlay watches it to flash the input and stay open.
|
||||
int get invalidNonce => _invalidNonce;
|
||||
|
||||
void open() {
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
_input = '';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
_input = '';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setInput(String value) {
|
||||
if (_input == value) return;
|
||||
_input = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Signal that the submitted command was unknown — the overlay flashes and
|
||||
/// stays open rather than executing or dismissing.
|
||||
void flashInvalid() {
|
||||
_invalidNonce++;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grammar ---------------------------------------------------------------
|
||||
|
||||
/// One parsed ex command. v1 table; anything off it is [ExUnknown].
|
||||
sealed class ExCommand {
|
||||
const ExCommand();
|
||||
}
|
||||
|
||||
/// Empty input (`:` then Enter) — dismiss with no action.
|
||||
class ExNoop extends ExCommand {
|
||||
const ExNoop();
|
||||
}
|
||||
|
||||
/// `:w` — write (save) the active buffer.
|
||||
class ExWrite extends ExCommand {
|
||||
const ExWrite();
|
||||
}
|
||||
|
||||
/// `:q` (and `:q!`) — close the active editor tab.
|
||||
class ExQuit extends ExCommand {
|
||||
const ExQuit();
|
||||
}
|
||||
|
||||
/// `:wq` / `:x` (and bang variants) and `ZZ` — save then close the active tab.
|
||||
class ExWriteQuit extends ExCommand {
|
||||
const ExWriteQuit();
|
||||
}
|
||||
|
||||
/// `:e <path>` — open quick-open seeded with `<path>` (empty seed allowed).
|
||||
class ExEdit extends ExCommand {
|
||||
const ExEdit(this.query);
|
||||
final String query;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is ExEdit && other.query == query;
|
||||
@override
|
||||
int get hashCode => query.hashCode;
|
||||
}
|
||||
|
||||
/// `:<n>` — jump the active buffer to 1-based line `<n>`.
|
||||
class ExGoto extends ExCommand {
|
||||
const ExGoto(this.line);
|
||||
final int line;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is ExGoto && other.line == line;
|
||||
@override
|
||||
int get hashCode => line.hashCode;
|
||||
}
|
||||
|
||||
/// Anything not on the v1 table — the overlay flashes and stays open.
|
||||
class ExUnknown extends ExCommand {
|
||||
const ExUnknown();
|
||||
}
|
||||
|
||||
/// Parse the text typed after `:` into an [ExCommand]. A leading colon is
|
||||
/// tolerated (in case the user types it). v1 grammar only.
|
||||
ExCommand parseExCommand(String raw) {
|
||||
var s = raw.trim();
|
||||
if (s.startsWith(':')) s = s.substring(1).trim();
|
||||
if (s.isEmpty) return const ExNoop();
|
||||
|
||||
// `:e` / `:e <path>` — everything after the first token seeds quick-open.
|
||||
if (s == 'e') return const ExEdit('');
|
||||
if (s.startsWith('e ')) return ExEdit(s.substring(2).trim());
|
||||
|
||||
switch (s) {
|
||||
case 'w':
|
||||
return const ExWrite();
|
||||
case 'q' || 'q!':
|
||||
// No dirty-guard in v1, so `q!` is just `q`.
|
||||
return const ExQuit();
|
||||
case 'wq' || 'wq!' || 'x' || 'x!':
|
||||
return const ExWriteQuit();
|
||||
}
|
||||
|
||||
final line = int.tryParse(s);
|
||||
if (line != null && line >= 1) return ExGoto(line);
|
||||
|
||||
return const ExUnknown();
|
||||
}
|
||||
|
||||
// --- Execution (editor-targeted; no-op when no active buffer) ---------------
|
||||
|
||||
/// The active editor buffer id, or null when no buffer is active.
|
||||
Future<String?> activeEditorBufferId(DaemonClient ipc) async {
|
||||
final IpcResponse r = await ipc.request('editor.active');
|
||||
if (!r.ok) return null;
|
||||
final active = r.data['active'];
|
||||
return active is Map ? active['id'] as String? : null;
|
||||
}
|
||||
|
||||
/// `:w` — save the active buffer. `editor.save` resolves the active buffer
|
||||
/// server-side, so a missing buffer is a silent no-op.
|
||||
Future<void> exWriteActive(DaemonClient ipc) async {
|
||||
await ipc.request('editor.save');
|
||||
}
|
||||
|
||||
/// `:q` — close the active editor tab. The registry promotes the next buffer
|
||||
/// (or collapses the split on the last one) — no separate split-close needed.
|
||||
Future<void> exQuitActive(DaemonClient ipc) async {
|
||||
final id = await activeEditorBufferId(ipc);
|
||||
if (id == null) return;
|
||||
await ipc.request('editor.close', args: {'id': id});
|
||||
}
|
||||
|
||||
/// `:wq` / `:x` / `ZZ` — save the active buffer then close its tab.
|
||||
Future<void> exWriteQuitActive(DaemonClient ipc) async {
|
||||
final id = await activeEditorBufferId(ipc);
|
||||
if (id == null) return;
|
||||
await ipc.request('editor.save', args: {'id': id});
|
||||
await ipc.request('editor.close', args: {'id': id});
|
||||
}
|
||||
|
||||
/// `:<n>` — jump the active buffer to 1-based [line]. `editor.goto-line`
|
||||
/// resolves the active buffer server-side and clamps out-of-range lines.
|
||||
Future<void> exGotoLineActive(DaemonClient ipc, int line) async {
|
||||
await ipc.request('editor.goto-line', args: {'line': line});
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import 'package:clide/kernel/src/notify.dart';
|
||||
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/ex_line.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/quick_open.dart';
|
||||
import 'package:clide/kernel/src/reader_nav.dart';
|
||||
@@ -58,6 +59,7 @@ class KernelServices {
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.quickOpen,
|
||||
required this.exLine,
|
||||
required this.recentFiles,
|
||||
required this.readerNav,
|
||||
required this.keybindings,
|
||||
@@ -97,6 +99,9 @@ class KernelServices {
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final QuickOpenController quickOpen;
|
||||
|
||||
/// Transient Vim ex command-line overlay state (T-407).
|
||||
final ExLineController exLine;
|
||||
final RecentFilesService recentFiles;
|
||||
final ReaderNavRegistry readerNav;
|
||||
final KeybindingResolver keybindings;
|
||||
@@ -167,6 +172,7 @@ class KernelServices {
|
||||
final palette = PaletteController(commands);
|
||||
final recentFiles = RecentFilesService();
|
||||
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
|
||||
final exLine = ExLineController();
|
||||
final readerNav = ReaderNavRegistry(messages);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
@@ -253,6 +259,7 @@ class KernelServices {
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
quickOpen: quickOpen,
|
||||
exLine: exLine,
|
||||
recentFiles: recentFiles,
|
||||
readerNav: readerNav,
|
||||
keybindings: keybindings,
|
||||
@@ -286,6 +293,7 @@ class KernelServices {
|
||||
commands.dispose();
|
||||
palette.dispose();
|
||||
quickOpen.dispose();
|
||||
exLine.dispose();
|
||||
toast.dispose();
|
||||
recentFiles.dispose();
|
||||
readerNav.dispose();
|
||||
|
||||
@@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent {
|
||||
const QuickOpenAcceptIntent();
|
||||
}
|
||||
|
||||
// -- Vim ex command-line (T-407) --------------------------------------------
|
||||
|
||||
/// Open the Vim ex command-line overlay (`:`). A typed intent (not a
|
||||
/// `command:` bridge) so it survives the editor's command-mode matcher and a
|
||||
/// focused pane's nav matcher, both of which bubble unhandled typed intents to
|
||||
/// the app-root Actions where this resolves to `services.exLine.open()`.
|
||||
class ExLineOpenIntent extends Intent {
|
||||
const ExLineOpenIntent();
|
||||
}
|
||||
|
||||
/// Save the active buffer and close its tab (`ZZ`), without opening the
|
||||
/// overlay — shares the `:wq` execution path.
|
||||
class ExLineWriteQuitIntent extends Intent {
|
||||
const ExLineWriteQuitIntent();
|
||||
}
|
||||
|
||||
// -- Find in files ----------------------------------------------------------
|
||||
|
||||
/// Reveal the find-in-files search panel in the sidebar.
|
||||
@@ -193,6 +209,8 @@ final Map<String, Intent Function()> builtinIntents = {
|
||||
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
||||
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
||||
'findInFiles.open': () => const FindInFilesIntent(),
|
||||
'exline.open': () => const ExLineOpenIntent(),
|
||||
'exline.writeQuit': () => const ExLineWriteQuitIntent(),
|
||||
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
|
||||
'nav.down': () => const NavDownIntent(),
|
||||
'nav.up': () => const NavUpIntent(),
|
||||
|
||||
@@ -102,9 +102,16 @@ class _PaneKeyNavState extends State<PaneKeyNav> {
|
||||
switch (r.outcome) {
|
||||
case SeqOutcome.fired:
|
||||
// The vim preset also binds these keys to editor.vim.* motions; in a
|
||||
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
|
||||
// editor.vim.* with no focus guard) is swallowed, never executed here.
|
||||
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
|
||||
// pane only nav.* applies, and editor.vim.* (an InvokeCommandIntent) is
|
||||
// swallowed — never run buffer edits from a pane. A typed *app* intent
|
||||
// (e.g. the ex-line `:` / ZZ) bubbles to the app-root Actions for its
|
||||
// global handler (T-407).
|
||||
final fired = r.intent;
|
||||
if (fired is NavIntent) {
|
||||
widget.onNav(fired, r.count);
|
||||
} else if (fired != null && fired is! InvokeCommandIntent) {
|
||||
Actions.maybeInvoke(context, fired);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
case SeqOutcome.pending:
|
||||
return KeyEventResult.handled;
|
||||
|
||||
@@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier {
|
||||
return _selectedIndex.clamp(0, n - 1);
|
||||
}
|
||||
|
||||
void open() {
|
||||
/// Open the picker. An optional [seed] pre-fills the filter — used by the
|
||||
/// ex-line `:e <path>` command (T-407) to jump straight to a query.
|
||||
void open({String? seed}) {
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
_filter = '';
|
||||
_filter = seed ?? '';
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/// Verb list matches CLAUDE.md's tier-2 surface:
|
||||
/// editor.open editor.active editor.activate editor.insert
|
||||
/// editor.replace-selection editor.save editor.close editor.list
|
||||
/// editor.read editor.set-selection editor.set-content
|
||||
/// editor.read editor.set-selection editor.set-content editor.goto-line
|
||||
///
|
||||
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
|
||||
/// one-to-one onto these via the IPC dispatch layer.
|
||||
@@ -50,6 +50,14 @@ void registerEditorCommands(DaemonDispatcher d, EditorRegistry registry) {
|
||||
d.register('editor.set-content', (req) => _setContent(req, registry));
|
||||
d.register('editor.save', (req) => _save(req, registry), schema: _idArg);
|
||||
d.register('editor.close', (req) => _close(req, registry), schema: _idArg);
|
||||
d.register(
|
||||
'editor.goto-line',
|
||||
(req) => _gotoLine(req, registry),
|
||||
schema: const CommandSchema(
|
||||
positional: ['line'],
|
||||
args: {'line': ArgSpec(type: ArgType.number)},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err(
|
||||
@@ -220,3 +228,19 @@ Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
|
||||
r.close(id);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id});
|
||||
}
|
||||
|
||||
/// Jump the active (or [id]'d) buffer's caret to the start of a 1-based line —
|
||||
/// the ex-line `:N` goto (T-407) and the CLI `clide editor goto-line <n>`.
|
||||
/// Reuses the [_offsetForLine] mapping `editor.open --line` uses; out-of-range
|
||||
/// lines clamp to the buffer end via setSelection.
|
||||
Future<IpcResponse> _gotoLine(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
final buf = r.get(id);
|
||||
if (buf == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
final rawLine = req.args['line'];
|
||||
final line = rawLine is num ? rawLine.toInt() : int.tryParse('$rawLine');
|
||||
if (line == null || line < 1) return _userErr(req.id, 'line must be a positive integer');
|
||||
r.setSelection(id, Selection.collapsed(_offsetForLine(buf.content, line)));
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'line': line});
|
||||
}
|
||||
|
||||
@@ -120,6 +120,19 @@ class RootShellState extends State<RootShell> {
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ExLineOpenIntent: CallbackAction<ExLineOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.exLine.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ExLineWriteQuitIntent: CallbackAction<ExLineWriteQuitIntent>(
|
||||
onInvoke: (_) {
|
||||
// ZZ — save+close the active tab without opening the overlay.
|
||||
unawaited(exWriteQuitActive(widget.services.ipc));
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||
@@ -160,6 +173,7 @@ class RootShellState extends State<RootShell> {
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const ExLineOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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';
|
||||
|
||||
/// The Vim ex command-line overlay (T-407): a transient one-line `:` prompt
|
||||
/// that runs the fixed v1 command table ([parseExCommand]). Modeled on the
|
||||
/// quick-open chrome but single-line and result-less.
|
||||
///
|
||||
/// It is NOT a vim mode — while open it publishes the `exline.open` scope flag
|
||||
/// (so `vim.yaml` can bind Esc → dismiss for it alone) and dismisses back to
|
||||
/// normal mode with no mode churn. The `:` and `ZZ` entries open it / run
|
||||
/// `:wq` from the keymap; this widget owns the input, dispatch, and the
|
||||
/// rejected-command hint. Always mounted (like quick-open); inert unless the
|
||||
/// keymap opens it, which only `vim.yaml` does.
|
||||
class ExLineOverlay extends StatefulWidget {
|
||||
const ExLineOverlay({super.key});
|
||||
|
||||
@override
|
||||
State<ExLineOverlay> createState() => _ExLineOverlayState();
|
||||
}
|
||||
|
||||
class _ExLineOverlayState extends State<ExLineOverlay> {
|
||||
final _input = TextEditingController();
|
||||
final _focus = FocusNode(debugLabel: 'ExLineOverlay.input');
|
||||
|
||||
ExLineController? _exLine;
|
||||
KeymapService? _keymap;
|
||||
KernelServices? _services;
|
||||
|
||||
/// True after a rejected (unknown) command, until the user edits the input.
|
||||
/// Drives the red border + hint instead of a timed flash (test-friendly).
|
||||
bool _rejected = false;
|
||||
int _seenInvalidNonce = 0;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final kernel = ClideKernel.of(context);
|
||||
_services = kernel;
|
||||
if (!identical(_exLine, kernel.exLine)) {
|
||||
_exLine?.removeListener(_onChanged);
|
||||
_exLine = kernel.exLine;
|
||||
_seenInvalidNonce = _exLine!.invalidNonce;
|
||||
_exLine!.addListener(_onChanged);
|
||||
_syncFromController();
|
||||
}
|
||||
_keymap = kernel.keymap;
|
||||
final isOpen = _exLine?.isOpen ?? false;
|
||||
_keymap?.setScopeFlag('exline.open', isOpen);
|
||||
if (isOpen && !_focus.hasFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && (_exLine?.isOpen ?? false)) _focus.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_exLine?.removeListener(_onChanged);
|
||||
_keymap?.clearScopeFlag('exline.open');
|
||||
_input.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() {
|
||||
final controller = _exLine;
|
||||
final isOpen = controller?.isOpen ?? false;
|
||||
_keymap?.setScopeFlag('exline.open', isOpen);
|
||||
if (isOpen) {
|
||||
_focus.requestFocus();
|
||||
} else {
|
||||
_rejected = false;
|
||||
}
|
||||
if (controller != null && controller.invalidNonce != _seenInvalidNonce) {
|
||||
_seenInvalidNonce = controller.invalidNonce;
|
||||
_rejected = true;
|
||||
}
|
||||
_syncFromController();
|
||||
}
|
||||
|
||||
void _syncFromController() {
|
||||
final text = _exLine?.input ?? '';
|
||||
if (_input.text != text) {
|
||||
_input.value = TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onInputChanged(String value) {
|
||||
if (_rejected) setState(() => _rejected = false);
|
||||
_exLine?.setInput(value);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final controller = _exLine;
|
||||
final services = _services;
|
||||
if (controller == null || services == null) return;
|
||||
final cmd = parseExCommand(controller.input);
|
||||
switch (cmd) {
|
||||
case ExNoop():
|
||||
controller.close();
|
||||
case ExUnknown():
|
||||
controller.flashInvalid(); // stay open; the hint + border show
|
||||
case ExEdit(:final query):
|
||||
controller.close();
|
||||
services.quickOpen.open(seed: query.isEmpty ? null : query);
|
||||
case ExWrite():
|
||||
controller.close();
|
||||
await exWriteActive(services.ipc);
|
||||
case ExQuit():
|
||||
controller.close();
|
||||
await exQuitActive(services.ipc);
|
||||
case ExWriteQuit():
|
||||
controller.close();
|
||||
await exWriteQuitActive(services.ipc);
|
||||
case ExGoto(:final line):
|
||||
controller.close();
|
||||
await exGotoLineActive(services.ipc, line);
|
||||
}
|
||||
}
|
||||
|
||||
Object? _dismiss(DismissIntent _) {
|
||||
_exLine?.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.exLine,
|
||||
builder: (ctx, _) {
|
||||
if (!kernel.exLine.isOpen) return const SizedBox.shrink();
|
||||
return Positioned(
|
||||
top: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{DismissIntent: CallbackAction<DismissIntent>(onInvoke: _dismiss)},
|
||||
child: Container(
|
||||
width: 480,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: _rejected ? tokens.statusError : tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [BoxShadow(color: tokens.shadowAmbient, blurRadius: 12, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(':', fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: EditableText(
|
||||
controller: _input,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.dropdownForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 1,
|
||||
onChanged: _onInputChanged,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_rejected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: ClideText('Not an editor command', fontSize: clideFontCaption, color: tokens.statusError),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export 'src/clide_collapser_card.dart';
|
||||
export 'src/clide_code_block.dart';
|
||||
export 'src/clide_divider.dart';
|
||||
export 'src/clide_file_image.dart';
|
||||
export 'src/ex_line_overlay.dart';
|
||||
export 'src/clide_filter_box.dart';
|
||||
export 'src/clide_lightbox.dart';
|
||||
export 'src/clide_markdown.dart';
|
||||
|
||||
Reference in New Issue
Block a user