From 4aa24a98988ec7041948c07a453c3a09bcc13b3e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 1 Jun 2026 20:50:49 +0200 Subject: [PATCH] fix dead default keymap from undefined focus intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit default.yaml bound tab/shift+tab to focus.next/focus.previous, intent ids absent from builtinIntents. parseIntentId returns null, so KeymapLayer.fromYaml throws — and KeymapService.load catches that and sets _preset = null. The whole default preset was silently dropped at boot: palette, quick-open, find-in-files, and zoom bindings never fired. It went unnoticed because every keymap_service_test injects a synthetic bundle; the shipped asset was never parsed in a test. Add focus.next -> NextFocusIntent and focus.previous -> PreviousFocusIntent (Flutter- provided, for correct Tab widget traversal), and a test that reads every real assets/keymaps/*.yaml through the loader so a future typo fails CI instead of disabling the keymap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_deps/2026-06.sql | 3 ++ .pql/changelog/ticket_history/2026-06.sql | 26 +++++++++ .pql/changelog/tickets/2026-06.sql | 18 +++++++ CHANGELOG.md | 5 ++ lib/kernel/src/keymap/intents.dart | 6 +++ .../src/keymap/shipped_presets_test.dart | 54 +++++++++++++++++++ 6 files changed, 112 insertions(+) create mode 100644 .pql/changelog/ticket_deps/2026-06.sql create mode 100644 test/kernel/src/keymap/shipped_presets_test.dart diff --git a/.pql/changelog/ticket_deps/2026-06.sql b/.pql/changelog/ticket_deps/2026-06.sql new file mode 100644 index 00000000..8f665b7c --- /dev/null +++ b/.pql/changelog/ticket_deps/2026-06.sql @@ -0,0 +1,3 @@ +INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-204', 'T-205', '2026-06-01 18:48:57', '2026-06-01 18:48:57', NULL, '114c7894d0dad6ad52fdaf55ac973ba8', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-205', 'T-206', '2026-06-01 18:48:57', '2026-06-01 18:48:57', NULL, 'a7d8d53a36034ef6c6f48425d52da49e', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); +INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-207', 'T-206', '2026-06-01 18:48:58', '2026-06-01 18:48:58', NULL, '03d07c24f7a03b17a9d21de621494494', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash); diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 01f1e675..d254b44d 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -13,3 +13,29 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, Reopened fix (2026-06-01): the original fix called panels.activateTab(Slots.workspace, ''editor.active''), but _WorkspaceSlot renders its editor split off arrangement.editorOpen, NOT the active tab — so the editor never appeared. Real fix: EditorExtension.activate now calls arrangement.openEditor() on editor.opened / active-changed(non-null) and closeEditor() on active-changed(null). Test asserts arrangement.editorOpen.', NULL, '2026-06-01 16:36:38', '2026-06-01 16:36:38', '2026-06-01 16:36:38', NULL, '62283198922795b643960d5beab56dba', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-203', 'status', 'backlog', 'done', NULL, '2026-06-01 16:48:56', '2026-06-01 16:48:56', '2026-06-01 16:48:56', NULL, '2cd8d1f09a2cd432491f4ee1fecbadc0', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-202', 'status', 'backlog', 'done', NULL, '2026-06-01 16:48:56', '2026-06-01 16:48:56', '2026-06-01 16:48:56', NULL, '2e730a377952b4ec2dd11561a97fcd00', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-65', 'description', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings. + +**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work: + +1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:` bindings. +2. Add a small mode-tracking service that publishes `vim.` scope flags via `KeymapService.setScopeFlag`. +3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc. + +**Acceptance:** +1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes. +2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions. +3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert).', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings. + +**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work: + +1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:` bindings. +2. Add a small mode-tracking service that publishes `vim.` scope flags via `KeymapService.setScopeFlag`. +3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc. + +**Acceptance:** +1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes. +2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions. +3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert). + +Refinement (2026-06-01): scoped Vim-first for a Vim-power-user demo this weekend. ''Author a YAML file'' was wrong — T-117 shipped single-chord resolution + scope flags + when-clauses only. Decomposed into children: T-204 (fix dead default preset — live bug), T-205 (key-sequence + count resolution), T-206 (modal editor motion/edit intents), T-207 (Vim mode service + status indicator). T-65 itself becomes assets/keymaps/vim.yaml + regression tests once children land. T-64/T-66 deferred (single-chord, easy; T-205 hands JetBrains shift+shift later). Foundation is shared, not Vim-only.', NULL, '2026-06-01 18:49:02', '2026-06-01 18:49:02', '2026-06-01 18:49:02', NULL, '3f379507780d78aa9f76b28f4a5c3179', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-204', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 18:49:17', '2026-06-01 18:49:17', '2026-06-01 18:49:17', NULL, 'b3a889c39a03676489bf19b8d3343cf9', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 85e9d039..2270fd19 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -11,3 +11,21 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, Reopened fix (2026-06-01): the original fix called panels.activateTab(Slots.workspace, ''editor.active''), but _WorkspaceSlot renders its editor split off arrangement.editorOpen, NOT the active tab — so the editor never appeared. Real fix: EditorExtension.activate now calls arrangement.openEditor() on editor.opened / active-changed(non-null) and closeEditor() on active-changed(null). Test asserts arrangement.editorOpen.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:19', '2026-06-01 16:36:38', NULL, '01039a9e83c3112ca93e33c228763b50', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-202', 'bug', NULL, 'rate_limit_event with numeric resetsAt crashes the Claude session', 'stream_json_session._statusFromEvent cast info[''resetsAt''] as String?, but claude sends resetsAt as a unix-epoch NUMBER → ''int is not a subtype of String?'' unhandled in _onLine, killing the session''s line parsing. Fix: accept num (epoch seconds → DateTime.fromMillisecondsSinceEpoch) or String (ISO). Surfaced in a live session log.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '589320cb1ad6a97a3d91c090ce4f2112', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-203', 'bug', 'T-197', 'Editor split throws semantics + SelectableRegion exceptions on open', 'Opening the editor split (now that T-197 works) flooded two exceptions. (1) The _EditorDragHandle Semantics had slider:true + value + onIncrease/onDecrease but no increasedValue/decreasedValue → ''SemanticsNode with action increase needs both value and increasedValue'' on every flush; added increasedValue/decreasedValue. (2) Reparenting the Claude pane (direct child → Column/Expanded) when the split opens disposed its ClideSelectionArea/SelectableRegion subtree mid selection-update microtask → ''selectable not in this registrar'' + ''renderObject of inactive element''; fixed with a stable GlobalKey on the workspace primary so Flutter moves the element instead of disposing it.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '77c284e1d35358783bda17955abba6b8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-205', 'story', 'T-65', 'Key-sequence resolution in the keymap layer (Vim motions, counts)', 'The resolver is single-chord: KeyChord.fromKeyEvent -> Keymap.resolve fires and forgets one keystroke. Real Vim needs multi-key sequences (dd, gg, dw, ciw, yy) and count prefixes (5j, 3dd). Add a pending-sequence model to KeymapService: accumulate chords into a buffer, match against sequence bindings by prefix, fire on full match, reset on no-prefix-match or timeout. Add a KeySequence type (ordered list of KeyChord) alongside KeyChord. Extend the YAML schema to express sequences distinctly from alias-lists: keys:[d,d] today means ''either d or d'' (alias) — need a ''sequence:'' key or a chord-string notation (e.g. ''d d'' space-separated) for ordered sequences. Add count-prefix capture so a leading digit run is parsed as a repeat count passed to the intent. Keep single-chord resolution unchanged (fast path). This also unblocks JetBrains shift+shift (T-66) via a double-tap special-case. Decision-worthy: the sequence/notation choice may warrant a D-record.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-01 18:48:27', '2026-06-01 18:48:27', NULL, '50a4ef00ac4ea54e4d83d0991fc9905b', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-206', 'story', 'T-65', 'Modal editor: Vim motion & edit intents', 'The editor (lib/builtin/editor/src/editor_view.dart) is a raw Flutter EditableText that only handles Cmd/Ctrl+S; in normal mode, letter keys must NOT insert text and must instead drive motions/edits. Add typed editor Intents + Action handlers wired into the editor that operate on the SyntaxTextController/TextEditingValue: cursor motions (cursorLeft/Down/Up/Right for h/j/k/l), word motions (wordForward/wordBackward/wordEnd for w/b/e), line motions (lineStart/lineEnd/firstNonBlank for 0/$/^), document motions (documentStart/End for gg/G), edits (deleteChar x, deleteLine dd, deleteWord dw, changeLine cc, changeWord cw, yankLine yy, paste p/P, openLineBelow/Above o/O), and insert-entry (enterInsert i/a/I/A/o). Motions+edits must compose with the count prefix (T-205) and operator+motion (dw, cw). The editor must read the active Vim mode (T-206 service) to decide whether a bare key inserts or commands — key interception must win over EditableText''s text input in normal mode. Acceptance: each intent has an Action that mutates the buffer correctly; regression tests cover j (cursor down), x (delete char), dd (delete line), i (enter insert).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-01 18:48:40', '2026-06-01 18:48:40', NULL, '752546ab98b527cd6abcb1232f578aca', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-207', 'story', 'T-65', 'Vim mode service + status-bar mode indicator', 'A small mode-tracking service (ChangeNotifier) that owns the current Vim mode (normal/insert/visual), publishes vim.normal / vim.insert / vim.visual scope flags via KeymapService.setScopeFlag, and exposes the mode to the editor (T-206) and a status-bar indicator (-- NORMAL -- / -- INSERT -- / -- VISUAL --). Handles transitions: Esc -> normal (from any mode); i/a/o/I/A -> insert (from normal); v -> visual (from normal); colon -> command-line indicator (stretch). Resets to normal on editor focus by default (configurable). The when-clause grammar already supports dotted flags (vim.normal && editor.focused), confirmed in when_clause.dart. Service is a pure ChangeNotifier with no deps; fits in lib/kernel/src/keymap/ or as an editor builtin piece. Acceptance: setPreset(''vim'') + this service produce correct mode transitions; a regression test asserts i->insert and Esc->normal flip the scope flags; the status bar reflects mode.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-01 18:48:47', '2026-06-01 18:48:47', NULL, '337aed65495c6d858c5ce205b54d5b06', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-65', 'story', NULL, 'Vim keybinding preset', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings. + +**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work: + +1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:` bindings. +2. Add a small mode-tracking service that publishes `vim.` scope flags via `KeymapService.setScopeFlag`. +3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc. + +**Acceptance:** +1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes. +2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions. +3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert). + +Refinement (2026-06-01): scoped Vim-first for a Vim-power-user demo this weekend. ''Author a YAML file'' was wrong — T-117 shipped single-chord resolution + scope flags + when-clauses only. Decomposed into children: T-204 (fix dead default preset — live bug), T-205 (key-sequence + count resolution), T-206 (modal editor motion/edit intents), T-207 (Vim mode service + status indicator). T-65 itself becomes assets/keymaps/vim.yaml + regression tests once children land. T-64/T-66 deferred (single-chord, easy; T-205 hands JetBrains shift+shift later). Foundation is shared, not Vim-only.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-01 18:49:02', NULL, '8a7f2eee186a7824893e7f2d22604156', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-204', 'task', 'T-65', 'Fix dead default keymap: focus.next/previous undefined drops whole preset', 'The keymap loader catches FormatException from preset parsing and silently sets _preset=null (keymap_service.dart load()). default.yaml binds tab->focus.next and shift+tab->focus.previous, but neither id exists in builtinIntents (only focus.nextPanel/previousPanel). parseIntentId returns null -> KeymapLayer.fromYaml throws -> the ENTIRE default preset is dropped at boot. So palette (ctrl+shift+p), quick-open (ctrl+p), find-in-files, and text-scale bindings are all dead at runtime. Uncaught because every keymap_service_test injects a synthetic bundle; the real asset is never parsed. Fix: add focus.next->NextFocusIntent and focus.previous->PreviousFocusIntent (Flutter-provided, like activate/dismiss) to builtinIntents so Tab does correct widget focus traversal; add a test that loads the REAL assets/keymaps/default.yaml and asserts it parses with the expected binding count, as a regression guard for every shipped preset.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-01 18:48:19', '2026-06-01 18:49:17', NULL, '01fc224dd1bef50e6c42340ec3a3d81f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7d1bcd..5774344c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- The default keymap is no longer silently disabled at startup: `default.yaml` + bound Tab/Shift+Tab to undefined `focus.next`/`focus.previous` intents, which + made the loader drop the entire preset (palette, quick-open, find-in-files, + zoom). Those are now real focus-traversal intents, and a test parses every + shipped preset so a typo fails CI instead. (T-204) - Opening the editor split no longer floods exceptions: the resize handle's slider semantics now carry increased/decreased values, and the Claude pane keeps a stable identity across the reparent so its text-selection region diff --git a/lib/kernel/src/keymap/intents.dart b/lib/kernel/src/keymap/intents.dart index 2f5f43c7..a5473a33 100644 --- a/lib/kernel/src/keymap/intents.dart +++ b/lib/kernel/src/keymap/intents.dart @@ -119,6 +119,12 @@ class InvokeCommandIntent extends Intent { final Map builtinIntents = { 'activate': () => const ActivateIntent(), 'dismiss': () => const DismissIntent(), + // Widget-level focus traversal (Tab / Shift+Tab). These are + // Flutter-provided, like activate/dismiss — bound by id so the + // default preset integrates with the framework's focus system. They + // are distinct from the panel-to-panel cycling below (F6). + 'focus.next': () => const NextFocusIntent(), + 'focus.previous': () => const PreviousFocusIntent(), 'focus.nextPanel': () => const FocusNextPanelIntent(), 'focus.previousPanel': () => const FocusPreviousPanelIntent(), 'palette.open': () => const PaletteOpenIntent(), diff --git a/test/kernel/src/keymap/shipped_presets_test.dart b/test/kernel/src/keymap/shipped_presets_test.dart new file mode 100644 index 00000000..4bf2d9bb --- /dev/null +++ b/test/kernel/src/keymap/shipped_presets_test.dart @@ -0,0 +1,54 @@ +/// Guard: every preset we ship under `assets/keymaps/` must parse +/// through the real loader. +/// +/// T-204 — `default.yaml` bound `tab`/`shift+tab` to `focus.next` / +/// `focus.previous`, intent ids that didn't exist in [builtinIntents]. +/// `KeymapLayer.fromYaml` throws on an unknown id, and `KeymapService.load` +/// catches that and silently sets `_preset = null` — so the ENTIRE default +/// keymap was dead at runtime (palette, quick-open, find-in-files, scale). +/// It slipped through because every `keymap_service_test` injects a +/// synthetic bundle; the shipped asset was never parsed in a test. +/// +/// This test reads the real files from disk and parses them, so a typo in +/// any shipped preset fails CI instead of silently disabling the keymap. +library; + +import 'dart:io'; + +import 'package:clide/kernel/src/keymap/intents.dart'; +import 'package:clide/kernel/src/keymap/keymap.dart'; +import 'package:flutter/widgets.dart' show NextFocusIntent, PreviousFocusIntent; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final dir = Directory('assets/keymaps'); + final presets = dir.listSync().whereType().where((f) => f.path.endsWith('.yaml')).toList()..sort((a, b) => a.path.compareTo(b.path)); + + test('assets/keymaps ships at least the default preset', () { + expect(presets.map((f) => f.uri.pathSegments.last), contains('default.yaml')); + }); + + for (final file in presets) { + final name = file.uri.pathSegments.last; + test('$name parses through the real loader', () { + final layer = KeymapLayer.fromYaml(file.readAsStringSync()); + // A preset that produced zero bindings would mean every entry was + // dropped — almost certainly a schema mistake, not an intentional + // empty file. + expect(layer.bindings, isNotEmpty, reason: '$name produced no bindings'); + }); + } + + test('focus.next / focus.previous are real intent ids', () { + expect(parseIntentId('focus.next'), isA()); + expect(parseIntentId('focus.previous'), isA()); + }); + + test('default.yaml binds Tab / Shift+Tab to focus traversal', () { + final layer = KeymapLayer.fromYaml( + File('assets/keymaps/default.yaml').readAsStringSync(), + ); + expect(layer.bindings.any((b) => b.intent is NextFocusIntent), isTrue); + expect(layer.bindings.any((b) => b.intent is PreviousFocusIntent), isTrue); + }); +}