Skip to content

Commit a6f2c75

Browse files
committed
feat: support vim single-character replace
1 parent 56bf3b8 commit a6f2c75

4 files changed

Lines changed: 179 additions & 3 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
previousParagraphOperation,
4949
prevWordStart,
5050
replaceUnderCursor,
51+
replaceSelection,
5152
substituteLine,
5253
substituteLineEnd,
5354
syncSelection,
@@ -123,6 +124,12 @@ export function createVimHandler(input: {
123124
return event.name ?? ""
124125
}
125126

127+
function replaceValue(event: VimEvent, visual = false) {
128+
if (event.name === "return") return visual ? "\r" : "\n"
129+
if (isPrintable(event)) return value(event)
130+
return null
131+
}
132+
126133
function isShifted(event: VimEvent, key: string) {
127134
return event.name === key.toUpperCase() || (event.name === key && !!event.shift)
128135
}
@@ -259,6 +266,55 @@ export function createVimHandler(input: {
259266
function dispatch(event: VimEvent, key: string): boolean {
260267
if (!preservesWantedColumn(event, key)) clearWantedColumn()
261268

269+
if (input.state.pending() === "r") {
270+
if (hasModifier(event)) {
271+
input.state.clearPending()
272+
return false
273+
}
274+
275+
const next = replaceValue(event)
276+
if (next !== null) {
277+
edit(() => {
278+
const offset = input.textarea().cursorOffset
279+
const reg = deleteUnderCursor(input.textarea())
280+
if (reg) {
281+
input.textarea().insertText(next)
282+
input.textarea().cursorOffset = next === "\n" ? offset + 1 : offset
283+
}
284+
input.state.clearPending()
285+
})
286+
event.preventDefault()
287+
return true
288+
}
289+
290+
input.state.clearPending()
291+
event.preventDefault()
292+
return true
293+
}
294+
295+
if (input.state.pending() === "vr" && input.state.isVisual()) {
296+
if (hasModifier(event)) {
297+
input.state.clearPending()
298+
return false
299+
}
300+
301+
const next = replaceValue(event, true)
302+
if (next !== null) {
303+
edit(() => {
304+
replaceSelection(input.textarea(), next, input.state.isVisualLine(), input.state.anchor() ?? undefined)
305+
clearSelection(input.textarea())
306+
input.state.clearPending()
307+
input.state.setMode("normal")
308+
})
309+
event.preventDefault()
310+
return true
311+
}
312+
313+
input.state.clearPending()
314+
event.preventDefault()
315+
return true
316+
}
317+
262318
const scroll = vimScroll(event)
263319
if (scroll) {
264320
input.state.clearPending()
@@ -316,6 +372,12 @@ export function createVimHandler(input: {
316372
return true
317373
}
318374

375+
if (key === "r" && !event.shift && !hasModifier(event)) {
376+
input.state.setPending("vr")
377+
event.preventDefault()
378+
return true
379+
}
380+
319381
if ((key === "i" || key === "a" || key === "o") && !event.shift && !hasModifier(event)) {
320382
event.preventDefault()
321383
return true
@@ -659,6 +721,12 @@ export function createVimHandler(input: {
659721
return true
660722
}
661723

724+
if (key === "r" && !event.shift && !hasModifier(event)) {
725+
input.state.setPending("r")
726+
event.preventDefault()
727+
return true
728+
}
729+
662730
if (key === "p" && !event.shift && !hasModifier(event)) {
663731
edit(() => {
664732
pasteAfter(input.textarea(), register())

packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,19 @@ export function toggleSelectionCase(textarea: TextareaRenderable, linewise = fal
832832
textarea.cursorOffset = sel.start
833833
}
834834

835+
export function replaceSelection(textarea: TextareaRenderable, value: string, linewise = false, anchor?: number) {
836+
const sel = selectionRange(textarea, anchor, linewise)
837+
if (!sel) return
838+
const next = textarea.plainText
839+
.slice(sel.start, sel.end)
840+
.split("")
841+
.map((char) => (char === "\n" ? char : value))
842+
.join("")
843+
deleteOffsets(textarea, sel.start, sel.end)
844+
textarea.insertText(next)
845+
textarea.cursorOffset = sel.start
846+
}
847+
835848
export function deleteSelection(textarea: TextareaRenderable, linewise = false, anchor?: number): VimRegister {
836849
const sel = selectionRange(textarea, anchor, linewise)
837850
if (!sel) return null

packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
22

33
export type VimMode = "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy"
4-
export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y"
4+
export type VimPending = "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "r" | "vr"
55
export type VimFind = { char: string; forward: boolean; till: boolean } | null
66
export type VimRegister = { text: string; linewise: boolean } | null
77
export type VimSnapshot = { text: string; cursor: number; data?: unknown }

packages/opencode/test/cli/tui/vim-motions.test.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ function createHandler(
166166
const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">(
167167
options?.mode ?? "normal",
168168
)
169-
const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y">("")
169+
const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "r" | "vr">("")
170170
const [lastFind, setLastFind] = createSignal<{ char: string; forward: boolean; till: boolean } | null>(null)
171171
const [register, setRegister] = createSignal<{ text: string; linewise: boolean } | null>(null)
172172
const [anchor, setAnchor] = createSignal<number | null>(null)
@@ -1565,6 +1565,72 @@ describe("vim motion handler", () => {
15651565
expect(ctx.textarea.cursorOffset).toBe(0)
15661566
})
15671567

1568+
test("r replaces one character and stays normal", () => {
1569+
const ctx = createHandler("abcd")
1570+
ctx.textarea.cursorOffset = 1
1571+
1572+
const start = createEvent("r")
1573+
expect(ctx.handler.handleKey(start.event)).toBe(true)
1574+
expect(start.prevented()).toBe(true)
1575+
expect(ctx.state.pending()).toBe("r")
1576+
1577+
const replacement = createEvent("X")
1578+
expect(ctx.handler.handleKey(replacement.event)).toBe(true)
1579+
expect(replacement.prevented()).toBe(true)
1580+
expect(ctx.textarea.plainText).toBe("aXcd")
1581+
expect(ctx.textarea.cursorOffset).toBe(1)
1582+
expect(ctx.state.mode()).toBe("normal")
1583+
expect(ctx.state.pending()).toBe("")
1584+
})
1585+
1586+
test("r replaces with space", () => {
1587+
const ctx = createHandler("abcd")
1588+
ctx.textarea.cursorOffset = 2
1589+
1590+
ctx.handler.handleKey(createEvent("r").event)
1591+
ctx.handler.handleKey(createEvent("space").event)
1592+
1593+
expect(ctx.textarea.plainText).toBe("ab d")
1594+
expect(ctx.textarea.cursorOffset).toBe(2)
1595+
expect(ctx.state.mode()).toBe("normal")
1596+
})
1597+
1598+
test("r return replaces character with newline and moves to next line", () => {
1599+
const ctx = createHandler("abcd")
1600+
ctx.textarea.cursorOffset = 1
1601+
1602+
ctx.handler.handleKey(createEvent("r").event)
1603+
ctx.handler.handleKey(createEvent("return").event)
1604+
1605+
expect(ctx.textarea.plainText).toBe("a\ncd")
1606+
expect(ctx.textarea.cursorOffset).toBe(2)
1607+
expect(ctx.state.mode()).toBe("normal")
1608+
})
1609+
1610+
test("r does not insert on empty line", () => {
1611+
const ctx = createHandler("ab\n\ncd")
1612+
ctx.textarea.cursorOffset = 3
1613+
1614+
ctx.handler.handleKey(createEvent("r").event)
1615+
ctx.handler.handleKey(createEvent("X").event)
1616+
1617+
expect(ctx.textarea.plainText).toBe("ab\n\ncd")
1618+
expect(ctx.textarea.cursorOffset).toBe(3)
1619+
expect(ctx.state.pending()).toBe("")
1620+
})
1621+
1622+
test("r replaces with uppercase characters before jump handling", () => {
1623+
const ctx = createHandler("abcd")
1624+
ctx.textarea.cursorOffset = 1
1625+
1626+
ctx.handler.handleKey(createEvent("r").event)
1627+
ctx.handler.handleKey(createEvent("G").event)
1628+
1629+
expect(ctx.textarea.plainText).toBe("aGcd")
1630+
expect(ctx.textarea.cursorOffset).toBe(1)
1631+
expect(ctx.jumpCalls).toEqual([])
1632+
})
1633+
15681634
test("R enters replace mode and escape exits", () => {
15691635
const ctx = createHandler("abc")
15701636

@@ -3451,6 +3517,35 @@ describe("vim motion handler", () => {
34513517
expect((ctx.textarea as any).editorView.getSelection()).toBe(null)
34523518
})
34533519

3520+
test("visual r replaces selection before jump handling", () => {
3521+
const ctx = createHandler("abcd\nefgh")
3522+
ctx.textarea.cursorOffset = 1
3523+
3524+
ctx.handler.handleKey(createEvent("v").event)
3525+
ctx.handler.handleKey(createEvent("l").event)
3526+
ctx.handler.handleKey(createEvent("r").event)
3527+
ctx.handler.handleKey(createEvent("G").event)
3528+
3529+
expect(ctx.textarea.plainText).toBe("aGGd\nefgh")
3530+
expect(ctx.textarea.cursorOffset).toBe(1)
3531+
expect(ctx.jumpCalls).toEqual([])
3532+
expect(ctx.state.mode()).toBe("normal")
3533+
})
3534+
3535+
test("visual r return inserts carriage returns without splitting lines", () => {
3536+
const ctx = createHandler("abcd")
3537+
ctx.textarea.cursorOffset = 1
3538+
3539+
ctx.handler.handleKey(createEvent("v").event)
3540+
ctx.handler.handleKey(createEvent("l").event)
3541+
ctx.handler.handleKey(createEvent("r").event)
3542+
ctx.handler.handleKey(createEvent("return").event)
3543+
3544+
expect(ctx.textarea.plainText).toBe("a\r\rd")
3545+
expect(ctx.textarea.cursorOffset).toBe(1)
3546+
expect(ctx.state.mode()).toBe("normal")
3547+
})
3548+
34543549
test("visual mode with backward motion", () => {
34553550
const ctx = createHandler("hello world")
34563551
ctx.textarea.cursorOffset = 5
@@ -5140,7 +5235,7 @@ describe("copy mode cursor state", () => {
51405235
const textarea = createTextarea("")
51415236
const [enabled] = createSignal(true)
51425237
const [mode, setMode] = createSignal<"normal" | "insert" | "replace" | "visual" | "visual-line" | "copy">("copy")
5143-
const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y">("")
5238+
const [pending, setPending] = createSignal<"" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "r" | "vr">("")
51445239
const [lastFind, setLastFind] = createSignal<{ char: string; forward: boolean; till: boolean } | null>(null)
51455240
const [register, setRegister] = createSignal<{ text: string; linewise: boolean } | null>(null)
51465241
const [anchor, setAnchor] = createSignal<number | null>(null)

0 commit comments

Comments
 (0)