ship the Vim keymap preset
T-65 — the capstone of the Vim epic. assets/keymaps/vim.yaml binds the muscle-memory set guarded on vim.normal/insert/visual: hjkl/w/b/e/0/$/^/ gg/G motions, x/dd/dw/D/yy/p/P/cc/cw edits, i/a/I/A/o/O insert entries, v + d/y/c in visual, Esc back to normal, and counts via the matcher. App shortcuts (palette, find, zoom) are carried in the preset so they survive under Vim. The editor now feeds Shift chords to the matcher (Vim's capitals: G, D, A, P, $) while still bubbling Ctrl/Alt/Meta to the global handler. The keybindings-ui stub gains palette commands (`Keymap: Vim` / `Keymap: Default`) to switch presets — the user-facing way to turn Vim on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,3 +45,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
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-205', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 19:07:16', '2026-06-01 19:07:16', '2026-06-01 19:07:16', NULL, 'e1f8c83cfe8956b83080ac25e515f86d', 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-205', 'status', 'in_progress', 'done', NULL, '2026-06-01 19:12:01', '2026-06-01 19:12:01', '2026-06-01 19:12:01', NULL, '061ffbffafbfdfe7ecec8f278fbc6bf0', 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-206', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 19:16:58', '2026-06-01 19:16:58', '2026-06-01 19:16:58', NULL, '4b1e494c63361eb99d59e0491ff841c3', 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-206', 'status', 'in_progress', 'done', NULL, '2026-06-01 19:27:18', '2026-06-01 19:27:18', '2026-06-01 19:27:18', NULL, '9ec3404aa10ff5c655124c4e5b857a31', 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', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 19:29:15', '2026-06-01 19:29:15', '2026-06-01 19:29:15', NULL, '021a754b25453a1548ee9278f2719072', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -35,3 +35,18 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
||||
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.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-01 18:48:27', '2026-06-01 19:07:16', NULL, '63396c8b4d6ae568bde2b7465001c3ad', 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.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:27', '2026-06-01 19:12:01', NULL, '7cc1e3c9a5249e5d352b0107b1936327', 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).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-01 18:48:40', '2026-06-01 19:16:58', NULL, '4627c4a8eb9257df10d0b3e82c7c1cc6', 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).', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:40', '2026-06-01 19:27:18', NULL, '2810354fde44fa08199b38acb9c017a4', 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:<id>` bindings.
|
||||
2. Add a small mode-tracking service that publishes `vim.<mode>` 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.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-01 19:29:15', NULL, 'efc76aa91cfc889968d5bda60f897afa', 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);
|
||||
|
||||
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Vim keymap preset: a modal editor (normal/insert/visual) with hjkl/w/b/e
|
||||
motions, dd/dw/x/D/yy/p/cc/cw edits, counts (`5j`), visual-range d/y/c, and
|
||||
a status-bar mode indicator. Switch presets from the palette (`Keymap: Vim`
|
||||
/ `Keymap: Default`). Built on a new keymap key-sequence layer (D-82). (T-65)
|
||||
- Search-and-replace across the workspace: enter a replacement in the search
|
||||
panel to preview each rewritten line, then Replace all (regex capture groups
|
||||
supported). Guarded by a clean-git-tree gate — git is the undo — and a
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# clide Vim keymap preset (T-65).
|
||||
#
|
||||
# Modal editing built on the keymap sequence layer (D-82). The Vim mode
|
||||
# service (builtin.vim) publishes vim.normal / vim.insert / vim.visual
|
||||
# scope flags; bindings here are guarded on them. Motions/edits resolve to
|
||||
# `command:editor.vim.<action>`, which the editor applies to the buffer
|
||||
# (read-only in command mode so keys never type); mode changes resolve to
|
||||
# `command:vim.mode.<mode>`.
|
||||
#
|
||||
# Notation (D-82): `+` joins a chord, a space sequences (`d d`), a YAML
|
||||
# list alternates (`[d, x]`). Repeat counts (`5j`) are captured by the
|
||||
# matcher — no binding needed.
|
||||
#
|
||||
# Scope: the muscle-memory set, not bit-exact Vim. cw/$/^ are
|
||||
# approximations; see vim_edit_ops.dart.
|
||||
|
||||
name: vim
|
||||
|
||||
bindings:
|
||||
# ---- App shortcuts (shared with the default preset) -----------------
|
||||
# Modified chords bubble past the editor to the global handler, so these
|
||||
# keep working with the editor focused and under every mode.
|
||||
- intent: palette.open
|
||||
keys: [ctrl+shift+p, meta+shift+p]
|
||||
- intent: palette.selectNext
|
||||
keys: [down, ctrl+n]
|
||||
when: palette.open
|
||||
- intent: palette.selectPrevious
|
||||
keys: [up, ctrl+p]
|
||||
when: palette.open
|
||||
- intent: palette.accept
|
||||
keys: enter
|
||||
when: palette.open
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: palette.open
|
||||
- intent: quickOpen.open
|
||||
keys: [ctrl+p, meta+p]
|
||||
when: "!palette.open"
|
||||
- intent: quickOpen.selectNext
|
||||
keys: [down, ctrl+n]
|
||||
when: quickOpen.open
|
||||
- intent: quickOpen.selectPrevious
|
||||
keys: up
|
||||
when: quickOpen.open
|
||||
- intent: quickOpen.accept
|
||||
keys: enter
|
||||
when: quickOpen.open
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: quickOpen.open
|
||||
- intent: findInFiles.open
|
||||
keys: [ctrl+shift+f, meta+shift+f]
|
||||
- intent: focus.nextPanel
|
||||
keys: f6
|
||||
- intent: focus.previousPanel
|
||||
keys: shift+f6
|
||||
- intent: text.scaleIncrease
|
||||
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
|
||||
- intent: text.scaleDecrease
|
||||
keys: [ctrl+minus, meta+minus]
|
||||
- intent: text.scaleReset
|
||||
keys: [ctrl+0, meta+0]
|
||||
|
||||
# ---- Mode transitions ------------------------------------------------
|
||||
- intent: command:vim.mode.visual
|
||||
keys: v
|
||||
when: vim.normal
|
||||
- intent: command:vim.mode.normal
|
||||
keys: escape
|
||||
when: "vim.insert || vim.visual"
|
||||
|
||||
# ---- Insert entry (normal → insert), positioned by the editor --------
|
||||
- intent: command:editor.vim.insert
|
||||
keys: i
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.append
|
||||
keys: a
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.insertLineStart
|
||||
keys: shift+i # I
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.appendLineEnd
|
||||
keys: shift+a # A
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.openBelow
|
||||
keys: o
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.openAbove
|
||||
keys: shift+o # O
|
||||
when: vim.normal
|
||||
|
||||
# ---- Motions (normal + visual) ---------------------------------------
|
||||
- intent: command:editor.vim.left
|
||||
keys: h
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.down
|
||||
keys: j
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.up
|
||||
keys: k
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.right
|
||||
keys: l
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.wordForward
|
||||
keys: w
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.wordBackward
|
||||
keys: b
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.wordEnd
|
||||
keys: e
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.lineStart
|
||||
keys: "0"
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.lineEnd
|
||||
keys: shift+4 # $
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.firstNonBlank
|
||||
keys: shift+6 # ^
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.docStart
|
||||
keys: "g g" # gg
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.vim.docEnd
|
||||
keys: shift+g # G
|
||||
when: "vim.normal || vim.visual"
|
||||
|
||||
# ---- Normal-mode edits ----------------------------------------------
|
||||
- intent: command:editor.vim.deleteChar
|
||||
keys: x
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.deleteLine
|
||||
keys: "d d"
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.deleteWord
|
||||
keys: "d w"
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.deleteToEnd
|
||||
keys: shift+d # D
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.changeLine
|
||||
keys: "c c"
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.changeWord
|
||||
keys: "c w"
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.yankLine
|
||||
keys: "y y"
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.paste
|
||||
keys: p
|
||||
when: vim.normal
|
||||
- intent: command:editor.vim.pasteBefore
|
||||
keys: shift+p # P
|
||||
when: vim.normal
|
||||
|
||||
# ---- Visual-mode operators ------------------------------------------
|
||||
- intent: command:editor.vim.visualDelete
|
||||
keys: [d, x]
|
||||
when: vim.visual
|
||||
- intent: command:editor.vim.visualYank
|
||||
keys: y
|
||||
when: vim.visual
|
||||
- intent: command:editor.vim.visualChange
|
||||
keys: c
|
||||
when: vim.visual
|
||||
@@ -201,10 +201,13 @@ class _EditorViewState extends State<EditorView> {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// Normal / visual mode. Modified chords are app shortcuts (palette,
|
||||
// find, …) — let them bubble to the global handler. Bare keys drive
|
||||
// the Vim matcher and never reach text input.
|
||||
if (chord.modifiers.isNotEmpty) return KeyEventResult.ignored;
|
||||
// Normal / visual mode. Ctrl/Alt/Meta chords are app shortcuts
|
||||
// (palette, find, …) — let them bubble to the global handler. Bare
|
||||
// keys and Shift chords (Vim's capitals: G, D, A, P, $) drive the
|
||||
// matcher and never reach text input.
|
||||
if (chord.modifiers.any((m) => m != KeyModifier.shift)) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final r = _matcher!.feed(chord);
|
||||
switch (r.outcome) {
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
/// Keymap preset switching. The full keybindings editor lands in a later
|
||||
/// tier; today this contributes palette commands to switch the active
|
||||
/// keymap preset (the user-facing "select it in settings" affordance for
|
||||
/// the Vim / VS Code / JetBrains presets — T-64/65/66).
|
||||
class KeybindingsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.keybindings-ui';
|
||||
@override
|
||||
String get title => 'Keybindings UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
KeymapService? _keymap;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_keymap = ctx.keymap;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async => _keymap = null;
|
||||
|
||||
/// Presets that ship today. VS Code / JetBrains (T-64/T-66) join here
|
||||
/// once their YAMLs land.
|
||||
static const _presets = <String, String>{
|
||||
'default': 'Keymap: Default',
|
||||
'vim': 'Keymap: Vim',
|
||||
};
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
for (final entry in _presets.entries)
|
||||
CommandContribution(
|
||||
id: 'keymap.preset.${entry.key}',
|
||||
command: 'keymap.preset.${entry.key}',
|
||||
title: entry.value,
|
||||
run: (_) async {
|
||||
await _keymap?.setPreset(entry.key);
|
||||
return IpcResponse.ok(id: '', data: {'preset': entry.key});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/// T-65: the shipped Vim preset. Loads the real assets/keymaps/vim.yaml
|
||||
/// and asserts representative bindings resolve in the right mode — motions,
|
||||
/// the i→insert / Esc→normal / v→visual mode changes, a dd sequence, a
|
||||
/// shifted capital (G), and that app shortcuts (palette) survive.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/key_chord.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap.dart';
|
||||
import 'package:clide/kernel/src/keymap/sequence_matcher.dart';
|
||||
import 'package:flutter/widgets.dart' show Intent;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
String? _cmd(Intent? i) => i is InvokeCommandIntent ? i.commandId : null;
|
||||
|
||||
void main() {
|
||||
late Keymap km;
|
||||
setUp(() {
|
||||
final src = File('assets/keymaps/vim.yaml').readAsStringSync();
|
||||
km = Keymap([KeymapLayer.fromYaml(src)]);
|
||||
});
|
||||
|
||||
const normal = {'vim.normal': true};
|
||||
const insert = {'vim.insert': true};
|
||||
const visual = {'vim.visual': true};
|
||||
|
||||
Intent? resolve(String chord, Map<String, bool> scope) => km.resolve(KeyChord.parse(chord), scope);
|
||||
|
||||
test('motions resolve in normal mode', () {
|
||||
expect(_cmd(resolve('j', normal)), 'editor.vim.down');
|
||||
expect(_cmd(resolve('h', normal)), 'editor.vim.left');
|
||||
expect(_cmd(resolve('w', normal)), 'editor.vim.wordForward');
|
||||
expect(_cmd(resolve('0', normal)), 'editor.vim.lineStart');
|
||||
});
|
||||
|
||||
test('capitals (shifted) resolve', () {
|
||||
expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd');
|
||||
expect(_cmd(resolve('shift+d', normal)), 'editor.vim.deleteToEnd');
|
||||
expect(_cmd(resolve('shift+a', normal)), 'editor.vim.appendLineEnd');
|
||||
});
|
||||
|
||||
test('motions also resolve in visual mode', () {
|
||||
expect(_cmd(resolve('l', visual)), 'editor.vim.right');
|
||||
expect(_cmd(resolve('w', visual)), 'editor.vim.wordForward');
|
||||
});
|
||||
|
||||
test('mode changes', () {
|
||||
expect(_cmd(resolve('i', normal)), 'editor.vim.insert');
|
||||
expect(_cmd(resolve('v', normal)), 'vim.mode.visual');
|
||||
expect(_cmd(resolve('escape', insert)), 'vim.mode.normal');
|
||||
expect(_cmd(resolve('escape', visual)), 'vim.mode.normal');
|
||||
});
|
||||
|
||||
test('normal-mode i does not fire in visual mode', () {
|
||||
expect(resolve('i', visual), isNull);
|
||||
});
|
||||
|
||||
test('visual operators resolve only in visual mode', () {
|
||||
expect(_cmd(resolve('y', visual)), 'editor.vim.visualYank');
|
||||
expect(_cmd(resolve('d', visual)), 'editor.vim.visualDelete'); // single d in visual
|
||||
});
|
||||
|
||||
test('app shortcuts survive under the vim preset', () {
|
||||
expect(resolve('ctrl+shift+p', normal), isA<PaletteOpenIntent>());
|
||||
expect(resolve('ctrl+shift+f', insert), isA<FindInFilesIntent>());
|
||||
});
|
||||
|
||||
test('dd / dw sequences resolve through the matcher in normal mode', () {
|
||||
final m = SequenceMatcher(keymap: () => km, context: () => normal);
|
||||
expect(m.feed(KeyChord.parse('d')).outcome, SeqOutcome.pending);
|
||||
final r = m.feed(KeyChord.parse('d'));
|
||||
expect(_cmd(r.intent), 'editor.vim.deleteLine');
|
||||
|
||||
m.reset();
|
||||
m.feed(KeyChord.parse('d'));
|
||||
expect(_cmd(m.feed(KeyChord.parse('w')).intent), 'editor.vim.deleteWord');
|
||||
});
|
||||
|
||||
test('gg sequence resolves to docStart', () {
|
||||
final m = SequenceMatcher(keymap: () => km, context: () => normal);
|
||||
expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending);
|
||||
expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// T-65: the keybindings UI contributes palette commands that switch the
|
||||
/// active keymap preset.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/keybindings_ui/keybindings_ui.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.services.extensions.register(KeybindingsUiExtension());
|
||||
await f.services.extensions.activate('builtin.keybindings-ui');
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
test('contributes a switch command per shipped preset', () {
|
||||
expect(f.services.commands.get('keymap.preset.vim'), isNotNull);
|
||||
expect(f.services.commands.get('keymap.preset.default'), isNotNull);
|
||||
});
|
||||
|
||||
test('running the vim command switches the active preset', () async {
|
||||
await f.services.commands.execute('keymap.preset.vim');
|
||||
expect(f.services.settings.get<String>(kKeymapPresetSetting), 'vim');
|
||||
});
|
||||
|
||||
test('switching back to default works', () async {
|
||||
await f.services.commands.execute('keymap.preset.vim');
|
||||
await f.services.commands.execute('keymap.preset.default');
|
||||
expect(f.services.settings.get<String>(kKeymapPresetSetting), 'default');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user