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:
@@ -5604,3 +5604,150 @@ Closed 2026-06-15: the dart:ffi fence + flutter build web --wasm compile gate ar
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'review', 'done', NULL, '2026-06-15 15:53:47', '2026-06-15 15:53:47', '2026-06-15 15:53:47', NULL, '799f878cdf6c8f6b9256fb852b4c4537', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'review', 'done', NULL, '2026-06-15 15:59:15', '2026-06-15 15:59:15', '2026-06-15 15:59:15', NULL, '96e17294ee3609fa7f2dd9a69fa38fa6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'review', 'done', NULL, '2026-06-15 16:03:07', '2026-06-15 16:03:07', '2026-06-15 16:03:07', NULL, '75bf3f398e16ec94668b9d7937c33a4e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'status', 'in_progress', 'backlog', NULL, '2026-06-15 16:14:56', '2026-06-15 16:14:56', '2026-06-15 16:14:56', NULL, '085d54bd54f253b684cea7fd61638cee', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'description', NULL, 'The ticket-id label at the top of each kanban card (e.g. "T-403") is rendered in semibold and is hard to read at the small card font size — user feedback 2026-06-16 (screenshot: ~/.cache/clide/pasted/paste-1781601250588.png).
|
||||
|
||||
**Fix:** drop the bold weight from the id label. lib/builtin/tickets/src/tickets_view.dart:405 — `ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600)` → remove `fontWeight: FontWeight.w600` so it renders at the default UI weight. Confirm the parent-id breadcrumb line (the muted `└ T-NNN` above a child, ~line 385-395) still reads fine; keep the title (line 409) as-is.
|
||||
|
||||
Scope: cosmetic weight tweak only. Verify against the four presets/themes; no golden churn expected beyond the tickets-view widget golden if one exists.', NULL, '2026-06-16 09:16:18', '2026-06-16 09:16:18', '2026-06-16 09:16:18', NULL, '3b414a2fe4ba72623f59d3e461082bf7', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'description', NULL, 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781601988494.png, observed in the settled-reach session). An Agent/Task tool interaction renders as THREE sibling top-level cards instead of one bundled, collapsed card:
|
||||
1. `> Agent Implement T-1039+T-1043 dispatch threading` — 1 step, </> badge (the Agent tool-use collapser)
|
||||
2. `> agent run Edit /…/server/src/atlas/plugin.rs` — 65 steps
|
||||
3. `> agent prompt You are DUDLEY, server developer…`
|
||||
|
||||
EXPECTED (per T-305 collapser + T-338 sidechain fold): the sub-agent''s run + prompt + its 65 steps should be bundled INSIDE the single Agent collapser card, not surfaced as siblings. The ''1 step'' on the Agent card next to a separate ''65 steps'' card is the tell — the step count that belongs to the agent landed on a different card than its work.
|
||||
|
||||
QUESTIONS TO RESOLVE:
|
||||
- Is the three-card split a regression in the sidechain fold, or are ''agent run''/''agent prompt'' a distinct (hosted-agent) render path that never went through the collapser? Determine which.
|
||||
- The stray/inconsistent left indent on these cards (user also flagged) — confirm and remove so the bundle reads as one unit.
|
||||
|
||||
CODE POINTERS: lib/builtin/claude/src/activity_cluster.dart (isAgentTool: Task|Agent, line 83); lib/builtin/claude/src/transcript_reader.dart (isSidechain / parentToolUseId fold, ~line 571-575; subagents/agent-<id>.jsonl, line 10); lib/builtin/claude/src/conversation_view.dart (card rendering). Check whether ''agent run''/''agent prompt'' items carry parentToolUseId and whether the fold groups them under the Agent tool_use id.
|
||||
|
||||
Scope: diagnose first (bug vs two-render-paths), then bundle the sub-agent interaction into the single collapser + fix the indent. Widget test for the folded sub-agent card.', NULL, '2026-06-16 09:27:53', '2026-06-16 09:27:53', '2026-06-16 09:27:53', NULL, '8a6a6a533fd388d09a31a1f23af55fa5', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
|
||||
|
||||
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
|
||||
- v1 grammar, one table, no parsing cleverness:
|
||||
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
|
||||
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
|
||||
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
|
||||
|
||||
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
|
||||
|
||||
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
|
||||
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
|
||||
|
||||
ACCEPTANCE CRITERIA:
|
||||
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
|
||||
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
|
||||
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
|
||||
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
|
||||
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
|
||||
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
|
||||
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
|
||||
|
||||
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
|
||||
|
||||
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
|
||||
|
||||
OPEN QUESTIONS:
|
||||
- :q today→editor.close closes the whole split (arrangement.closeEditor), not the focused tab — acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
|
||||
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism — a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb — and where it lives (editor ext vs vim ext).
|
||||
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" — confirm the no-buffer behavior.
|
||||
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
|
||||
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer — add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
|
||||
|
||||
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
|
||||
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
|
||||
|
||||
KEY MECHANISM (verified in code) — this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
|
||||
- `:q` → EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) — the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
|
||||
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
|
||||
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 → lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab — no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
|
||||
|
||||
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
|
||||
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close — no protocol change; keeps tab-visual-order knowledge in the view that owns it.
|
||||
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
|
||||
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
|
||||
|
||||
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
|
||||
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active → closeEditor path) — no separate editor.close dispatch.
|
||||
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
|
||||
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
|
||||
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes — does NOT touch other panes (still an open question below).
|
||||
|
||||
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle — keep v1 editor-targeted.)
|
||||
|
||||
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
|
||||
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 — they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
|
||||
|
||||
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
|
||||
- v1 grammar, one table, no parsing cleverness:
|
||||
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
|
||||
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
|
||||
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
|
||||
|
||||
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
|
||||
|
||||
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
|
||||
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
|
||||
|
||||
ACCEPTANCE CRITERIA:
|
||||
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
|
||||
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
|
||||
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
|
||||
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
|
||||
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
|
||||
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
|
||||
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
|
||||
|
||||
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
|
||||
|
||||
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
|
||||
|
||||
OPEN QUESTIONS:
|
||||
- :q today→editor.close closes the whole split (arrangement.closeEditor), not the focused tab — acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
|
||||
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism — a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb — and where it lives (editor ext vs vim ext).
|
||||
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" — confirm the no-buffer behavior.
|
||||
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
|
||||
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer — add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
|
||||
|
||||
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
|
||||
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
|
||||
|
||||
KEY MECHANISM (verified in code) — this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
|
||||
- `:q` → EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) — the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
|
||||
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
|
||||
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 → lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab — no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
|
||||
|
||||
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
|
||||
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close — no protocol change; keeps tab-visual-order knowledge in the view that owns it.
|
||||
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
|
||||
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
|
||||
|
||||
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
|
||||
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active → closeEditor path) — no separate editor.close dispatch.
|
||||
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
|
||||
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
|
||||
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes — does NOT touch other panes (still an open question below).
|
||||
|
||||
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle — keep v1 editor-targeted.)
|
||||
|
||||
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
|
||||
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 — they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
|
||||
|
||||
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
|
||||
|
||||
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
|
||||
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer — test-friendly).
|
||||
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) — new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
|
||||
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
|
||||
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
|
||||
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
|
||||
|
||||
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
|
||||
|
||||
NOT yet exercised in the running GUI — recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
|
||||
|
||||
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', NULL, '2026-06-16 09:42:34', '2026-06-16 09:42:34', '2026-06-16 09:42:34', NULL, 'c53afd4a1354012e36081b84dab66bfd', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'ready', 'review', NULL, '2026-06-16 09:42:39', '2026-06-16 09:42:39', '2026-06-16 09:42:39', NULL, 'e2cc00b9f1b80080e17e0213079400f1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -265,3 +265,5 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-440', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'f219bb1a70eb1f49e30975ce8c526051', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'T-441', '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, 'fbabc4fc344b34476b38a186c2f44a16', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'T-442', '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, 'c85d8fd95cd73aec1e83b477612e1131', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -6523,3 +6523,171 @@ Routed through it: PTY children (`registry.dart` overrides PATH), git (`env.dart
|
||||
Verified: `flutter analyze` clean; new `shell_env_test.dart` covers the probe + every fallback + the merge; `env_test`/`cli_install_test` updated; `make test` green; `flutter build web --wasm` still green (resolver is web-safe, probe is `!kIsWeb`).
|
||||
|
||||
**Why `review`, not `done`:** logic + tests cover Linux and macOS, but the real *desktop/dock-launch* behavior (the actual GUI process inheriting a sparse PATH) can only be confirmed by launching a packaged build from the launcher — recommend a quick live confirm (open clide from the dock, check pql/git/a `~/.local/bin` tool resolve). macOS Homebrew path is code-correct but unverified on a Mac.', 'done', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 16:03:07', NULL, 'e9e65365ca2c33edecd72e30a76c3e67', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'story', '06FB3DHCTP001YCHFP39XER0ZM', 'Phase 2 — remote workspace identity + ssh:// open scheme', 'Model-independent backbone. clide can represent and open an ssh:// workspace end-to-end (actual remote calls land on the execution layer from the fork phase). URI: ssh://[user@]host[:port]/abs/remote/path, resolving host aliases via ~/.ssh/config. lib/kernel/src/project.dart: RecentProject gains host/port/user (absent = local; back-compatible toJson/fromJson), bool get isRemote, remote display form in relativePath/timeAgo (e.g. buildbox:~/repo); introduce a WorkspaceRef { String? host; String path; } value type and migrate _current/current off bare Directory (Directory(remotePath) is meaningless locally) — local callers read .path; resolveProject (~:204) branches: local runs git rev-parse as today, remote resolves the toplevel via the execution layer. lib/main.dart: swapBackend remote branch wires DaemonClient to the remote transport (no local server bound for remote workspaces). Connection lifecycle: connect -> resolve auth -> establish transport -> preflight remote tools -> ProjectOpened. On SSH drop, DaemonClient reconnect re-attaches; events --since cursor-pull (server.dart ~:339) is the re-sync primitive (gap:true -> UI full refresh). Verify: unit tests on RecentProject/WorkspaceRef JSON round-trips (local + remote); open ssh://localhost/... loopback workspace and confirm resolveProject returns the remote toplevel. Depends on Phase 1 (transport seam).
|
||||
|
||||
2026-06-12 progress: the model-independent slice is in — WorkspaceRef value type (lib/kernel/src/workspace_ref.dart: local/remote ctors, ssh://[user@]host[:port]/abs/path parse with rejection of host-less/path-less forms, uri/display, value equality; exported from kernel.dart) and RecentProject host/port/user (back-compatible JSON — absent keys deserialize local; isRemote; ref getter; relativePath renders host:path; copyWith preserves host identity). Tests: test/kernel/src/workspace_ref_test.dart + new RecentProject cases in project_test.dart.
|
||||
|
||||
REMAINING (gated on T-330''s footprint pick / T-336 execution layer): migrate ProjectManager._current/current off bare Directory onto WorkspaceRef (touch all .current?.path callers), open() branching on isRemote, resolveProject remote toplevel via the execution layer, swapBackend remote branch (DaemonTransport seam from T-331 is ready), connection lifecycle + preflight + events --since re-sync. The ssh://localhost loopback verify also needs a reachable sshd (none on the dev box).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 13:26:31', '2026-06-15 16:14:56', NULL, '41befae79bb026e68d4b7a91c485f71a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket card: drop bold from the ticket-id label (hard to read)', NULL, 'backlog', 'low', NULL, NULL, NULL, '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, '6b822ca870b90597ae120773c4208d57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket card: drop bold from the ticket-id label (hard to read)', 'The ticket-id label at the top of each kanban card (e.g. "T-403") is rendered in semibold and is hard to read at the small card font size — user feedback 2026-06-16 (screenshot: ~/.cache/clide/pasted/paste-1781601250588.png).
|
||||
|
||||
**Fix:** drop the bold weight from the id label. lib/builtin/tickets/src/tickets_view.dart:405 — `ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600)` → remove `fontWeight: FontWeight.w600` so it renders at the default UI weight. Confirm the parent-id breadcrumb line (the muted `└ T-NNN` above a child, ~line 385-395) still reads fine; keep the title (line 409) as-is.
|
||||
|
||||
Scope: cosmetic weight tweak only. Verify against the four presets/themes; no golden churn expected beyond the tickets-view widget golden if one exists.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-16 09:16:07', '2026-06-16 09:16:18', NULL, 'b6d0d8c954fc598cf6d9c09034eb81c9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Sub-agent interaction renders as 3 separate cards instead of one bundled collapsed card', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, '4dad9945833992749dd3b257263263c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Sub-agent interaction renders as 3 separate cards instead of one bundled collapsed card', 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781601988494.png, observed in the settled-reach session). An Agent/Task tool interaction renders as THREE sibling top-level cards instead of one bundled, collapsed card:
|
||||
1. `> Agent Implement T-1039+T-1043 dispatch threading` — 1 step, </> badge (the Agent tool-use collapser)
|
||||
2. `> agent run Edit /…/server/src/atlas/plugin.rs` — 65 steps
|
||||
3. `> agent prompt You are DUDLEY, server developer…`
|
||||
|
||||
EXPECTED (per T-305 collapser + T-338 sidechain fold): the sub-agent''s run + prompt + its 65 steps should be bundled INSIDE the single Agent collapser card, not surfaced as siblings. The ''1 step'' on the Agent card next to a separate ''65 steps'' card is the tell — the step count that belongs to the agent landed on a different card than its work.
|
||||
|
||||
QUESTIONS TO RESOLVE:
|
||||
- Is the three-card split a regression in the sidechain fold, or are ''agent run''/''agent prompt'' a distinct (hosted-agent) render path that never went through the collapser? Determine which.
|
||||
- The stray/inconsistent left indent on these cards (user also flagged) — confirm and remove so the bundle reads as one unit.
|
||||
|
||||
CODE POINTERS: lib/builtin/claude/src/activity_cluster.dart (isAgentTool: Task|Agent, line 83); lib/builtin/claude/src/transcript_reader.dart (isSidechain / parentToolUseId fold, ~line 571-575; subagents/agent-<id>.jsonl, line 10); lib/builtin/claude/src/conversation_view.dart (card rendering). Check whether ''agent run''/''agent prompt'' items carry parentToolUseId and whether the fold groups them under the Agent tool_use id.
|
||||
|
||||
Scope: diagnose first (bug vs two-render-paths), then bundle the sub-agent interaction into the single collapser + fix the indent. Widget test for the folded sub-agent card.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 09:27:39', '2026-06-16 09:27:53', NULL, '01f87c62ce6ad7d1266d0acb10a38d01', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
|
||||
|
||||
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
|
||||
- v1 grammar, one table, no parsing cleverness:
|
||||
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
|
||||
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
|
||||
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
|
||||
|
||||
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
|
||||
|
||||
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
|
||||
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
|
||||
|
||||
ACCEPTANCE CRITERIA:
|
||||
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
|
||||
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
|
||||
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
|
||||
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
|
||||
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
|
||||
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
|
||||
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
|
||||
|
||||
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
|
||||
|
||||
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
|
||||
|
||||
OPEN QUESTIONS:
|
||||
- :q today→editor.close closes the whole split (arrangement.closeEditor), not the focused tab — acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
|
||||
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism — a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb — and where it lives (editor ext vs vim ext).
|
||||
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" — confirm the no-buffer behavior.
|
||||
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
|
||||
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer — add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
|
||||
|
||||
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
|
||||
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
|
||||
|
||||
KEY MECHANISM (verified in code) — this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
|
||||
- `:q` → EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) — the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
|
||||
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
|
||||
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 → lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab — no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
|
||||
|
||||
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
|
||||
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close — no protocol change; keeps tab-visual-order knowledge in the view that owns it.
|
||||
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
|
||||
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
|
||||
|
||||
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
|
||||
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active → closeEditor path) — no separate editor.close dispatch.
|
||||
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
|
||||
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
|
||||
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes — does NOT touch other panes (still an open question below).
|
||||
|
||||
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle — keep v1 editor-targeted.)
|
||||
|
||||
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
|
||||
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 — they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
|
||||
|
||||
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
|
||||
|
||||
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
|
||||
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer — test-friendly).
|
||||
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) — new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
|
||||
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
|
||||
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
|
||||
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
|
||||
|
||||
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
|
||||
|
||||
NOT yet exercised in the running GUI — recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
|
||||
|
||||
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:42:34', NULL, '1c0b6cd5dc6d81a9b4deb0f35a48e700', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
|
||||
|
||||
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode — Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
|
||||
- v1 grammar, one table, no parsing cleverness:
|
||||
:w → editor save (find the editor''s save command id; check editor_commands.dart _save), :q → command:editor.close, :wq / :x → save then close, :e <text> → quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> → editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> → shake/flash + stay open.
|
||||
- ZZ ("shift+z shift+z" sequence) → save-close, riding the same plumbing — include it here, it''s one YAML line once :wq exists.
|
||||
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused — :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
|
||||
|
||||
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
|
||||
|
||||
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
|
||||
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode — an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`→editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab — document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command — save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today — this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>→quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) — add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer — smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
|
||||
|
||||
ACCEPTANCE CRITERIA:
|
||||
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
|
||||
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
|
||||
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
|
||||
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
|
||||
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
|
||||
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
|
||||
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
|
||||
|
||||
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split — for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only — reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
|
||||
|
||||
DEPENDENCIES: Depends on / overlaps T-404 — both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
|
||||
|
||||
OPEN QUESTIONS:
|
||||
- :q today→editor.close closes the whole split (arrangement.closeEditor), not the focused tab — acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
|
||||
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism — a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb — and where it lives (editor ext vs vim ext).
|
||||
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" — confirm the no-buffer behavior.
|
||||
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
|
||||
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer — add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
|
||||
|
||||
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
|
||||
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
|
||||
|
||||
KEY MECHANISM (verified in code) — this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
|
||||
- `:q` → EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) — the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
|
||||
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
|
||||
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 → lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab — no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
|
||||
|
||||
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
|
||||
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close — no protocol change; keeps tab-visual-order knowledge in the view that owns it.
|
||||
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
|
||||
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
|
||||
|
||||
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
|
||||
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active → closeEditor path) — no separate editor.close dispatch.
|
||||
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
|
||||
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
|
||||
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes — does NOT touch other panes (still an open question below).
|
||||
|
||||
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle — keep v1 editor-targeted.)
|
||||
|
||||
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
|
||||
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 — they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
|
||||
|
||||
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
|
||||
|
||||
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
|
||||
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer — test-friendly).
|
||||
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) — new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
|
||||
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
|
||||
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
|
||||
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
|
||||
|
||||
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
|
||||
|
||||
NOT yet exercised in the running GUI — recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
|
||||
|
||||
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', 'review', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-16 09:42:39', NULL, 'e320e4acb9a1a9100e39ebf8199e72d2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -18,6 +18,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
|
||||
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
|
||||
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
|
||||
`:e <path>` jump to quick-open seeded with the path, `:<n>` goto-line. Unknown
|
||||
commands flash and stay open; with no active buffer it no-ops. Opens from the
|
||||
editor or a focused pane; Esc dismisses. Adds the `editor.goto-line` CLI/IPC
|
||||
verb. (T-407)
|
||||
|
||||
- **Crash-survivable logging.** clide writes a durable JSON-lines log to a
|
||||
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
|
||||
Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so
|
||||
|
||||
@@ -50,6 +50,11 @@ bindings:
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: quickOpen.open
|
||||
# Ex command-line overlay (T-407): Esc dismisses it back to normal mode (the
|
||||
# overlay's own EditableText handles Enter via onSubmitted).
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: exline.open
|
||||
- intent: findInFiles.open
|
||||
keys: [ctrl+shift+f, meta+shift+f]
|
||||
- intent: focus.nextPanel
|
||||
@@ -128,6 +133,18 @@ bindings:
|
||||
keys: [ctrl+w q, ctrl+w c]
|
||||
when: "vim.normal || vim.visual"
|
||||
|
||||
# ---- Ex command-line (T-407) -----------------------------------------
|
||||
# `:` opens the transient ex overlay (`:w` `:q` `:wq` `:x` `:e <path>` `:N`);
|
||||
# `ZZ` runs `:wq` directly. Both are typed app intents the editor's command
|
||||
# matcher and a pane's nav matcher bubble to the app-root Actions, so they
|
||||
# fire from any focus. Editor-targeted: no-op when no buffer is active.
|
||||
- intent: exline.open
|
||||
keys: shift+semicolon # :
|
||||
when: vim.normal
|
||||
- intent: exline.writeQuit
|
||||
keys: shift+z shift+z # ZZ
|
||||
when: vim.normal
|
||||
|
||||
# ---- Mode transitions ------------------------------------------------
|
||||
- intent: command:vim.mode.visual
|
||||
keys: v
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -187,6 +187,59 @@ void main() {
|
||||
expect(c.activeId, isNull); // cleared until an active-changed arrives
|
||||
});
|
||||
|
||||
test('editor.selection-changed mirrors an external caret move onto the active buffer (T-407)', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')],
|
||||
}),
|
||||
);
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'},
|
||||
}),
|
||||
);
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'line1\nline2')));
|
||||
await c.hydrate();
|
||||
expect(c.selection.start, 0);
|
||||
|
||||
// An external setSelection (ex-line :N goto) jumps the caret server-side.
|
||||
emitEditor(bus, 'editor.selection-changed', {
|
||||
'id': 'b_1',
|
||||
'selection': {'start': 6, 'end': 6},
|
||||
});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.selection.start, 6);
|
||||
expect(c.selection.end, 6);
|
||||
});
|
||||
|
||||
test('editor.selection-changed for a non-active buffer is ignored', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')],
|
||||
}),
|
||||
);
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'},
|
||||
}),
|
||||
);
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'hello')));
|
||||
await c.hydrate();
|
||||
|
||||
emitEditor(bus, 'editor.selection-changed', {
|
||||
'id': 'b_other',
|
||||
'selection': {'start': 3, 'end': 3},
|
||||
});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.selection.start, 0); // untouched
|
||||
});
|
||||
|
||||
test('editor.saved clears the dirty marker on the buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
|
||||
@@ -6,6 +6,7 @@ library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/editor_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -76,6 +77,32 @@ void main() {
|
||||
expect(find.text('Open a file to begin editing.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an external selection-changed moves the caret without retyping (T-407)', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.selection.baseOffset, 0);
|
||||
|
||||
// An ex-line `:N` goto sets the selection server-side; the buffer content
|
||||
// is unchanged, so only the caret should move (the selection-only branch).
|
||||
f.services.events.emit(
|
||||
DaemonEvent(
|
||||
subsystem: 'editor',
|
||||
kind: 'editor.selection-changed',
|
||||
data: const {
|
||||
'id': 'b_1',
|
||||
'selection': {'start': 5, 'end': 5},
|
||||
},
|
||||
ts: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final field = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(field.controller.selection.baseOffset, 5);
|
||||
expect(field.controller.text, 'content of lib/a.dart'); // unchanged
|
||||
});
|
||||
|
||||
testWidgets('tapping an inactive tab routes to editor.activate', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
|
||||
String? activated;
|
||||
|
||||
@@ -89,6 +89,49 @@ void main() {
|
||||
expect((r.data['selection'] as Map)['start'], 8);
|
||||
});
|
||||
|
||||
test('editor.goto-line jumps the active buffer to a 1-based line (T-407)', () async {
|
||||
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
|
||||
await call('editor.open', {'path': 'multi.txt'});
|
||||
final r = await call('editor.goto-line', {'line': 3});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['line'], 3);
|
||||
// Line 3 starts after 'one\n' + 'two\n' = 8 characters.
|
||||
final read = await call('editor.read');
|
||||
expect((read.data['selection'] as Map)['start'], 8);
|
||||
});
|
||||
|
||||
test('editor.goto-line clamps an out-of-range line to the content end', () async {
|
||||
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\n'); // 8 chars
|
||||
await call('editor.open', {'path': 'multi.txt'});
|
||||
final r = await call('editor.goto-line', {'line': 999});
|
||||
expect(r.ok, isTrue);
|
||||
final read = await call('editor.read');
|
||||
expect((read.data['selection'] as Map)['start'], 8);
|
||||
});
|
||||
|
||||
test('editor.goto-line rejects a non-positive line', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r = await call('editor.goto-line', {'line': 0});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('editor.goto-line with no active buffer is not-found', () async {
|
||||
final r = await call('editor.goto-line', {'line': 2});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('CLI positional line binds to editor.goto-line (T-232)', () async {
|
||||
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
|
||||
await call('editor.open', {'path': 'multi.txt'});
|
||||
final r = await call('editor.goto-line', {
|
||||
'positional': ['2'],
|
||||
});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['line'], 2);
|
||||
});
|
||||
|
||||
test('editor.insert without id targets the active buffer', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r = await call('editor.insert', {'text': 'X '});
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/// Unit tests for the Vim ex command-line (T-407): the [parseExCommand]
|
||||
/// grammar, the [ExLineController] open/close/flash state, and the
|
||||
/// editor-targeted executor functions (which no-op when no buffer is active).
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/fake_ipc.dart';
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
void main() {
|
||||
group('parseExCommand', () {
|
||||
test('empty (and bare colon) is a no-op', () {
|
||||
expect(parseExCommand(''), isA<ExNoop>());
|
||||
expect(parseExCommand(' '), isA<ExNoop>());
|
||||
expect(parseExCommand(':'), isA<ExNoop>());
|
||||
});
|
||||
|
||||
test('write / quit / write-quit and their aliases', () {
|
||||
expect(parseExCommand('w'), isA<ExWrite>());
|
||||
expect(parseExCommand('q'), isA<ExQuit>());
|
||||
expect(parseExCommand('q!'), isA<ExQuit>());
|
||||
expect(parseExCommand('wq'), isA<ExWriteQuit>());
|
||||
expect(parseExCommand('wq!'), isA<ExWriteQuit>());
|
||||
expect(parseExCommand('x'), isA<ExWriteQuit>());
|
||||
expect(parseExCommand('x!'), isA<ExWriteQuit>());
|
||||
});
|
||||
|
||||
test('a leading colon is tolerated', () {
|
||||
expect(parseExCommand(':w'), isA<ExWrite>());
|
||||
expect(parseExCommand(':q'), isA<ExQuit>());
|
||||
});
|
||||
|
||||
test('edit seeds quick-open with the rest of the line', () {
|
||||
expect(parseExCommand('e'), const ExEdit(''));
|
||||
expect(parseExCommand('e lib/main.dart'), const ExEdit('lib/main.dart'));
|
||||
expect(parseExCommand('e spaced '), const ExEdit('spaced'));
|
||||
});
|
||||
|
||||
test('a positive integer is a goto-line', () {
|
||||
expect(parseExCommand('1'), const ExGoto(1));
|
||||
expect(parseExCommand('42'), const ExGoto(42));
|
||||
});
|
||||
|
||||
test('zero, negatives and junk are unknown', () {
|
||||
expect(parseExCommand('0'), isA<ExUnknown>());
|
||||
expect(parseExCommand('-3'), isA<ExUnknown>());
|
||||
expect(parseExCommand('wat'), isA<ExUnknown>());
|
||||
expect(parseExCommand('e'), isNot(isA<ExUnknown>())); // guard: e is ExEdit
|
||||
});
|
||||
});
|
||||
|
||||
group('ExLineController', () {
|
||||
late ExLineController c;
|
||||
setUp(() => c = ExLineController());
|
||||
tearDown(() => c.dispose());
|
||||
|
||||
test('open clears input and flips isOpen; close resets', () {
|
||||
var notifications = 0;
|
||||
c.addListener(() => notifications++);
|
||||
c.setInput('stale');
|
||||
c.open();
|
||||
expect(c.isOpen, isTrue);
|
||||
expect(c.input, isEmpty);
|
||||
c.setInput('w');
|
||||
expect(c.input, 'w');
|
||||
c.close();
|
||||
expect(c.isOpen, isFalse);
|
||||
expect(c.input, isEmpty);
|
||||
expect(notifications, greaterThan(0));
|
||||
});
|
||||
|
||||
test('open is idempotent and does not re-clear a second time', () {
|
||||
c.open();
|
||||
c.setInput('w');
|
||||
c.open(); // no-op
|
||||
expect(c.input, 'w');
|
||||
});
|
||||
|
||||
test('flashInvalid bumps the nonce monotonically', () {
|
||||
final start = c.invalidNonce;
|
||||
c.flashInvalid();
|
||||
c.flashInvalid();
|
||||
expect(c.invalidNonce, start + 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('executors', () {
|
||||
late DaemonBus bus;
|
||||
late FakeDaemonClient ipc;
|
||||
late List<String> calls;
|
||||
|
||||
setUp(() {
|
||||
bus = DaemonBus();
|
||||
ipc = FakeDaemonClient(log: Logger(), events: bus);
|
||||
calls = [];
|
||||
});
|
||||
tearDown(() => bus.dispose());
|
||||
|
||||
void record(String cmd, [Map<String, Object?> data = const {}]) {
|
||||
ipc.stub(cmd, (a) async {
|
||||
calls.add(cmd);
|
||||
return _ok(data);
|
||||
});
|
||||
}
|
||||
|
||||
test(':w saves the active buffer', () async {
|
||||
record('editor.save');
|
||||
await exWriteActive(ipc);
|
||||
expect(calls, ['editor.save']);
|
||||
});
|
||||
|
||||
test(':q resolves the active id then closes it', () async {
|
||||
ipc.stub('editor.active', (_) async {
|
||||
calls.add('editor.active');
|
||||
return _ok({
|
||||
'active': {'id': 'b_7'},
|
||||
});
|
||||
});
|
||||
ipc.stub('editor.close', (a) async {
|
||||
calls.add('editor.close:${a['id']}');
|
||||
return _ok({});
|
||||
});
|
||||
await exQuitActive(ipc);
|
||||
expect(calls, ['editor.active', 'editor.close:b_7']);
|
||||
});
|
||||
|
||||
test(':q is a no-op with no active buffer', () async {
|
||||
ipc.stub('editor.active', (_) async => _ok({'active': null}));
|
||||
record('editor.close');
|
||||
await exQuitActive(ipc);
|
||||
expect(calls, isEmpty); // never reached editor.close
|
||||
});
|
||||
|
||||
test(':wq saves then closes the active tab', () async {
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_3'},
|
||||
}),
|
||||
);
|
||||
ipc.stub('editor.save', (a) async {
|
||||
calls.add('save:${a['id']}');
|
||||
return _ok({});
|
||||
});
|
||||
ipc.stub('editor.close', (a) async {
|
||||
calls.add('close:${a['id']}');
|
||||
return _ok({});
|
||||
});
|
||||
await exWriteQuitActive(ipc);
|
||||
expect(calls, ['save:b_3', 'close:b_3']);
|
||||
});
|
||||
|
||||
test(':wq is a no-op with no active buffer', () async {
|
||||
ipc.stub('editor.active', (_) async => _ok({'active': null}));
|
||||
record('editor.save');
|
||||
record('editor.close');
|
||||
await exWriteQuitActive(ipc);
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
test(':N dispatches editor.goto-line with the line', () async {
|
||||
ipc.stub('editor.goto-line', (a) async {
|
||||
calls.add('goto:${a['line']}');
|
||||
return _ok({});
|
||||
});
|
||||
await exGotoLineActive(ipc, 42);
|
||||
expect(calls, ['goto:42']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/// Widget tests for the Vim ex command-line overlay (T-407): each v1 command
|
||||
/// row dispatches the right editor IPC verb (or seeds quick-open), unknown
|
||||
/// commands keep the overlay open with a hint, and Esc dismisses it.
|
||||
///
|
||||
/// Built on a tight, sized Stack rather than the shared `harness()` — the
|
||||
/// overlay is a `Positioned` child and needs a bounded Stack ancestor (the
|
||||
/// canSizeOverlay harness mis-sizes positioned content).
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
IpcResponse _ok([Map<String, Object?> data = const {}]) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
Widget mount() => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: ClideKernel(
|
||||
services: f.services,
|
||||
child: ClideTheme(
|
||||
controller: f.services.theme,
|
||||
child: const MediaQuery(
|
||||
data: MediaQueryData(size: Size(800, 600)),
|
||||
child: SizedBox(width: 800, height: 600, child: Stack(children: [ExLineOverlay()])),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Open the overlay and type [text] into it (no submit yet).
|
||||
Future<void> openAndType(WidgetTester tester, String text) async {
|
||||
await tester.pumpWidget(mount());
|
||||
f.services.exLine.open();
|
||||
await pumpAsync(tester);
|
||||
await tester.enterText(find.byType(EditableText), text);
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
Future<void> submit(WidgetTester tester) async {
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
testWidgets('closed: renders nothing', (tester) async {
|
||||
await tester.pumpWidget(mount());
|
||||
expect(find.byType(EditableText), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(':w saves the active buffer and closes', (tester) async {
|
||||
var saved = false;
|
||||
f.ipc.stub('editor.save', (_) async {
|
||||
saved = true;
|
||||
return _ok();
|
||||
});
|
||||
await openAndType(tester, 'w');
|
||||
await submit(tester);
|
||||
expect(saved, isTrue);
|
||||
expect(f.services.exLine.isOpen, isFalse);
|
||||
});
|
||||
|
||||
testWidgets(':q closes the active tab via its id', (tester) async {
|
||||
String? closed;
|
||||
f.ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_9'},
|
||||
}),
|
||||
);
|
||||
f.ipc.stub('editor.close', (a) async {
|
||||
closed = a['id'] as String?;
|
||||
return _ok();
|
||||
});
|
||||
await openAndType(tester, 'q');
|
||||
await submit(tester);
|
||||
expect(closed, 'b_9');
|
||||
expect(f.services.exLine.isOpen, isFalse);
|
||||
});
|
||||
|
||||
testWidgets(':42 dispatches editor.goto-line', (tester) async {
|
||||
Object? line;
|
||||
f.ipc.stub('editor.goto-line', (a) async {
|
||||
line = a['line'];
|
||||
return _ok();
|
||||
});
|
||||
await openAndType(tester, '42');
|
||||
await submit(tester);
|
||||
expect(line, 42);
|
||||
});
|
||||
|
||||
testWidgets(':e seeds quick-open and closes the ex-line', (tester) async {
|
||||
await openAndType(tester, 'e lib/main.dart');
|
||||
await submit(tester);
|
||||
expect(f.services.exLine.isOpen, isFalse);
|
||||
expect(f.services.quickOpen.isOpen, isTrue);
|
||||
expect(f.services.quickOpen.filter, 'lib/main.dart');
|
||||
});
|
||||
|
||||
testWidgets('unknown command keeps the overlay open and shows the hint', (tester) async {
|
||||
await openAndType(tester, 'nope');
|
||||
await submit(tester);
|
||||
expect(f.services.exLine.isOpen, isTrue);
|
||||
expect(find.text('Not an editor command'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('editing after a rejection clears the hint', (tester) async {
|
||||
await openAndType(tester, 'nope');
|
||||
await submit(tester);
|
||||
expect(find.text('Not an editor command'), findsOneWidget);
|
||||
await tester.enterText(find.byType(EditableText), 'w');
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('Not an editor command'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('DismissIntent closes the overlay', (tester) async {
|
||||
await openAndType(tester, 'w');
|
||||
final ctx = tester.element(find.byType(EditableText));
|
||||
Actions.invoke(ctx, const DismissIntent());
|
||||
await pumpAsync(tester);
|
||||
expect(f.services.exLine.isOpen, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('opening publishes the exline.open scope flag; closing clears it', (tester) async {
|
||||
await tester.pumpWidget(mount());
|
||||
f.services.exLine.open();
|
||||
await pumpAsync(tester);
|
||||
expect(f.services.keymap.scope['exline.open'], isTrue);
|
||||
f.services.exLine.close();
|
||||
await pumpAsync(tester);
|
||||
expect(f.services.keymap.scope['exline.open'], isNot(true));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user