opencode-vim 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +25 -63
  2. package/assets/vim-in-motion-dialogs.gif +0 -0
  3. package/dist/tui.js +3326 -0
  4. package/docs/configuration.md +50 -400
  5. package/docs/keymap-actions.md +60 -188
  6. package/docs/vim-behavior.md +93 -0
  7. package/package.json +6 -6
  8. package/src/modules/snippets/index.tsx +0 -17
  9. package/src/modules/snippets/loader.ts +0 -147
  10. package/src/modules/snippets/search.ts +0 -118
  11. package/src/modules/snippets/skill-loader.ts +0 -81
  12. package/src/modules/snippets/state.ts +0 -40
  13. package/src/modules/snippets/trigger.ts +0 -68
  14. package/src/modules/snippets/types.ts +0 -50
  15. package/src/modules/snippets/view.tsx +0 -674
  16. package/src/modules/vim/actions.ts +0 -85
  17. package/src/modules/vim/config.ts +0 -125
  18. package/src/modules/vim/edit.test.ts +0 -51
  19. package/src/modules/vim/edit.ts +0 -33
  20. package/src/modules/vim/graphemes.ts +0 -38
  21. package/src/modules/vim/index.tsx +0 -200
  22. package/src/modules/vim/keys.ts +0 -38
  23. package/src/modules/vim/log.ts +0 -37
  24. package/src/modules/vim/map.test.ts +0 -45
  25. package/src/modules/vim/map.ts +0 -198
  26. package/src/modules/vim/state.test.ts +0 -18
  27. package/src/modules/vim/state.tsx +0 -46
  28. package/src/modules/vim/view.tsx +0 -130
  29. package/src/modules/vim/vimee.test.ts +0 -211
  30. package/src/modules/vim/vimee.ts +0 -1146
  31. package/src/plugin.tsx +0 -116
  32. package/src/prompt/host.tsx +0 -38
  33. package/src/prompt/modules.ts +0 -16
  34. package/src/prompt/root.tsx +0 -69
  35. package/src/prompt/types.ts +0 -42
  36. package/src/update.ts +0 -120
  37. package/tui.tsx +0 -275
  38. package/view.tsx +0 -29
