vim normal-mode navigation in non-editor panes (T-406)
The structural T-403 child: make vim normal mode mean navigation in panes that were mouse-only. The passive global key path can't run multi-chord sequences (D-82), so each pane hosts its own SequenceMatcher — factored into a reusable PaneKeyNav that resolves the live keymap and dispatches nav.* intents while a pane holds focus under the vim preset. - nav.* intents (down/up/pageDown/pageUp/top/bottom/expandOrRight/ collapseOrLeft/activate) — preset-neutral; vim.yaml binds j/k/ctrl+d/ctrl+u/ gg/G/l/h/[o,enter] under `vim.normal && !editor.focused`. - The editor publishes an `editor.focused` scope flag from its focus node, so the same keys stay buffer motions while the editor is focused and become nav when a pane is — resolved by file order + the guard (no change to the editor motion bindings). - File tree: a flattened visible-index selection cursor in FileTreeController (j/k move, h collapse-or-out, l expand-or-into, o/enter open), with a focus ring + scroll-into-view. - Conversation: j/k line-scroll, ctrl+d/u half-page, gg top, G bottom — G re-arms follow-tail. Foundation for T-404/T-405/T-407, which build on the per-pane matcher and the editor.focused guard. Git panel + ticket board list nav deferred to a follow-up (the ticket says lists can trail). Tests: keymap resolution under both scopes, PaneKeyNav dispatch, the controller selection model, and end-to-end key-driven nav in both panes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4775,3 +4775,8 @@ STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close th
|
||||
--- 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.', NULL, '2026-06-13 11:41:23', '2026-06-13 11:41:23', '2026-06-13 11:41:23', NULL, '526f24177bfa0f09ca59c571d9e84705', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'backlog', 'in_progress', NULL, '2026-06-13 11:42:26', '2026-06-13 11:42:26', '2026-06-13 11:42:26', NULL, 'a4b28821b462cd1fadab05c4e159b66b', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'ready', 'in_progress', NULL, '2026-06-13 11:45:19', '2026-06-13 11:45:19', '2026-06-13 11:45:19', NULL, '7467127de4d8f29ed0e76b8a01489af9', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:21', '2026-06-13 11:47:21', '2026-06-13 11:47:21', NULL, 'dd46fa777ff5fbb42396fe4037288165', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:26', '2026-06-13 11:47:26', '2026-06-13 11:47:26', NULL, 'fc555a3966e167d5273a8c8338214070', 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', 'backlog', 'ready', NULL, '2026-06-13 11:47:31', '2026-06-13 11:47:31', '2026-06-13 11:47:31', NULL, '56abf6590754059d88570072056c5e31', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'in_progress', 'done', NULL, '2026-06-13 12:45:43', '2026-06-13 12:45:43', '2026-06-13 12:45:43', NULL, 'e540413389a0c213369d83a3a75f9706', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -5161,3 +5161,178 @@ OPEN QUESTIONS:
|
||||
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view — affects testability + scroll-into-view.
|
||||
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
|
||||
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 11:42:26', NULL, 'a1258e7d47ac99671a66d47eed661939', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
|
||||
|
||||
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all — mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
|
||||
|
||||
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. — semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
|
||||
|
||||
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) — child ticket adds the commands, vim binds gt/gT to them.
|
||||
|
||||
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
|
||||
|
||||
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404–407) ---
|
||||
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w …, g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift — and it must be built ONCE, in one place, not three times.
|
||||
|
||||
RECOMMENDED SEQUENCING:
|
||||
1. T-406 (the structural child) FIRST — it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
|
||||
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path — land it anytime for immediate value across every preset.
|
||||
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
|
||||
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet — T-407 must add one).
|
||||
|
||||
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) — coordinate the shared-prefix matcher tests.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-13 11:45:19', NULL, '5518d537ba94da58e79fb00562bab03a', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
|
||||
|
||||
- `ctrl+w h` → command:panel.focus.left; `ctrl+w l` → command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` → command:dock.toggle — document the approximation in the YAML comment)
|
||||
- `ctrl+w w` and `ctrl+w ctrl+w` → focus.nextPanel; `ctrl+w shift+w` → focus.previousPanel
|
||||
- `ctrl+w o` → command:panel.focusMode (vim "only" — exact semantic match)
|
||||
- `ctrl+w q` and `ctrl+w c` → command:editor.close
|
||||
|
||||
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset — bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
|
||||
|
||||
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
|
||||
|
||||
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
|
||||
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands — but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey → KeymapService.resolveEvent → Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings — it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
|
||||
|
||||
ACCEPTANCE CRITERIA:
|
||||
- vim.yaml gains ctrl+w bindings: `ctrl+w h`→panel.focus.left, `ctrl+w l`→panel.focus.right, `ctrl+w j`→dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`→focus.nextPanel, `ctrl+w shift+w`→focus.previousPanel, `ctrl+w o`→panel.focusMode, `ctrl+w q`/`ctrl+w c`→editor.close, all `when: vim.normal || vim.visual`.
|
||||
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
|
||||
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
|
||||
- No resolution change under default/vscode/jetbrains — editor_presets_test.dart `ctrl+w → editor.close` (e.g. line 60) stays green.
|
||||
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
|
||||
- make analyze + format + keymap suite pass; 95% coverage floor holds.
|
||||
|
||||
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey — single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords — decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
|
||||
|
||||
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own — non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface — coordinate ordering to avoid three matcher rewires.
|
||||
|
||||
OPEN QUESTIONS:
|
||||
- Where does the global multi-chord matcher live — a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
|
||||
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately — confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
|
||||
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists — intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
|
||||
- Should the family also fire in vim.insert (it shouldn''t — ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-13 11:47:21', NULL, '0a109484fa4b7e20860d503cebb1c612', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
|
||||
|
||||
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous — cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
|
||||
|
||||
2. vim.yaml: `g t` → command:workspace.tab.next, `g shift+t` → command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix — the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
|
||||
|
||||
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
|
||||
|
||||
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
|
||||
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap — only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle — so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`→workspace.tab.next and `g shift+t`→workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntent→commands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it — but only once a matcher runs on the focused surface.
|
||||
|
||||
ACCEPTANCE CRITERIA:
|
||||
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
|
||||
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
|
||||
- vim.yaml binds `g t`→workspace.tab.next, `g shift+t`→workspace.tab.previous, when: vim.normal.
|
||||
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
|
||||
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
|
||||
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
|
||||
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
|
||||
|
||||
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` — same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
|
||||
|
||||
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now — needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
|
||||
|
||||
OPEN QUESTIONS:
|
||||
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
|
||||
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
|
||||
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
|
||||
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-13 11:47:26', NULL, 'b987c27388c5af5841dbf454e365de32', 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.', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:47:31', NULL, 'e8a308082017a6bdbb79aa0cff8c9c5d', 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 ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
|
||||
|
||||
Mechanism (follow the ActivateIntent pattern from default.yaml — intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
|
||||
|
||||
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
|
||||
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists — check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary — verify dispatch order and document it.
|
||||
3. Panes opt in with Actions handlers:
|
||||
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
|
||||
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
|
||||
- ticket board + git panel lists: selection cursor + activate
|
||||
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later — the intents are preset-neutral; this ticket only wires vim.
|
||||
|
||||
Scope guard: this is keyboard NAVIGATION only — no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
|
||||
|
||||
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
|
||||
|
||||
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
|
||||
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only — it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, G→maxScrollExtent AND re-arm _atBottom follow-tail, gg→0). Lists (tickets/git) trail in a follow-up commit. Navigation only — no editing semantics outside the editor.
|
||||
|
||||
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability — land/decide this first.
|
||||
|
||||
ACCEPTANCE CRITERIA:
|
||||
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
|
||||
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
|
||||
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
|
||||
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, gg→offset 0, G→_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
|
||||
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
|
||||
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
|
||||
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
|
||||
|
||||
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
|
||||
|
||||
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart — coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
|
||||
|
||||
OPEN QUESTIONS:
|
||||
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag — VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard — (b) only works because each pane owns its handler.
|
||||
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view — affects testability + scroll-into-view.
|
||||
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
|
||||
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 12:45:43', NULL, '5d7f59ef58611f7fe6c8a4cb3e755154', 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,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- **Vim normal-mode navigation works outside the editor.** Under the vim preset,
|
||||
a focused file tree or conversation now responds to j/k, ctrl+d/ctrl+u, gg/G,
|
||||
and (tree) h/l/o — a selection cursor in the tree, scrolling in the
|
||||
conversation. Each pane runs its own sequence matcher; an `editor.focused`
|
||||
flag keeps these keys as buffer motions while the editor holds focus. (T-406)
|
||||
- **Claude Code Workflow runs surface in the conversation and sidebar.** A
|
||||
`Workflow` tool-use renders a dedicated run card — phase groups, per-agent
|
||||
rows with live spinner/check status, usage, and the script — driven by the
|
||||
|
||||
@@ -63,6 +63,43 @@ bindings:
|
||||
- intent: text.scaleReset
|
||||
keys: [ctrl+0, meta+0]
|
||||
|
||||
# ---- Pane navigation (non-editor panes) ------------------------------
|
||||
# When a non-editor pane holds focus (file tree, conversation, lists), the
|
||||
# same motion keys mean NAVIGATION, not buffer edits (T-406). The
|
||||
# `!editor.focused` guard keeps these out of the editor's way; the editor
|
||||
# publishes `editor.focused` while it has focus. These MUST precede the
|
||||
# editor motions below — the resolver takes the first matching binding in
|
||||
# file order, so with a pane focused (editor.focused false) nav wins, and
|
||||
# with the editor focused the `!editor.focused` clause fails and the buffer
|
||||
# motion below wins. Each pane runs its own SequenceMatcher (PaneKeyNav).
|
||||
- intent: nav.down
|
||||
keys: j
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.up
|
||||
keys: k
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.pageDown
|
||||
keys: ctrl+d
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.pageUp
|
||||
keys: ctrl+u
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.top
|
||||
keys: "g g" # gg
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.bottom
|
||||
keys: shift+g # G
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.expandOrRight
|
||||
keys: l
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.collapseOrLeft
|
||||
keys: h
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.activate
|
||||
keys: [o, enter]
|
||||
when: "vim.normal && !editor.focused"
|
||||
|
||||
# ---- Mode transitions ------------------------------------------------
|
||||
- intent: command:vim.mode.visual
|
||||
keys: v
|
||||
|
||||
@@ -24,6 +24,8 @@ import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
@@ -387,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
return list;
|
||||
},
|
||||
);
|
||||
return ColoredBox(
|
||||
final body = ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
|
||||
);
|
||||
// Vim nav scrolls the conversation while this region holds focus under the
|
||||
// vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to
|
||||
// the ends — G also re-arms follow-tail so new output keeps it pinned.
|
||||
return PaneKeyNav(onNav: _onNav, child: body);
|
||||
}
|
||||
|
||||
/// One "line" of scroll for j/k — a few text rows' worth.
|
||||
static const double _lineScroll = 48;
|
||||
|
||||
void _onNav(NavIntent intent, int count) {
|
||||
if (!_scroll.hasClients) return;
|
||||
final p = _scroll.position;
|
||||
final half = p.viewportDimension / 2;
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
_scrollBy(_lineScroll * count);
|
||||
case NavUpIntent():
|
||||
_scrollBy(-_lineScroll * count);
|
||||
case NavPageDownIntent():
|
||||
_scrollBy(half);
|
||||
case NavPageUpIntent():
|
||||
_scrollBy(-half);
|
||||
case NavTopIntent():
|
||||
_scroll.jumpTo(0);
|
||||
_atBottom = false;
|
||||
case NavBottomIntent():
|
||||
_scroll.jumpTo(p.maxScrollExtent);
|
||||
_atBottom = true; // re-arm follow-tail (T-297)
|
||||
case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent():
|
||||
break; // a reader pane has no expand/activate semantics
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollBy(double delta) {
|
||||
final p = _scroll.position;
|
||||
final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent);
|
||||
_scroll.jumpTo(target);
|
||||
_atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
|
||||
super.initState();
|
||||
_text = SyntaxTextController(syntax: _syntax);
|
||||
_focus = FocusNode();
|
||||
_focus.addListener(_onFocusChanged);
|
||||
_text.addListener(_onTextChanged);
|
||||
_tabs.addListener(_onTabsChanged);
|
||||
}
|
||||
|
||||
/// Publish `editor.focused` so non-editor panes can guard their vim nav
|
||||
/// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G
|
||||
/// stay buffer motions; when a pane holds focus they become nav (T-406).
|
||||
void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
|
||||
void dispose() {
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.dispose();
|
||||
_focus.removeListener(_onFocusChanged);
|
||||
_focus.dispose();
|
||||
_tabs.removeListener(_onTabsChanged);
|
||||
_tabs.dispose();
|
||||
_controller?.removeListener(_onControllerChanged);
|
||||
_controller?.dispose();
|
||||
_keymap?.removeListener(_onModeChanged);
|
||||
_keymap?.clearScopeFlag('editor.focused');
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,26 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// One row in the flattened, currently-visible tree (T-406). The visible set is
|
||||
/// a pre-order walk of the root plus the children of every expanded directory —
|
||||
/// the same order the tree renders — so a selection cursor can move over it with
|
||||
/// j/k.
|
||||
@immutable
|
||||
class TreeNode {
|
||||
const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth});
|
||||
|
||||
final String path;
|
||||
final String name;
|
||||
final bool isDirectory;
|
||||
final int depth;
|
||||
}
|
||||
|
||||
class FileTreeController extends ChangeNotifier {
|
||||
FileTreeController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
|
||||
final Map<String, List<FileEntry>> _entries = {};
|
||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||
|
||||
/// Display name of the workspace root row ('' path).
|
||||
String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? '';
|
||||
|
||||
// -- Keyboard selection cursor (T-406) -------------------------------------
|
||||
|
||||
/// The path of the currently selected row, or null when nothing is selected.
|
||||
/// '' is the workspace-root row.
|
||||
String? _selectedPath;
|
||||
String? get selectedPath => _selectedPath;
|
||||
|
||||
/// The flattened, currently-visible rows in render order: the root, then the
|
||||
/// children of every expanded directory, depth-first.
|
||||
List<TreeNode> visibleNodes() {
|
||||
final out = <TreeNode>[];
|
||||
if (_rootPath == null) return out;
|
||||
out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0));
|
||||
if (isExpanded('')) _appendChildren('', 1, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
void _appendChildren(String path, int depth, List<TreeNode> out) {
|
||||
final entries = _entries[path];
|
||||
if (entries == null) return;
|
||||
for (final e in entries) {
|
||||
out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth));
|
||||
if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
TreeNode? _selectedNode([List<TreeNode>? nodes]) {
|
||||
final list = nodes ?? visibleNodes();
|
||||
for (final n in list) {
|
||||
if (n.path == _selectedPath) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Move the selection cursor [delta] rows (negative = up), clamped to the
|
||||
/// visible list. A first move with nothing selected lands on the first row
|
||||
/// (down) or last row (up).
|
||||
void moveSelection(int delta) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final cur = nodes.indexWhere((n) => n.path == _selectedPath);
|
||||
final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1);
|
||||
if (nodes[next].path == _selectedPath) return;
|
||||
_selectedPath = nodes[next].path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Select the first ([top]) or last visible row — vim gg / G.
|
||||
void selectEdge({required bool top}) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final path = (top ? nodes.first : nodes.last).path;
|
||||
if (path == _selectedPath) return;
|
||||
_selectedPath = path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Collapse the selected directory, or — if it's already collapsed (or a
|
||||
/// file) — step the selection out to its parent row (vim `h`).
|
||||
Future<void> collapseOrOut() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return;
|
||||
if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) {
|
||||
await toggle(node.path); // collapse in place; selection stays on the dir
|
||||
return;
|
||||
}
|
||||
if (node.path == '') return; // already at root
|
||||
_selectedPath = _parentOf(node.path);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Expand the selected directory, or — if it's already expanded — step the
|
||||
/// selection into its first child (vim `l`). A file is a no-op.
|
||||
Future<void> expandOrInto() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null || !node.isDirectory) return;
|
||||
if (!_expanded.contains(node.path)) {
|
||||
await toggle(node.path); // expand
|
||||
return;
|
||||
}
|
||||
final children = _entries[node.path];
|
||||
if (children != null && children.isNotEmpty) {
|
||||
_selectedPath = children.first.path;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the selected row to an action target for the view: a directory to
|
||||
/// toggle, or a file path to open (vim `o` / `enter`). Returns null when
|
||||
/// nothing is selected.
|
||||
({bool isDirectory, String path})? activateTarget() {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return null;
|
||||
return (isDirectory: node.isDirectory, path: node.path);
|
||||
}
|
||||
|
||||
List<FileEntry> allLoadedEntries() {
|
||||
final out = <FileEntry>[];
|
||||
for (final list in _entries.values) {
|
||||
|
||||
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
|
||||
class _FileTreeViewState extends State<FileTreeView> {
|
||||
FileTreeController? _controller;
|
||||
String _filter = '';
|
||||
final ScrollController _scroll = ScrollController();
|
||||
|
||||
/// Key on the currently-selected row, so a keyboard move can scroll it into
|
||||
/// view (T-406).
|
||||
final GlobalKey _selectedKey = GlobalKey();
|
||||
|
||||
/// Half-page step for ctrl+d / ctrl+u over the flattened tree.
|
||||
static const int _pageStep = 10;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onNav(NavIntent intent, int count, FileTreeController c) {
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
c.moveSelection(count);
|
||||
case NavUpIntent():
|
||||
c.moveSelection(-count);
|
||||
case NavPageDownIntent():
|
||||
c.moveSelection(_pageStep);
|
||||
case NavPageUpIntent():
|
||||
c.moveSelection(-_pageStep);
|
||||
case NavTopIntent():
|
||||
c.selectEdge(top: true);
|
||||
case NavBottomIntent():
|
||||
c.selectEdge(top: false);
|
||||
case NavExpandOrRightIntent():
|
||||
unawaited(c.expandOrInto());
|
||||
case NavCollapseOrLeftIntent():
|
||||
unawaited(c.collapseOrOut());
|
||||
case NavActivateIntent():
|
||||
_activateSelected(c);
|
||||
}
|
||||
}
|
||||
|
||||
void _activateSelected(FileTreeController c) {
|
||||
final t = c.activateTarget();
|
||||
if (t == null) return;
|
||||
if (t.isDirectory) {
|
||||
unawaited(c.toggle(t.path));
|
||||
} else {
|
||||
openWorkspaceFile(ClideKernel.of(context), t.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the selected row into view after the frame it's laid out in.
|
||||
void _ensureSelectedVisible() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _selectedKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
final selected = c.selectedPath;
|
||||
if (_filter.isEmpty && selected != null) _ensureSelectedVisible();
|
||||
final scroller = SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||
@@ -65,20 +133,10 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
label: 'file tree — $rootName',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
|
||||
// region holds focus under the vim preset (T-406). The filter
|
||||
// box sits outside it, so typing a filter is never intercepted.
|
||||
child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
const _Children({required this.path, required this.controller, required this.depth});
|
||||
const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
if (controller.isExpanded(e.path))
|
||||
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
)
|
||||
else
|
||||
_FileRow(name: e.name, path: e.path, depth: depth),
|
||||
_FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirRow extends StatelessWidget {
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = controller.isExpanded(path);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '${expanded ? 'Collapse' : 'Expand'} $name',
|
||||
onTap: () => controller.toggle(path),
|
||||
child: _Row(
|
||||
key: selected ? selectedKey : null,
|
||||
depth: depth,
|
||||
onTap: () => controller.toggle(path),
|
||||
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
||||
label: name,
|
||||
rotateLeading: expanded,
|
||||
selected: selected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({required this.name, required this.path, required this.depth});
|
||||
const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
onTap: () => _openFile(context, path),
|
||||
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
|
||||
child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
|
||||
const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false});
|
||||
|
||||
final int depth;
|
||||
final VoidCallback onTap;
|
||||
@@ -188,6 +257,10 @@ class _Row extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final bool rotateLeading;
|
||||
|
||||
/// True when the keyboard selection cursor is on this row (T-406) — draws a
|
||||
/// persistent highlight + accent ring, distinct from transient hover.
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
@@ -195,7 +268,12 @@ class _Row extends StatelessWidget {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
decoration: selected
|
||||
? BoxDecoration(
|
||||
color: tokens.sidebarItemHover,
|
||||
border: Border.all(color: tokens.globalFocus, width: 1),
|
||||
)
|
||||
: (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null),
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -28,6 +28,7 @@ export 'src/keymap/key_chord.dart';
|
||||
export 'src/keymap/keymap.dart';
|
||||
export 'src/keymap/keymap_service.dart';
|
||||
export 'src/keymap/modifier_tap.dart';
|
||||
export 'src/keymap/pane_key_nav.dart';
|
||||
export 'src/keymap/sequence_matcher.dart';
|
||||
export 'src/keymap/when_clause.dart';
|
||||
export 'src/dialog.dart';
|
||||
|
||||
@@ -98,6 +98,63 @@ class TextScaleResetIntent extends Intent {
|
||||
const TextScaleResetIntent();
|
||||
}
|
||||
|
||||
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
|
||||
|
||||
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
|
||||
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
|
||||
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
|
||||
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
|
||||
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
|
||||
/// apart from any other fired intent.
|
||||
sealed class NavIntent extends Intent {
|
||||
const NavIntent();
|
||||
}
|
||||
|
||||
/// Move the selection / scroll down one step (vim `j`).
|
||||
class NavDownIntent extends NavIntent {
|
||||
const NavDownIntent();
|
||||
}
|
||||
|
||||
/// Move the selection / scroll up one step (vim `k`).
|
||||
class NavUpIntent extends NavIntent {
|
||||
const NavUpIntent();
|
||||
}
|
||||
|
||||
/// Scroll down half a viewport (vim `ctrl+d`).
|
||||
class NavPageDownIntent extends NavIntent {
|
||||
const NavPageDownIntent();
|
||||
}
|
||||
|
||||
/// Scroll up half a viewport (vim `ctrl+u`).
|
||||
class NavPageUpIntent extends NavIntent {
|
||||
const NavPageUpIntent();
|
||||
}
|
||||
|
||||
/// Jump to the first item / top (vim `gg`).
|
||||
class NavTopIntent extends NavIntent {
|
||||
const NavTopIntent();
|
||||
}
|
||||
|
||||
/// Jump to the last item / bottom (vim `G`).
|
||||
class NavBottomIntent extends NavIntent {
|
||||
const NavBottomIntent();
|
||||
}
|
||||
|
||||
/// Expand the focused node, or step into it / move right (vim `l`).
|
||||
class NavExpandOrRightIntent extends NavIntent {
|
||||
const NavExpandOrRightIntent();
|
||||
}
|
||||
|
||||
/// Collapse the focused node, or step out of it / move left (vim `h`).
|
||||
class NavCollapseOrLeftIntent extends NavIntent {
|
||||
const NavCollapseOrLeftIntent();
|
||||
}
|
||||
|
||||
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
|
||||
class NavActivateIntent extends NavIntent {
|
||||
const NavActivateIntent();
|
||||
}
|
||||
|
||||
// -- Command bridge ---------------------------------------------------------
|
||||
|
||||
/// Generic "invoke this CommandRegistry command id" intent. Used for
|
||||
@@ -136,6 +193,16 @@ final Map<String, Intent Function()> builtinIntents = {
|
||||
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
||||
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
||||
'findInFiles.open': () => const FindInFilesIntent(),
|
||||
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
|
||||
'nav.down': () => const NavDownIntent(),
|
||||
'nav.up': () => const NavUpIntent(),
|
||||
'nav.pageDown': () => const NavPageDownIntent(),
|
||||
'nav.pageUp': () => const NavPageUpIntent(),
|
||||
'nav.top': () => const NavTopIntent(),
|
||||
'nav.bottom': () => const NavBottomIntent(),
|
||||
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
|
||||
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
|
||||
'nav.activate': () => const NavActivateIntent(),
|
||||
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
||||
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
||||
'text.scaleReset': () => const TextScaleResetIntent(),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/// A reusable vim normal-mode navigation key handler for non-editor panes
|
||||
/// (T-406).
|
||||
///
|
||||
/// The passive global key path is single-chord only and can't run sequences or
|
||||
/// consume events (D-82), so — exactly like the editor's command-mode handler —
|
||||
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
|
||||
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
|
||||
/// tree, conversation, and lists share one implementation.
|
||||
///
|
||||
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
|
||||
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
|
||||
/// the matcher against the live keymap; a fired [NavIntent] is handed to
|
||||
/// [onNav] with its repeat count. Everything else under `vim.normal` is
|
||||
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
|
||||
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
|
||||
/// non-vim preset or in insert mode the region is transparent — keys pass
|
||||
/// straight through.
|
||||
///
|
||||
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
|
||||
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
|
||||
/// intent here and to the editor motion in the editor — see vim.yaml.
|
||||
library;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../facade.dart';
|
||||
import 'intents.dart';
|
||||
import 'key_chord.dart';
|
||||
import 'keymap.dart';
|
||||
import 'sequence_matcher.dart';
|
||||
|
||||
/// Signature for a fired navigation motion: the [intent] and its repeat
|
||||
/// [count] (>= 1, from a leading digit prefix like `5j`).
|
||||
typedef NavHandler = void Function(NavIntent intent, int count);
|
||||
|
||||
class PaneKeyNav extends StatefulWidget {
|
||||
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Called when a `nav.*` motion resolves while this region has focus.
|
||||
final NavHandler onNav;
|
||||
|
||||
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
|
||||
/// want to move focus here programmatically (a row tap, F6) pass their own.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
final bool autofocus;
|
||||
|
||||
/// Whether the region can take focus at all. False makes it a pure pass-through
|
||||
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
|
||||
final bool canRequestFocus;
|
||||
|
||||
@override
|
||||
State<PaneKeyNav> createState() => _PaneKeyNavState();
|
||||
}
|
||||
|
||||
class _PaneKeyNavState extends State<PaneKeyNav> {
|
||||
FocusNode? _ownNode;
|
||||
SequenceMatcher? _matcher;
|
||||
|
||||
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
|
||||
|
||||
/// The half-page scroll chords are the only modified chords this handler
|
||||
/// claims; every other modified chord bubbles to the global shortcut path.
|
||||
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
|
||||
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_matcher != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ownNode?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
final kernel = ClideKernel.of(context);
|
||||
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
|
||||
// preset → transparent, keys pass through to whatever's below.
|
||||
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
|
||||
|
||||
final hw = HardwareKeyboard.instance;
|
||||
final chord = KeyChord.fromKeyEvent(event, hw);
|
||||
if (chord == null) return KeyEventResult.ignored;
|
||||
|
||||
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
|
||||
// modified chords we claim (half-page scroll). Any other modified chord is
|
||||
// an app shortcut (palette, quick-open) — let it bubble to the global path.
|
||||
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
|
||||
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
|
||||
|
||||
final r = _matcher!.feed(chord);
|
||||
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);
|
||||
return KeyEventResult.handled;
|
||||
case SeqOutcome.pending:
|
||||
return KeyEventResult.handled;
|
||||
case SeqOutcome.unmatched:
|
||||
// Vim normal mode beeps on unbound keys — swallow so a bare key never
|
||||
// leaks to text input or the global handler.
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,11 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/kernel.dart' show PaneKeyNav;
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey;
|
||||
import 'package:flutter/widgets.dart' show Builder, Focus, Image, FileImage, MediaQuery, Scrollable, ScrollableState, ValueKey;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
@@ -199,6 +200,61 @@ void main() {
|
||||
expect(find.text('Waiting for Claude…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('vim G / gg / j scroll the conversation under vim.normal (T-406)', (tester) async {
|
||||
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
|
||||
|
||||
// Enough prose to overflow the 700px viewport so there's room to scroll.
|
||||
await pumpWith(tester, [for (var i = 0; i < 40; i++) AssistantTextMessage(uuid: 'a$i', timestamp: _t, isSidechain: false, text: 'line number $i')]);
|
||||
|
||||
// Focus the pane's nav region (its own Focus is PaneKeyNav's outermost).
|
||||
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
final pos = tester.state<ScrollableState>(find.byType(Scrollable).first).position;
|
||||
expect(pos.maxScrollExtent, greaterThan(0), reason: 'content must overflow to scroll');
|
||||
|
||||
// G → jump to the bottom.
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, pos.maxScrollExtent);
|
||||
|
||||
// gg → jump to the top.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
|
||||
// j → down one line (48px); k → back up.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 48);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
|
||||
// ctrl+d / ctrl+u → half a viewport down then back up.
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, greaterThan(0));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
|
||||
// h / l / o have no reader-pane semantics — they don't move the scroll.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
});
|
||||
|
||||
testWidgets('a Workflow tool-use with a live run renders the workflow card (T-416)', (tester) async {
|
||||
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
|
||||
run = run.foldEvent({
|
||||
|
||||
@@ -56,6 +56,13 @@ void main() {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('focusing the editor publishes editor.focused (T-406)', (tester) async {
|
||||
stubOneBuffer('hello');
|
||||
expect(f.services.keymap.scope['editor.focused'], isNot(true));
|
||||
await pumpEditor(tester); // taps into the editor → focus
|
||||
expect(f.services.keymap.scope['editor.focused'], isTrue, reason: 'pane nav guards on !editor.focused');
|
||||
});
|
||||
|
||||
testWidgets('normal-mode x deletes the char under the caret', (tester) async {
|
||||
String? sentText;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
|
||||
@@ -22,9 +22,13 @@ void main() {
|
||||
km = Keymap([KeymapLayer.fromYaml(src)]);
|
||||
});
|
||||
|
||||
const normal = {'vim.normal': true};
|
||||
// Editor-focused normal mode: j/k/h/l/gg/G/o are buffer motions here because
|
||||
// the `editor.focused` flag suppresses the pane-nav bindings (T-406).
|
||||
const normal = {'vim.normal': true, 'editor.focused': true};
|
||||
const insert = {'vim.insert': true};
|
||||
const visual = {'vim.visual': true};
|
||||
// A non-editor pane focused under vim normal mode: the same keys are nav.*.
|
||||
const paneNormal = {'vim.normal': true};
|
||||
|
||||
Intent? resolve(String chord, Map<String, bool> scope) => km.resolve(KeyChord.parse(chord), scope);
|
||||
|
||||
@@ -83,4 +87,43 @@ void main() {
|
||||
expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending);
|
||||
expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
|
||||
});
|
||||
|
||||
group('pane navigation (T-406)', () {
|
||||
test('motion keys resolve to nav.* when a non-editor pane is focused', () {
|
||||
expect(resolve('j', paneNormal), isA<NavDownIntent>());
|
||||
expect(resolve('k', paneNormal), isA<NavUpIntent>());
|
||||
expect(resolve('h', paneNormal), isA<NavCollapseOrLeftIntent>());
|
||||
expect(resolve('l', paneNormal), isA<NavExpandOrRightIntent>());
|
||||
expect(resolve('ctrl+d', paneNormal), isA<NavPageDownIntent>());
|
||||
expect(resolve('ctrl+u', paneNormal), isA<NavPageUpIntent>());
|
||||
expect(resolve('shift+g', paneNormal), isA<NavBottomIntent>());
|
||||
expect(resolve('o', paneNormal), isA<NavActivateIntent>());
|
||||
expect(resolve('enter', paneNormal), isA<NavActivateIntent>());
|
||||
});
|
||||
|
||||
test('the editor.focused guard hands the same keys to the editor', () {
|
||||
// With the editor focused, nav.* is suppressed and the buffer motions win.
|
||||
expect(_cmd(resolve('j', normal)), 'editor.vim.down');
|
||||
expect(_cmd(resolve('h', normal)), 'editor.vim.left');
|
||||
expect(_cmd(resolve('l', normal)), 'editor.vim.right');
|
||||
expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd');
|
||||
expect(_cmd(resolve('o', normal)), 'editor.vim.openBelow');
|
||||
});
|
||||
|
||||
test('gg resolves to nav.top in a pane, docStart in the editor', () {
|
||||
final pane = SequenceMatcher(keymap: () => km, context: () => paneNormal);
|
||||
pane.feed(KeyChord.parse('g'));
|
||||
expect(pane.feed(KeyChord.parse('g')).intent, isA<NavTopIntent>());
|
||||
|
||||
final editor = SequenceMatcher(keymap: () => km, context: () => normal);
|
||||
editor.feed(KeyChord.parse('g'));
|
||||
expect(_cmd(editor.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
|
||||
});
|
||||
|
||||
test('pane nav is normal-mode only — visual mode keeps the editor motion', () {
|
||||
// nav.* is guarded `vim.normal && !editor.focused`; visual mode has no
|
||||
// vim.normal flag, so j stays the editor motion even without editor.focused.
|
||||
expect(_cmd(resolve('j', visual)), 'editor.vim.down');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,6 +382,99 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('FileTreeController — keyboard selection (T-406)', () {
|
||||
// Tree: '' (root) → [lib/ (→ app.dart), main.dart]
|
||||
Future<FileTreeController> tree({bool expandLib = false}) async {
|
||||
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
|
||||
f.ipc.stub('files.watch', (_) async => _ok(const {}));
|
||||
f.ipc.stub('files.ls', (args) async {
|
||||
final path = args['path'] as String? ?? '';
|
||||
if (path == '') {
|
||||
return _ok({
|
||||
'entries': [_fileEntry(name: 'lib', path: 'lib', isDirectory: true), _fileEntry(name: 'main.dart', path: 'main.dart')],
|
||||
});
|
||||
}
|
||||
if (path == 'lib') {
|
||||
return _ok({
|
||||
'entries': [_fileEntry(name: 'app.dart', path: 'lib/app.dart')],
|
||||
});
|
||||
}
|
||||
return _ok({'entries': <Object?>[]});
|
||||
});
|
||||
final c = makeCtrl();
|
||||
await c.load();
|
||||
if (expandLib) await c.toggle('lib');
|
||||
return c;
|
||||
}
|
||||
|
||||
test('visibleNodes flattens the root + expanded children in render order', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'lib/app.dart', 'main.dart']);
|
||||
expect(c.visibleNodes().map((n) => n.depth), [0, 1, 2, 1]);
|
||||
});
|
||||
|
||||
test('a collapsed directory hides its children from the visible list', () async {
|
||||
final c = await tree();
|
||||
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'main.dart']);
|
||||
});
|
||||
|
||||
test('moveSelection walks the visible list and clamps at the ends', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
expect(c.selectedPath, isNull);
|
||||
c.moveSelection(1);
|
||||
expect(c.selectedPath, ''); // first move lands on the root
|
||||
c.moveSelection(1);
|
||||
expect(c.selectedPath, 'lib');
|
||||
c.moveSelection(2);
|
||||
expect(c.selectedPath, 'main.dart'); // lib/app.dart skipped over by +2
|
||||
c.moveSelection(5); // clamp at the bottom
|
||||
expect(c.selectedPath, 'main.dart');
|
||||
c.moveSelection(-100); // clamp at the top
|
||||
expect(c.selectedPath, '');
|
||||
});
|
||||
|
||||
test('selectEdge jumps to the first / last visible row (gg / G)', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
c.selectEdge(top: false);
|
||||
expect(c.selectedPath, 'main.dart');
|
||||
c.selectEdge(top: true);
|
||||
expect(c.selectedPath, '');
|
||||
});
|
||||
|
||||
test('expandOrInto expands a collapsed dir, then steps into its first child', () async {
|
||||
final c = await tree();
|
||||
c.moveSelection(1); // root
|
||||
c.moveSelection(1); // lib (collapsed)
|
||||
expect(c.isExpanded('lib'), isFalse);
|
||||
await c.expandOrInto(); // expands
|
||||
expect(c.isExpanded('lib'), isTrue);
|
||||
expect(c.selectedPath, 'lib'); // selection stays on the dir
|
||||
await c.expandOrInto(); // steps into first child
|
||||
expect(c.selectedPath, 'lib/app.dart');
|
||||
});
|
||||
|
||||
test('collapseOrOut collapses an expanded dir, then steps out to the parent', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
c.selectEdge(top: true);
|
||||
c.moveSelection(2); // lib/app.dart
|
||||
expect(c.selectedPath, 'lib/app.dart');
|
||||
await c.collapseOrOut(); // a file → step to parent
|
||||
expect(c.selectedPath, 'lib');
|
||||
await c.collapseOrOut(); // an expanded dir → collapse in place
|
||||
expect(c.isExpanded('lib'), isFalse);
|
||||
expect(c.selectedPath, 'lib');
|
||||
});
|
||||
|
||||
test('activateTarget reports the selected row as dir-or-file for the view', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
c.selectEdge(top: true);
|
||||
c.moveSelection(1); // lib
|
||||
expect(c.activateTarget(), (isDirectory: true, path: 'lib'));
|
||||
c.moveSelection(2); // main.dart
|
||||
expect(c.activateTarget(), (isDirectory: false, path: 'main.dart'));
|
||||
});
|
||||
});
|
||||
|
||||
group('FileTreeController — dispose()', () {
|
||||
test('dispose cancels event subscription without error', () async {
|
||||
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/// Widget tests for keyboard navigation in the file tree (T-406): under the vim
|
||||
/// preset a focused tree moves a selection cursor with j/k, expands with l, and
|
||||
/// opens the selected file with o/enter — driving the FileTreeController through
|
||||
/// PaneKeyNav.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/files/src/file_tree_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.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) => IpcResponse.ok(id: '', data: data);
|
||||
Map<String, Object?> _entry(String name, String path, {bool dir = false}) => {
|
||||
'name': name,
|
||||
'path': path,
|
||||
'isDirectory': dir,
|
||||
'isSymlink': false,
|
||||
'sizeBytes': 0,
|
||||
'modifiedMs': 0,
|
||||
};
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
// Tree: /repo → [lib/ (→ app.dart), main.dart].
|
||||
void stubTree() {
|
||||
f.ipc.stub('files.root', (_) async => _ok({'path': '/repo'}));
|
||||
f.ipc.stub('files.watch', (_) async => _ok(const {}));
|
||||
f.ipc.stub('files.ls', (args) async {
|
||||
final path = args['path'] as String? ?? '';
|
||||
if (path == '') {
|
||||
return _ok({
|
||||
'entries': [_entry('lib', 'lib', dir: true), _entry('main.dart', 'main.dart')],
|
||||
});
|
||||
}
|
||||
if (path == 'lib') {
|
||||
return _ok({
|
||||
'entries': [_entry('app.dart', 'lib/app.dart')],
|
||||
});
|
||||
}
|
||||
return _ok({'entries': <Object?>[]});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> mountFocused(WidgetTester tester) async {
|
||||
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
|
||||
await tester.pumpWidget(harness(f, const FileTreeView()));
|
||||
await pumpAsync(tester);
|
||||
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('j moves the selection and o opens the selected file (T-406)', (tester) async {
|
||||
stubTree();
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path'] as String? ?? '');
|
||||
return _ok(const {});
|
||||
});
|
||||
await mountFocused(tester);
|
||||
|
||||
// visible: '' (root), 'lib', 'main.dart'. j×3 lands on main.dart.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(opened, ['main.dart']);
|
||||
});
|
||||
|
||||
testWidgets('l expands the selected directory, h collapses it (T-406)', (tester) async {
|
||||
stubTree();
|
||||
await mountFocused(tester);
|
||||
|
||||
expect(find.text('app.dart'), findsNothing); // lib collapsed
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // root
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // lib
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyL); // expand
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('app.dart'), findsOneWidget);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyH); // collapse lib
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('app.dart'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('G/gg/k and ctrl+d/u move the cursor; o on a dir toggles it (T-406)', (tester) async {
|
||||
stubTree();
|
||||
await mountFocused(tester);
|
||||
|
||||
// G → last visible row (main.dart), o → main.dart is a file → opens it.
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path'] as String? ?? '');
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG); // G → bottom
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyK); // up → lib
|
||||
await tester.pump();
|
||||
// o on the 'lib' directory toggles (expands) it rather than opening a file.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('app.dart'), findsOneWidget); // lib expanded, no file opened
|
||||
expect(opened, isEmpty);
|
||||
|
||||
// gg → top, then ctrl+d / ctrl+u exercise the half-page paths.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
// No crash, selection stayed in bounds — the dispatch paths ran.
|
||||
expect(opened, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/// Widget tests for PaneKeyNav (T-406): the per-pane vim-normal key handler
|
||||
/// that runs its own SequenceMatcher and dispatches nav.* intents — proven
|
||||
/// end-to-end against the real vim preset and scope flags.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.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';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<List<NavIntent>> pump(WidgetTester tester, {required Map<String, bool> scope}) async {
|
||||
// setPreset does real asset + keybindings-file I/O; run it outside the
|
||||
// fake-async zone or the testWidgets body hangs (the T-122 lesson).
|
||||
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
|
||||
for (final e in scope.entries) {
|
||||
f.services.keymap.setScopeFlag(e.key, e.value);
|
||||
}
|
||||
final got = <NavIntent>[];
|
||||
final node = FocusNode();
|
||||
addTearDown(node.dispose);
|
||||
await tester.pumpWidget(
|
||||
harness(f, PaneKeyNav(focusNode: node, autofocus: true, onNav: (i, _) => got.add(i), child: const SizedBox(width: 100, height: 100))),
|
||||
);
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
return got;
|
||||
}
|
||||
|
||||
testWidgets('bare motions dispatch nav.* under vim.normal (pane focused)', (tester) async {
|
||||
final got = await pump(tester, scope: {'vim.normal': true});
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
|
||||
expect(got, [isA<NavDownIntent>(), isA<NavUpIntent>(), isA<NavCollapseOrLeftIntent>(), isA<NavExpandOrRightIntent>()]);
|
||||
});
|
||||
|
||||
testWidgets('gg sequence resolves to nav.top', (tester) async {
|
||||
final got = await pump(tester, scope: {'vim.normal': true});
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
expect(got, [isA<NavTopIntent>()]);
|
||||
});
|
||||
|
||||
testWidgets('ctrl+d / ctrl+u are claimed as half-page nav', (tester) async {
|
||||
final got = await pump(tester, scope: {'vim.normal': true});
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
expect(got, [isA<NavPageDownIntent>(), isA<NavPageUpIntent>()]);
|
||||
});
|
||||
|
||||
testWidgets('the editor.focused guard suppresses nav (keys go to the editor)', (tester) async {
|
||||
final got = await pump(tester, scope: {'vim.normal': true, 'editor.focused': true});
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
|
||||
// j/k now resolve to editor.vim.* — not NavIntents — so onNav never fires.
|
||||
expect(got, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('keys pass through outside vim normal mode', (tester) async {
|
||||
final got = await pump(tester, scope: {'vim.insert': true});
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
expect(got, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('an unbound bare key is swallowed without dispatching nav', (tester) async {
|
||||
final got = await pump(tester, scope: {'vim.normal': true});
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyZ);
|
||||
expect(got, isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user