@@ -1,1146 +0,0 @@
1
- import type { KeyEvent } from "@opentui/core"
2
- import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext, resolveMotion } from "@vimee/core"
3
- import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
4
- import { focusedInput, setInput, type EditBufferLike, type PromptContext } from "./actions"
5
- import type { VimConfig } from "./config"
6
- import type { VimLog } from "./log"
7
- import { createGraphemeCodec } from "./graphemes"
8
- import { charToDisplay, displayToChar, displayWidth, createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
9
- import type { createVimState } from "./state"
10
-
11
- type VimState = ReturnType<typeof createVimState>
12
- type HostAction = VimeeAction | { type: "submit" } | { type: "command"; command: string }
13
- type HostKeybindAction = "normal" | "submit" | "command"
14
- type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction; command?: string }
15
- type HostRange = { start: number; end: number }
16
-
17
- const YANK_FLASH_MS = 250
18
-
19
- export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
20
-
21
- export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog) {
22
- const codec = createGraphemeCodec()
23
- let buffer = new TextBuffer("")
24
- let activeMap = createPromptMap("", undefined, codec)
25
- const maps = new Map([[activeMap.vimText, activeMap]])
26
- let vim = createInitialContext({ line: 0, col: 0 })
27
- const keybinds = createKeybinds(config, log)
28
- let timer: ReturnType<typeof setTimeout> | undefined
29
- let yankTimer: ReturnType<typeof setTimeout> | undefined
30
- let yankFlashActive = false
31
- let pendingInsert = ""
32
- let pendingTarget: { input: EditBufferLike; offset: number } | undefined
33
- let pendingContext: PromptContext | undefined
34
- let nativeInsertUndoSaved = false
35
- let historyText: string | undefined
36
- let activeInput: EditBufferLike | undefined
37
- let mapWidth: number | undefined
38
- let mapWrapMode: string | undefined
39
- const defaultHistoryKeys = {
40
- k: !hasNormalKeyPrefix(config, "k"),
41
- j: !hasNormalKeyPrefix(config, "j"),
42
- }
43
- const appendMapped = hasNormalKeyPrefix(config, "A")
44
-
45
- return {
46
- attach,
47
- suspend,
48
- isPending: () => vim.phase !== "idle" || vim.count > 0 || !!keybinds?.isPending(),
49
- handle(event: KeyEvent, key: string, ctx: PromptContext) {
50
- const ref = ctx.prompt()
51
- if (!ref) return false
52
- const input = focusedInput(ctx)
53
- attach(ctx)
54
-
55
- let vimeeKey = keyForVimee(event, key)
56
- if (!vimeeKey) return false
57
- vimeeKey = codec.encode(vimeeKey)
58
-
59
- if (state.mode() === "insert") {
60
- if (vimeeKey === "Escape") {
61
- cancelPendingInsert(ctx)
62
- enterNormal(ctx)
63
- state.setPending("")
64
- updateTimeout(ctx)
65
- log("vimee.key", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
66
- return true
67
- }
68
- return handleInsertKeybind(event, vimeeKey, ctx)
69
- }
70
-
71
- const text = input?.plainText ?? ref.current.input
72
- const dw = displayWidth(text)
73
- const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
74
- const charOff = displayToChar(text, displayOff)
75
-
76
- const canBrowseHistory = vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
77
- const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined
78
- if (historyCommand) {
79
- const result = dispatchCommand(historyCommand, ctx)
80
- historyText = result.ok ? (focusedInput(ctx)?.plainText ?? ref.current.input) : undefined
81
- state.setPending("")
82
- updateTimeout(ctx)
83
- log("vimee.history", { key, command: historyCommand, handled: result.ok })
84
- return true
85
- }
86
- historyText = undefined
87
-
88
- const map = mapForHostText(text, input)
89
- const cursor = hostPosition(map, displayOff)
90
-
91
- const wasPending = keybinds?.isPending() ?? false
92
- const pendingBefore = pendingInsert
93
- sync(map, cursor)
94
-
95
- if (vimeeKey === "A" && vim.mode === "normal" && vim.phase === "idle" && !wasPending && !appendMapped && appendVisualLine(input, map)) {
96
- state.setPending("")
97
- updateTimeout(ctx)
98
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
99
- return true
100
- }
101
-
102
- const hostEnd = vimeeKey === "e" && vim.phase === "idle" ? charToDisplay(map.hostText, endMotionOffset(map.hostText, charOff, vim.count || 1)) : undefined
103
- const shouldFlashYank = shouldFlashYankFor(vimeeKey)
104
- const visualYankRange = visualYankRangeFor(map)
105
- vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
106
- const textObjectHandled = handleTextObject(vimeeKey, ctx, map)
107
- if (textObjectHandled !== undefined) return textObjectHandled
108
- const resolved = keybinds?.resolve(vimeeKey, vim.mode, event.ctrl)
109
- if (resolved?.status === "pending") {
110
- vim = { ...vim, statusMessage: resolved.display }
111
- state.setPending(resolved.display)
112
- updateTimeout(ctx)
113
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: [] })
114
- return true
115
- }
116
- if (vimeeKey === "Enter" && state.mode() === "normal" && vim.phase === "idle" && !wasPending && resolved?.status !== "matched") {
117
- ref.submit()
118
- state.setPending("")
119
- updateTimeout(ctx)
120
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["submit"] })
121
- return true
122
- }
123
-
124
- let result
125
- if (resolved?.status === "matched") {
126
- const actions = applyKeybind(resolved.definition, map)
127
- result = { newCtx: vim, actions }
128
- } else {
129
- result = processKey(vimeeKey, event.ctrl)
130
- }
131
- vim = result.newCtx
132
- const actions = result.actions as HostAction[]
133
- if (hostEnd !== undefined && actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
134
- let clampFinalCursor = true
135
- const content = actions.find((action) => action.type === "content-change")?.content
136
- if (vimeeKey === "x" && state.mode() === "normal" && content !== undefined) {
137
- const next = nextMap(map, content)
138
- const nextDW = displayWidth(next.hostText)
139
- const target = clamp(displayOff, 0, Math.max(0, nextDW - 1))
140
- if (charOff < map.hostText.length - 1 && map.hostText[charOff + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
141
- vim = { ...vim, cursor: hostPosition(next, target) }
142
- clampFinalCursor = false
143
- }
144
- }
145
- applyActions(actions, ctx, map, clampFinalCursor)
146
- if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange)
147
- syncMode(state, vim.mode)
148
-
149
- const keybindPending = keybinds?.isPending() ?? false
150
- if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
151
- pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
152
- state.setPending(pendingDisplay(vim, keybindPending))
153
- updateTimeout(ctx)
154
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
155
- return consumesKey(vimeeKey, actions, vim, keybindPending)
156
- },
157
- cleanup() {
158
- suspend()
159
- maps.clear()
160
- },
161
- }
162
-
163
- function attach(ctx: PromptContext) {
164
- const input = focusedInput(ctx)
165
- if (activeInput === input) return
166
- suspend()
167
- activeInput = input
168
- activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", input, codec)
169
- maps.clear()
170
- rememberMap(activeMap)
171
- buffer = new TextBuffer(activeMap.vimText)
172
- nativeInsertUndoSaved = false
173
- vim = { ...resetContext(vim), cursor: hostPosition(activeMap, input?.cursorOffset ?? 0), mode: state.mode() }
174
- mapWidth = input?.width
175
- mapWrapMode = input?.wrapMode
176
- }
177
-
178
- function suspend() {
179
- if (pendingContext) cancelPendingInsert(pendingContext)
180
- if (timer) clearTimeout(timer)
181
- timer = undefined
182
- keybinds?.cancel()
183
- cancelYankFlash()
184
- if (activeInput && !activeInput.isDestroyed) clearVisualSelection(activeInput)
185
- if (isVisualMode(state.mode())) state.setMode("normal")
186
- vim = resetContext(vim)
187
- state.setPending("")
188
- pendingTarget = undefined
189
- pendingContext = undefined
190
- }
191
-
192
- function sync(map: PromptMap, cursor: CursorPosition) {
193
- if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
194
- vim = { ...vim, cursor, mode: state.mode() }
195
- }
196
-
197
- function mapForHostText(text: string, input: ReturnType<typeof focusedInput>) {
198
- const textChanged = activeMap.hostText !== text
199
- if (!textChanged && mapWidth === input?.width && mapWrapMode === input?.wrapMode) return activeMap
200
- if (textChanged && state.mode() === "insert") recordNativeChange(text)
201
- activeMap = createPromptMap(text, input, codec)
202
- mapWidth = input?.width
203
- mapWrapMode = input?.wrapMode
204
- if (textChanged && state.mode() !== "insert") maps.clear()
205
- rememberMap(activeMap)
206
- if (textChanged && state.mode() === "insert") {
207
- if (!nativeInsertUndoSaved) {
208
- buffer.saveUndoPoint(vim.cursor)
209
- nativeInsertUndoSaved = true
210
- }
211
- buffer.replaceContent(activeMap.vimText)
212
- } else if (textChanged) {
213
- buffer = new TextBuffer(activeMap.vimText)
214
- nativeInsertUndoSaved = false
215
- } else {
216
- buffer.replaceContent(activeMap.vimText)
217
- }
218
- return activeMap
219
- }
220
-
221
- function recordNativeChange(text: string) {
222
- const before = codec.encode(activeMap.hostText)
223
- const after = codec.encode(text)
224
- const start = codec.encode(activeMap.hostText.slice(0, displayToChar(activeMap.hostText, hostOffset(activeMap, vim.cursor)))).length
225
- let prefix = 0
226
- while (prefix < start && prefix < after.length && before[prefix] === after[prefix]) prefix++
227
- let suffix = 0
228
- while (suffix < before.length - start && suffix < after.length - prefix && before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++
229
- const keys = [...vim.pendingChange]
230
- if (!keys.length) keys.push("i")
231
- for (let index = prefix; index < start; index++) keys.push("Backspace")
232
- for (let index = start; index < before.length - suffix; index++) keys.push("Delete")
233
- for (const key of after.slice(prefix, after.length - suffix)) keys.push(key === "\n" ? "Enter" : key === "\t" ? "Tab" : key)
234
- vim = { ...vim, pendingChange: keys }
235
- }
236
-
237
- function rememberMap(map: PromptMap) {
238
- maps.set(map.vimText, map)
239
- }
240
-
241
- function nextMap(map: PromptMap, vimText: string) {
242
- const known = maps.get(vimText)
243
- if (known) return known
244
- const next = derivePromptMap(map, vimText)
245
- rememberMap(next)
246
- return next
247
- }
248
-
249
- function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap, clampFinalCursor = true) {
250
- const ref = ctx.prompt()
251
- const input = focusedInput(ctx)
252
- let currentMap = map
253
- if (!ref) return
254
-
255
- for (const action of actions) {
256
- switch (action.type) {
257
- case "content-change":
258
- rememberMap(currentMap)
259
- currentMap = nextMap(currentMap, action.content)
260
- activeMap = currentMap
261
- setInput(ref, currentMap.hostText)
262
- buffer.replaceContent(action.content)
263
- break
264
- case "cursor-move":
265
- setCursor(input, currentMap, action.position)
266
- break
267
- case "mode-change":
268
- nativeInsertUndoSaved = action.mode === "insert" && actions.some((item) => item.type === "content-change")
269
- if (action.mode === "insert") {
270
- cancelYankFlash()
271
- if (input) clearVisualSelection(input)
272
- }
273
- syncMode(state, action.mode)
274
- break
275
- case "quit":
276
- ref.blur()
277
- break
278
- case "submit":
279
- ref.submit()
280
- break
281
- case "command":
282
- dispatchCommand(action.command, ctx)
283
- return
284
- }
285
- }
286
-
287
- setCursor(input, currentMap, vim.cursor, clampFinalCursor)
288
- syncVisualSelection(input, currentMap, ctx)
289
- }
290
-
291
- function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext): boolean {
292
- if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return false
293
-
294
- const wasPending = keybinds.isPending()
295
- const pendingBefore = pendingInsert
296
- const resolved = keybinds.resolve(key, "insert", event.ctrl)
297
-
298
- switch (resolved.status) {
299
- case "pending":
300
- if (!wasPending) {
301
- const input = focusedInput(ctx)
302
- pendingTarget = input ? { input, offset: input.cursorOffset ?? 0 } : undefined
303
- pendingContext = ctx
304
- }
305
- pendingInsert = codec.decode(plainPending(resolved.display))
306
- state.setPending(resolved.display)
307
- updateTimeout(ctx)
308
- return true
309
- case "matched": {
310
- if (applyInsertKeybind(resolved.definition, ctx)) {
311
- pendingInsert = ""
312
- state.setPending("")
313
- updateTimeout(ctx)
314
- log("vimee.keybind", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
315
- return true
316
- }
317
-
318
- if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore)
319
- pendingInsert = ""
320
- state.setPending("")
321
- updateTimeout(ctx)
322
- log("vimee.keybind.unsupported", { key })
323
- return false
324
- }
325
- case "none":
326
- if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore)
327
- pendingInsert = ""
328
- state.setPending("")
329
- updateTimeout(ctx)
330
- return wasPending ? handleInsertKeybind(event, key, ctx) : false
331
- }
332
- }
333
-
334
- function applyInsertKeybind(definition: KeybindDefinition, ctx: PromptContext) {
335
- switch (insertHostAction(definition)) {
336
- case "normal":
337
- enterNormal(ctx)
338
- return true
339
- case "submit":
340
- ctx.prompt()?.submit()
341
- return true
342
- case "command":
343
- dispatchCommand((definition as HostKeybindDefinition).command!, ctx)
344
- return true
345
- default:
346
- return false
347
- }
348
- }
349
-
350
- function enterNormal(ctx: PromptContext) {
351
- const ref = ctx.prompt()
352
- const input = focusedInput(ctx)
353
- const text = input?.plainText ?? ref?.current.input ?? ""
354
- const map = mapForHostText(text, input)
355
-
356
- if (input && text.length > 0) {
357
- const dw = displayWidth(text)
358
- const offset = clamp(input?.cursorOffset ?? dw, 0, dw)
359
- const charOffset = displayToChar(text, offset)
360
- if (charOffset > 0 && text[charOffset - 1] !== "\n") {
361
- input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1)))
362
- }
363
- clampNormalCursor(input)
364
- }
365
-
366
- const lastChange = nativeInsertUndoSaved ? [...vim.pendingChange, "Escape"] : vim.lastChange
367
- vim = { ...resetContext(vim), cursor: hostPosition(map, input?.cursorOffset ?? 0), mode: "normal", statusMessage: "", lastChange, pendingChange: [] }
368
- nativeInsertUndoSaved = false
369
- syncMode(state, "normal")
370
- }
371
-
372
- function insertHostAction(definition: KeybindDefinition): HostKeybindAction | undefined {
373
- const action = (definition as HostKeybindDefinition).hostAction
374
- if (action) return action
375
- if ("execute" in definition) return undefined
376
- if (definition.keys === "<Esc>" || definition.keys === "<C-[>" || definition.keys === "Escape") return "normal"
377
- return undefined
378
- }
379
-
380
- function applyKeybind(definition: KeybindDefinition, map: PromptMap) {
381
- if ("execute" in definition) {
382
- const actions = definition.execute(vim, buffer) as HostAction[]
383
- vim = { ...vim, cursor: hostPosition(map, cursorOffset(map, vim.cursor)) }
384
- return actions
385
- }
386
-
387
- let actions: HostAction[] = []
388
- for (const token of parseKeySequence(definition.keys)) {
389
- const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token))
390
- vim = result.newCtx
391
- actions = [...actions, ...(result.actions as HostAction[])]
392
- }
393
- return actions
394
- }
395
-
396
- function processKey(key: string, ctrl = false): { newCtx: VimContext; actions: HostAction[] } {
397
- let wordRange: MotionRange | undefined
398
- if (!ctrl && vim.mode !== "insert" && (key === "w" || key === "W") && (vim.phase === "idle" || vim.phase === "operator-pending")) {
399
- wordRange = forwardWordRange(key, vim.cursor, buffer, vim.count || 1)
400
- if (vim.phase === "idle") {
401
- const cursor = { ...wordRange.end, col: Math.min(wordRange.end.col, Math.max(0, buffer.getLineLength(wordRange.end.line) - 1)) }
402
- return { newCtx: { ...resetContext(vim), cursor }, actions: [{ type: "cursor-move", position: cursor }] }
403
- }
404
- }
405
- if (key === "." && !ctrl && vim.mode === "normal" && vim.phase === "idle" && vim.lastChange.length) {
406
- // A repeated insert/change is one undo step, including native typing.
407
- const original = buffer
408
- const before = buffer.getContent()
409
- const cursor = vim.cursor
410
- const keys = [...vim.lastChange]
411
- if (vim.count) {
412
- while (/^[0-9]$/.test(keys[0] ?? "")) keys.shift()
413
- keys.unshift(...String(vim.count))
414
- }
415
- vim = { ...vim, count: 0 }
416
- const actions: HostAction[] = []
417
- buffer = new TextBuffer(before)
418
- try {
419
- for (const token of keys) {
420
- const result = processKey(token)
421
- vim = result.newCtx
422
- actions.push(...result.actions)
423
- }
424
- if (buffer.getContent() !== before) {
425
- original.saveUndoPoint(cursor)
426
- original.replaceContent(buffer.getContent())
427
- }
428
- } finally {
429
- buffer = original
430
- }
431
- return { newCtx: { ...vim, lastChange: keys, pendingChange: [] }, actions }
432
- }
433
-
434
- let range: MotionRange | undefined
435
- const operator = vim.operator
436
- if (!ctrl && operator && textObjectOperator(operator)) {
437
- if (vim.phase === "operator-pending") {
438
- const count = vim.count || 1
439
- if (key === "l" || key === "ArrowRight") {
440
- // Vimee 0.3 treats l as inclusive; Vim's operator motion is exclusive.
441
- range = { start: vim.cursor, end: { line: vim.cursor.line, col: Math.min(buffer.getLineLength(vim.cursor.line), vim.cursor.col + count) }, inclusive: false, linewise: false }
442
- } else if (key === operator) {
443
- range = { start: { line: vim.cursor.line, col: 0 }, end: { line: Math.min(buffer.getLineCount() - 1, vim.cursor.line + count - 1), col: 0 }, inclusive: true, linewise: true }
444
- } else if (operator === "c" && key === "w" && /\S/.test(buffer.getLine(vim.cursor.line)[vim.cursor.col] ?? "")) {
445
- range = resolveMotion("e", { ...vim.cursor, col: vim.cursor.col - 1 }, buffer, count, vim.count > 0, vim)?.range
446
- if (range) range.start = vim.cursor
447
- } else if (wordRange) {
448
- range = wordRange
449
- if (operator === "d" && range.end.line > range.start.line && /\S/.test(buffer.getLine(range.start.line)[range.start.col] ?? "") && range.end.col === Math.max(0, buffer.getLine(range.end.line).search(/\S/))) {
450
- range.end = { line: range.end.line - 1, col: buffer.getLineLength(range.end.line - 1) }
451
- }
452
- } else if (operator === "c") {
453
- const motion = resolveMotion(key, vim.cursor, buffer, count, vim.count > 0, vim)
454
- if (motion?.range.linewise) range = motion.range
455
- }
456
- } else if (vim.phase === "text-object-pending" && vim.textObjectModifier) {
457
- range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer) ?? undefined
458
- }
459
- }
460
- if (range && operator) {
461
- const keys: string[] = []
462
- if (vim.count) keys.push(...String(vim.count))
463
- for (const token of vim.pendingChange) {
464
- if (!/^[0-9]$/.test(token)) keys.push(token)
465
- }
466
- keys.push(key)
467
- const result = executeTextObject(operator, range, buffer, vim)
468
- if (operator === "y") result.context.pendingChange = []
469
- else if (result.context.mode === "insert") result.context.pendingChange = keys
470
- else { result.context.lastChange = keys; result.context.pendingChange = [] }
471
- return { newCtx: result.context, actions: result.actions }
472
- }
473
-
474
- // The engine saves undo points for yanks too. Run read-only operations
475
- // against a disposable buffer so they cannot alter the editing history.
476
- const yanking = operator === "y" || (isVisualMode(vim.mode) && key === "y") || (vim.mode === "normal" && key === "Y")
477
- return processKeystroke(key, vim, yanking ? new TextBuffer(buffer.getContent()) : buffer, ctrl, false)
478
- }
479
-
480
- function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
481
- if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined
482
- const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer)
483
- if (!range) return undefined
484
-
485
- const flashRange = vim.operator === "y" ? motionHostRange(map, range) : undefined
486
-
487
- if (!vim.operator) {
488
- vim = { ...resetContext(vim), visualAnchor: range.start, cursor: range.end }
489
- applyActions([{ type: "cursor-move", position: range.end }], ctx, map)
490
- state.setPending("")
491
- updateTimeout(ctx)
492
- log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["cursor-move"] })
493
- return true
494
- }
495
- if (!textObjectOperator(vim.operator)) return undefined
496
-
497
- const result = processKey(key)
498
- vim = result.newCtx
499
- applyActions(result.actions, ctx, map)
500
- if (flashRange) flashYank(ctx, activeMap, result.actions.find((action): action is YankAction => action.type === "yank"), flashRange)
501
- syncMode(state, vim.mode)
502
- state.setPending("")
503
- updateTimeout(ctx)
504
- log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
505
- return true
506
- }
507
-
508
- function updateTimeout(ctx: PromptContext) {
509
- if (timer) clearTimeout(timer)
510
- timer = undefined
511
- if (!keybinds?.isPending()) return
512
- timer = setTimeout(() => {
513
- keybinds.cancel()
514
- flushPendingInsert(ctx, pendingInsert)
515
- pendingInsert = ""
516
- state.setPending("")
517
- ctx.requestRender()
518
- }, config.keymapTimeout)
519
- }
520
-
521
- function cancelPendingInsert(ctx: PromptContext, offset?: number, flush = true) {
522
- if (!keybinds?.isPending()) return
523
- if (timer) clearTimeout(timer)
524
- timer = undefined
525
- keybinds.cancel()
526
- if (flush) flushPendingInsert(ctx, pendingInsert, offset)
527
- pendingInsert = ""
528
- pendingTarget = undefined
529
- pendingContext = undefined
530
- state.setPending("")
531
- }
532
-
533
- function flushPendingInsert(ctx: PromptContext, value: string, charOffset?: number) {
534
- if (!value || state.mode() !== "insert") return
535
- if (pendingTarget?.input.isDestroyed) return
536
- if (pendingTarget?.input.insertText && !pendingTarget.input.isDestroyed) {
537
- const { input, offset } = pendingTarget
538
- const cursor = input.cursorOffset ?? 0
539
- input.clearSelection?.()
540
- input.cursorOffset = offset
541
- input.insertText!(value)
542
- input.cursorOffset = cursor >= offset ? cursor + displayWidth(value) : cursor
543
- return
544
- }
545
- const ref = ctx.prompt()
546
- if (!ref) return
547
- const input = focusedInput(ctx)
548
- const text = input?.plainText ?? ref.current.input
549
- const currentDisplayOff = input?.cursorOffset ?? 0
550
- const currentCharOff = displayToChar(text, currentDisplayOff)
551
- const insertAtChar = charOffset ?? currentCharOff
552
- const next = text.slice(0, insertAtChar) + value + text.slice(insertAtChar)
553
- setInput(ref, next)
554
- const nextCharOff = currentCharOff >= insertAtChar ? currentCharOff + value.length : currentCharOff
555
- if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff))
556
- }
557
-
558
- function cursorOffset(map: PromptMap, position: CursorPosition) {
559
- return hostOffset(map, position, vim.mode === "insert" ? "next" : "previous")
560
- }
561
-
562
- function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition, clampCursor = true) {
563
- if (!input) return
564
- input.cursorOffset = cursorOffset(map, position)
565
- if (clampCursor && vim.mode !== "insert") clampNormalCursor(input)
566
- }
567
-
568
- function appendVisualLine(input: EditBufferLike | undefined, map: PromptMap) {
569
- if (!input?.gotoVisualLineEnd) return false
570
- clearVisualSelection(input)
571
- input.gotoVisualLineEnd()
572
- vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? displayWidth(map.hostText)), mode: "insert", phase: "idle", count: 0, operator: null, pendingChange: ["A"], statusMessage: "-- INSERT --" }
573
- nativeInsertUndoSaved = false
574
- syncMode(state, "insert")
575
- return true
576
- }
577
-
578
- function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) {
579
- if (!input) return
580
- if (!isVisualMode(vim.mode) || !vim.visualAnchor) {
581
- if (!yankFlashActive) clearVisualSelection(input)
582
- return
583
- }
584
-
585
- cancelYankFlash()
586
- const range = vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor)
587
- if (!range) {
588
- clearVisualSelection(input)
589
- return
590
- }
591
-
592
- setSelection(input, range.start, range.end, ctx)
593
- }
594
-
595
- function visualYankRangeFor(map: PromptMap) {
596
- if (!isVisualMode(vim.mode) || !vim.visualAnchor) return undefined
597
- return vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor)
598
- }
599
-
600
- function flashYank(ctx: PromptContext, map: PromptMap, action: YankAction | undefined, visualRange: HostRange | undefined) {
601
- if (!action) return
602
- const input = focusedInput(ctx)
603
- if (!input) return
604
- const range = visualRange ?? yankedTextRange(map, vim.cursor, action.text)
605
- if (!range) return
606
-
607
- cancelYankFlash()
608
- yankFlashActive = true
609
- setYankSelection(input, range.start, range.end, ctx)
610
- ctx.requestRender()
611
- yankTimer = setTimeout(() => {
612
- yankTimer = undefined
613
- if (!yankFlashActive) return
614
- yankFlashActive = false
615
- clearVisualSelection(input)
616
- ctx.requestRender()
617
- }, YANK_FLASH_MS)
618
- }
619
-
620
- function cancelYankFlash() {
621
- if (yankTimer) clearTimeout(yankTimer)
622
- yankTimer = undefined
623
- yankFlashActive = false
624
- }
625
-
626
- function shouldFlashYankFor(key: string) {
627
- if (isVisualMode(vim.mode)) return key === "y"
628
- return vim.operator === "y"
629
- }
630
- }
631
-
632
- function forwardWordRange(key: string, start: CursorPosition, buffer: TextBuffer, count: number): MotionRange {
633
- let { line, col } = start
634
- for (let index = 0; index < count; index++) {
635
- const text = buffer.getLine(line)
636
- const kind = wordClass(text[col], key === "W")
637
- while (col < text.length && wordClass(text[col], key === "W") === kind) col++
638
- while (col < text.length && /\s/.test(text[col])) col++
639
- while (col >= buffer.getLineLength(line) && line < buffer.getLineCount() - 1) {
640
- line++
641
- col = 0
642
- const next = buffer.getLine(line)
643
- if (!next.length) break
644
- while (col < next.length && /\s/.test(next[col])) col++
645
- if (col < next.length) break
646
- }
647
- }
648
- return { start, end: { line, col }, linewise: false, inclusive: false }
649
- }
650
-
651
- function wordClass(char: string | undefined, big: boolean) {
652
- if (!char || /\s/.test(char)) return "space"
653
- return big || /\w/.test(char) ? "word" : "punctuation"
654
- }
655
-
656
- function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
657
- const anchorOffset = hostOffset(map, anchor, "next")
658
- const cursorOffset = hostOffset(map, cursor, "previous")
659
- return hostRange(map, anchorOffset, cursorOffset)
660
- }
661
-
662
- function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
663
- const startLine = Math.min(anchor.line, cursor.line)
664
- const endLine = Math.max(anchor.line, cursor.line)
665
- const start = hostOffset(map, { line: startLine, col: 0 }, "next")
666
- const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) }, "previous")
667
- return hostRange(map, start, end)
668
- }
669
-
670
- function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string): HostRange | undefined {
671
- if (!text) return undefined
672
- if (text.endsWith("\n")) {
673
- const lineCount = text.split("\n").length - 1
674
- return visualLineRange(map, cursor, { line: cursor.line + Math.max(0, lineCount - 1), col: 0 })
675
- }
676
-
677
- const start = vimOffsetFromPosition(map.vimText, cursor)
678
- return vimOffsetRange(map, start, start + text.length - 1)
679
- }
680
-
681
- function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined {
682
- if (range.linewise) return visualLineRange(map, range.start, range.end)
683
- const start = vimOffsetFromPosition(map.vimText, range.start)
684
- const end = vimOffsetFromPosition(map.vimText, range.end)
685
- return vimOffsetRange(map, start, end)
686
- }
687
-
688
- function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
689
- const start = hostFromVimOffset(map, Math.min(left, right), "next")
690
- const end = hostFromVimOffset(map, Math.max(left, right), "previous")
691
- return hostRange(map, start, end)
692
- }
693
-
694
- function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined {
695
- if (!map.hostText) return undefined
696
- const dw = displayWidth(map.hostText)
697
- const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1))
698
- const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1))
699
- return { start, end }
700
- }
701
-
702
- type YankAction = Extract<VimeeAction, { type: "yank" }>
703
-
704
- function yankAction(actions: HostAction[]): YankAction | undefined {
705
- return actions.find((action): action is YankAction => action.type === "yank")
706
- }
707
-
708
- function setSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) {
709
- setSelectionColors(input, start, end, ctx.api.theme.current.warning, ctx.api.theme.current.background)
710
- }
711
-
712
- function setYankSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) {
713
- setSelectionColors(input, start, end, ctx.api.theme.current.info, ctx.api.theme.current.background)
714
- }
715
-
716
- function setSelectionColors(input: EditBufferLike, start: number, end: number, background: PromptContext["api"]["theme"]["current"]["warning"], foreground: PromptContext["api"]["theme"]["current"]["background"]) {
717
- input.selectionBg = background
718
- input.selectionFg = foreground
719
-
720
- if (input.setSelectionInclusive) {
721
- input.setSelectionInclusive(start, end)
722
- return
723
- }
724
-
725
- const exclusiveEnd = end + 1
726
- if (input.setSelection) {
727
- input.setSelection(start, exclusiveEnd)
728
- return
729
- }
730
-
731
- input.editorView?.setSelection?.(start, exclusiveEnd, background, foreground)
732
- }
733
-
734
- function clearVisualSelection(input: EditBufferLike) {
735
- if (input.clearSelection) {
736
- input.clearSelection()
737
- return
738
- }
739
-
740
- input.editorView?.resetSelection?.()
741
- }
742
-
743
- function isVisualMode(mode: VimContext["mode"]): mode is "visual" | "visual-line" {
744
- return mode === "visual" || mode === "visual-line"
745
- }
746
-
747
- function vimLineLength(text: string, line: number) {
748
- return text.split("\n")[line]?.length ?? 0
749
- }
750
-
751
- function vimOffsetFromPosition(text: string, position: CursorPosition) {
752
- const lines = text.split("\n")
753
- const line = clamp(position.line, 0, Math.max(0, lines.length - 1))
754
- let offset = 0
755
- for (let index = 0; index < line; index++) offset += lines[index].length + 1
756
- return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
757
- }
758
-
759
- function hostFromVimOffset(map: PromptMap, offset: number, bias: "previous" | "next") {
760
- const current = clamp(offset, 0, map.vimText.length)
761
- if (current === map.vimText.length) return displayWidth(map.hostText)
762
-
763
- const host = map.vimToHost[current]
764
- if (host !== undefined) return charToDisplay(map.hostText, host)
765
-
766
- if (bias === "previous") {
767
- for (let previous = current - 1; previous >= 0; previous--) {
768
- const previousHost = map.vimToHost[previous]
769
- if (previousHost !== undefined) return charToDisplay(map.hostText, previousHost)
770
- }
771
- }
772
-
773
- for (let next = current + 1; next < map.vimToHost.length; next++) {
774
- const nextHost = map.vimToHost[next]
775
- if (nextHost !== undefined) return charToDisplay(map.hostText, nextHost)
776
- }
777
- return displayWidth(map.hostText)
778
- }
779
-
780
- function clampNormalCursor(input: EditBufferLike) {
781
- const cursor = input.visualCursor
782
- const offset = input.cursorOffset
783
- const text = input.plainText
784
- if (!cursor || offset === undefined || text === undefined) return
785
- if (cursor.visualCol === 0) return
786
- const dw = displayWidth(text)
787
- if (offset >= dw) {
788
- input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, dw - 1)))
789
- return
790
- }
791
- const charIdx = displayToChar(text, offset)
792
- if (charIdx < text.length && text[charIdx] === '\n') {
793
- input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1)))
794
- }
795
- }
796
-
797
- function dispatchCommand(command: string, ctx: PromptContext) {
798
- const input = focusedInput(ctx)
799
- if (command === "prompt.history.previous" && input) input.cursorOffset = 0
800
- if (command === "prompt.history.next" && input) input.cursorOffset = input.plainText?.length ?? 0
801
-
802
- const result = ctx.api.keymap.dispatchCommand(command)
803
-
804
- // The host currently checks history-next using character length, then leaves
805
- // the cursor in that unit. Restore the display-width offset after dispatch.
806
- if (command !== "prompt.history.next" || !result.ok) return result
807
- const nextInput = focusedInput(ctx)
808
- if (nextInput?.plainText !== undefined) nextInput.cursorOffset = displayWidth(nextInput.plainText)
809
- return result
810
- }
811
-
812
- function defaultHistoryCommand(key: string, text: string, historyText: string | undefined, enabled: Record<"j" | "k", boolean>) {
813
- if (key !== "j" && key !== "k") return
814
- if (!enabled[key]) return
815
- if (text.length > 0 && text !== historyText) return
816
- return key === "k" ? "prompt.history.previous" : "prompt.history.next"
817
- }
818
-
819
- function hasNormalKeyPrefix(config: VimConfig, key: string) {
820
- for (const sequence of Object.keys(config.keymaps.normal ?? {})) {
821
- try {
822
- if (keyToken(parseKeySequence(sequence)[0] ?? "") === key) return true
823
- } catch {}
824
- }
825
- return false
826
- }
827
-
828
- function endMotionOffset(text: string, offset: number, count: number) {
829
- let index = offset
830
- for (let step = 0; step < count; step++) {
831
- index++
832
- while (index < text.length && isWhitespace(text[index])) index++
833
- const kind = wordKind(text[index])
834
- if (!kind) return Math.max(0, text.length - 1)
835
- while (wordKind(text[index + 1]) === kind) index++
836
- }
837
- return clamp(index, 0, Math.max(0, text.length - 1))
838
- }
839
-
840
- function isWhitespace(value: string | undefined) {
841
- return value === " " || value === "\t" || value === "\n"
842
- }
843
-
844
- function wordKind(value: string | undefined) {
845
- if (!value || isWhitespace(value)) return undefined
846
- return /\w/.test(value) ? "word" : "punctuation"
847
- }
848
-
849
- function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined {
850
- const map = createKeybindMap()
851
- let count = 0
852
-
853
- for (const [mode, keymaps] of Object.entries(config.keymaps) as Array<[VimeeMode, Record<string, string> | undefined]>) {
854
- if (!keymaps) continue
855
- for (const [keys, action] of Object.entries(keymaps)) {
856
- try {
857
- const tokens = parseKeySequence(keys)
858
- if (tokens.length > 1 && tokens[0] === "<CR>") throw new Error("<CR> cannot start a multi-key mapping")
859
- map.addKeybind(mode, keys as ValidKeySequence<typeof keys>, keybindAction(action))
860
- count++
861
- } catch (error) {
862
- log("vimee.keymap.invalid", { mode, keys, action, error: error instanceof Error ? error.message : String(error) })
863
- }
864
- }
865
- }
866
-
867
- return count > 0 ? map : undefined
868
- }
869
-
870
- function keybindAction(action: string): HostKeybindDefinition {
871
- if (action.startsWith("command:")) {
872
- const command = action.slice(8).trim()
873
- if (!command) throw new Error("Command name is required")
874
- return {
875
- execute: () => [{ type: "command", command } as unknown as VimeeAction],
876
- hostAction: "command",
877
- command,
878
- }
879
- }
880
- switch (action) {
881
- case "normal":
882
- return { keys: "<Esc>", hostAction: "normal" }
883
- case "insert":
884
- return { keys: "i" }
885
- case "submit":
886
- return { execute: () => [{ type: "submit" } as unknown as VimeeAction], hostAction: "submit" }
887
- default:
888
- return { keys: action }
889
- }
890
- }
891
-
892
- function keyForVimee(event: KeyEvent, key: string) {
893
- if (key === "<C-[>") return "Escape"
894
- if (event.ctrl) return event.name?.toLowerCase()
895
- const token = keyToken(key)
896
- return token.startsWith("<") ? undefined : token
897
- }
898
-
899
- function keyToken(token: string) {
900
- if (token === "<Esc>" || token === "<C-[>") return "Escape"
901
- if (token === "<CR>") return "Enter"
902
- if (token === "<Tab>") return "Tab"
903
- if (token === "<BS>") return "Backspace"
904
- if (token === "<Del>") return "Delete"
905
- if (token === "<Space>") return " "
906
- if (token === "<Up>") return "ArrowUp"
907
- if (token === "<Down>") return "ArrowDown"
908
- if (token === "<Left>") return "ArrowLeft"
909
- if (token === "<Right>") return "ArrowRight"
910
- if (token === "<Home>") return "Home"
911
- if (token === "<End>") return "End"
912
- if (token.startsWith("<C-") && token.endsWith(">")) return token.slice(3, -1).toLowerCase()
913
- return token
914
- }
915
-
916
- function tokenCtrl(token: string) {
917
- return token.startsWith("<C-") && token !== "<C-[>"
918
- }
919
-
920
- function syncMode(state: VimState, mode: VimContext["mode"]) {
921
- state.setMode(mode === "insert" || mode === "visual" || mode === "visual-line" ? mode : "normal")
922
- }
923
-
924
- function pendingDisplay(ctx: VimContext, keybindPending: boolean) {
925
- const count = ctx.count > 0 ? String(ctx.count) : ""
926
- if (ctx.phase === "operator-pending") return count + (ctx.operator ?? "")
927
- if (ctx.phase === "text-object-pending") return count + (ctx.operator ?? "") + (ctx.textObjectModifier ?? "")
928
- if (ctx.phase === "char-pending") return count + (ctx.operator ?? "") + (ctx.charCommand ?? "")
929
- if (keybindPending) return ctx.statusMessage
930
- return count
931
- }
932
-
933
- function plainPending(value: string) {
934
- return value.includes("<") || value.includes(">") ? "" : value
935
- }
936
-
937
- function consumesKey(key: string, actions: HostAction[], ctx: VimContext, keybindPending: boolean) {
938
- if (actions.length > 0) return true
939
- if (keybindPending) return true
940
- if (ctx.phase !== "idle") return true
941
- return ctx.mode !== "insert" || key === "Escape"
942
- }
943
-
944
- function textObjectAlias(key: string, ctx: VimContext) {
945
- if (ctx.phase !== "text-object-pending") return undefined
946
- if (key === "b") return "("
947
- if (key === "B") return "{"
948
- return undefined
949
- }
950
-
951
- function textObjectOperator(operator: Operator): operator is "y" | "d" | "c" {
952
- return operator === "y" || operator === "d" || operator === "c"
953
- }
954
-
955
- function resolvePromptTextObject(modifier: "i" | "a", key: string, cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
956
- if (key === "q") return quoteRange(modifier, cursor, buffer)
957
- if (key !== "p") return null
958
- return paragraphRange(modifier, cursor, buffer)
959
- }
960
-
961
- function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
962
- const pair = quoteObjectPair(cursor, buffer)
963
- if (!pair) return null
964
- const start = modifier === "i" ? pair.open + 1 : pair.open
965
- const end = modifier === "i" ? pair.close - 1 : pair.close
966
- return {
967
- start: { line: cursor.line, col: start },
968
- end: { line: cursor.line, col: Math.max(start, end) },
969
- linewise: false,
970
- inclusive: end >= start,
971
- }
972
- }
973
-
974
- function paragraphRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
975
- const count = buffer.getLineCount()
976
- if (count === 0) return null
977
-
978
- let start = clamp(cursor.line, 0, count - 1)
979
- while (start < count && blankLine(buffer.getLine(start))) start++
980
- if (start >= count) {
981
- start = clamp(cursor.line, 0, count - 1)
982
- while (start >= 0 && blankLine(buffer.getLine(start))) start--
983
- }
984
- if (start < 0 || start >= count) return null
985
-
986
- let end = start
987
- while (start > 0 && !blankLine(buffer.getLine(start - 1))) start--
988
- while (end < count - 1 && !blankLine(buffer.getLine(end + 1))) end++
989
-
990
- if (modifier === "a") {
991
- if (end < count - 1 && blankLine(buffer.getLine(end + 1))) {
992
- end++
993
- while (end < count - 1 && blankLine(buffer.getLine(end + 1))) end++
994
- } else {
995
- while (start > 0 && blankLine(buffer.getLine(start - 1))) start--
996
- }
997
- }
998
-
999
- return {
1000
- start: { line: start, col: 0 },
1001
- end: { line: end, col: Math.max(0, buffer.getLineLength(end) - 1) },
1002
- linewise: true,
1003
- inclusive: true,
1004
- }
1005
- }
1006
-
1007
- function quoteObjectPair(cursor: CursorPosition, buffer: TextBuffer) {
1008
- let best: { open: number; close: number; distance: number } | undefined
1009
- for (const quote of ['"', "'", "`"] as const) {
1010
- const pair = quotePair(cursor, buffer, quote)
1011
- if (!pair) continue
1012
- if (!best || pair.distance < best.distance) best = pair
1013
- }
1014
- return best
1015
- }
1016
-
1017
- function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
1018
- const line = buffer.getLine(cursor.line)
1019
- let open = -1
1020
- let close = -1
1021
- let inQuote = false
1022
- let quoteStart = -1
1023
-
1024
- for (let index = 0; index < line.length; index++) {
1025
- if (line[index] !== quote || escaped(line, index)) continue
1026
- if (!inQuote) {
1027
- quoteStart = index
1028
- inQuote = true
1029
- continue
1030
- }
1031
- if (cursor.col >= quoteStart && cursor.col <= index) {
1032
- return { open: quoteStart, close: index, distance: 0 }
1033
- }
1034
- inQuote = false
1035
- }
1036
-
1037
- for (let index = cursor.col; index < line.length; index++) {
1038
- if (line[index] !== quote || escaped(line, index)) continue
1039
- if (open === -1) open = index
1040
- else {
1041
- close = index
1042
- break
1043
- }
1044
- }
1045
- if (open !== -1 && close !== -1) return { open, close, distance: open - cursor.col }
1046
-
1047
- close = -1
1048
- open = -1
1049
- for (let index = cursor.col; index >= 0; index--) {
1050
- if (line[index] !== quote || escaped(line, index)) continue
1051
- if (close === -1) close = index
1052
- else {
1053
- open = index
1054
- break
1055
- }
1056
- }
1057
- if (open !== -1 && close !== -1) return { open, close, distance: cursor.col - close }
1058
- return undefined
1059
- }
1060
-
1061
- function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
1062
- if (operator !== "y") buffer.saveUndoPoint(ctx.cursor)
1063
- const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer)
1064
- const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
1065
- const context = {
1066
- ...resetContext(ctx),
1067
- mode: result.mode,
1068
- cursor: result.cursor,
1069
- register: result.yankedText,
1070
- registers,
1071
- statusMessage: result.statusMessage,
1072
- }
1073
- const actions: VimeeAction[] = [{ type: "yank", text: result.yankedText }, ...result.actions, { type: "mode-change", mode: result.mode }, { type: "cursor-move", position: result.cursor }]
1074
- return { actions, context, yankedText: result.yankedText }
1075
- }
1076
-
1077
- function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
1078
- const startLine = Math.min(range.start.line, range.end.line)
1079
- const endLine = Math.max(range.start.line, range.end.line)
1080
- const lineCount = endLine - startLine + 1
1081
- const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n"
1082
- if (operator === "y") {
1083
- return { actions: [] as VimeeAction[], cursor: { line: startLine, col: 0 }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" }
1084
- }
1085
-
1086
- buffer.deleteLines(startLine, lineCount)
1087
- if (operator === "c") buffer.insertLine(startLine, "")
1088
- else if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
1089
- const line = Math.min(startLine, buffer.getLineCount() - 1)
1090
- return {
1091
- actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
1092
- cursor: { line, col: 0 },
1093
- mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
1094
- yankedText,
1095
- statusMessage: lineCount >= 2 ? `${lineCount} fewer lines` : "",
1096
- }
1097
- }
1098
-
1099
- function executeCharwiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
1100
- const ordered = orderedRange(range)
1101
- const endCol = range.inclusive ? ordered.end.col + 1 : ordered.end.col
1102
- const yankedText = textInRange(buffer, ordered.start, { line: ordered.end.line, col: endCol })
1103
- if (operator === "y") {
1104
- return { actions: [] as VimeeAction[], cursor: ordered.start, mode: "normal" as VimeeMode, yankedText, statusMessage: yankedText.split("\n").length >= 2 ? `${yankedText.split("\n").length} lines yanked` : "" }
1105
- }
1106
-
1107
- const linesBefore = buffer.getLineCount()
1108
- buffer.deleteRange(ordered.start.line, ordered.start.col, ordered.end.line, endCol)
1109
- const linesRemoved = linesBefore - buffer.getLineCount()
1110
- return {
1111
- actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
1112
- cursor: { line: ordered.start.line, col: operator === "c" ? ordered.start.col : Math.min(ordered.start.col, Math.max(0, buffer.getLineLength(ordered.start.line) - 1)) },
1113
- mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
1114
- yankedText,
1115
- statusMessage: linesRemoved >= 2 ? `${linesRemoved} fewer lines` : "",
1116
- }
1117
- }
1118
-
1119
- function orderedRange(range: MotionRange) {
1120
- if (range.start.line > range.end.line || (range.start.line === range.end.line && range.start.col > range.end.col)) {
1121
- return { start: range.end, end: range.start }
1122
- }
1123
- return { start: range.start, end: range.end }
1124
- }
1125
-
1126
- function textInRange(buffer: TextBuffer, start: CursorPosition, end: CursorPosition) {
1127
- if (start.line === end.line) return buffer.getLine(start.line).slice(start.col, end.col)
1128
- const lines = [buffer.getLine(start.line).slice(start.col)]
1129
- for (let line = start.line + 1; line < end.line; line++) lines.push(buffer.getLine(line))
1130
- lines.push(buffer.getLine(end.line).slice(0, end.col))
1131
- return lines.join("\n")
1132
- }
1133
-
1134
- function blankLine(line: string) {
1135
- return line.trim().length === 0
1136
- }
1137
-
1138
- function escaped(line: string, index: number) {
1139
- let slashCount = 0
1140
- for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor--) slashCount++
1141
- return slashCount % 2 === 1
1142
- }
1143
-
1144
- function clamp(value: number, min: number, max: number) {
1145
- return Math.max(min, Math.min(max, value))
1146
- }