diff --git a/README.md b/README.md index 28b32acb62b5..6aa672d38b12 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,15 @@ Text selection from the chat session view. | `i` | Focus the prompt input in insert mode without scrolling | | `z`, `zt`, `zz`, `zb` | Adjust copy-mode scroll positioning | | `H`, `M`, `L` | Jump to the top, middle, or bottom of the viewport | +| `/`, `?` | Search forward or backward in chat history | +| `n`, `N` | Repeat search in the same or opposite direction | + +When in search mode, `Enter` submits the search, and `Escape` clears search highlights before exiting copy mode. + +> Search uses smartcase, lowercase queries are case-insensitive, and queries containing uppercase letters are case-sensitive. > [!TIP] -> Configure the entry key with `keybinds.copy_mode`. +> Configure the copy mode entry key with `keybinds.copy_mode`. ### Minimal UI diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index a922166aa733..e1b26cd63bbc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -80,6 +80,8 @@ import { VIM_WINDOW_TOKEN, } from "../../keymap" +type CopySearchDirection = "forward" | "backward" + export type PromptProps = { sessionID?: string workspaceID?: string @@ -115,6 +117,18 @@ export type PromptProps = { matchingBracket: () => boolean nextParagraph: () => boolean previousParagraph: () => boolean + searchStart: (direction: CopySearchDirection) => void + searchAppend: (value: string) => boolean + searchBackspace: () => boolean + searchSubmit: () => boolean + searchCancel: () => void + searchClear: () => boolean + searchActive: () => boolean + searchHighlighted: () => boolean + searchMatchCount: () => number + searchDisplay: () => string | undefined + searchNext: () => boolean + searchPrevious: () => boolean text: () => string col: () => number setCol: (offset: number) => void @@ -547,6 +561,7 @@ export function Prompt(props: PromptProps) { active: () => store.mode === "normal", state: vimState, copyVisual: () => props.copy?.visualMode(), + copySearch: () => props.copy?.searchDisplay(), }) let flash = 0 let timer: ReturnType | undefined @@ -773,6 +788,36 @@ export function Prompt(props: PromptProps) { copyPreviousParagraph() { return props.copy?.previousParagraph() ?? false }, + copySearchStart(direction) { + props.copy?.searchStart(direction) + }, + copySearchAppend(value) { + return props.copy?.searchAppend(value) ?? false + }, + copySearchBackspace() { + return props.copy?.searchBackspace() ?? false + }, + copySearchSubmit() { + return props.copy?.searchSubmit() ?? true + }, + copySearchCancel() { + props.copy?.searchCancel() + }, + copySearchClear() { + return props.copy?.searchClear() ?? false + }, + copySearchActive() { + return props.copy?.searchActive() ?? false + }, + copySearchHighlighted() { + return props.copy?.searchHighlighted() ?? false + }, + copySearchNext() { + return props.copy?.searchNext() ?? false + }, + copySearchPrevious() { + return props.copy?.searchPrevious() ?? false + }, copyText() { return props.copy?.text() ?? "" }, @@ -1189,6 +1234,68 @@ export function Prompt(props: PromptProps) { })), })) + useBindings(() => ({ + target: inputTarget, + priority: 200, + enabled: + inputTarget() !== undefined && + !props.disabled && + vimEnabled() && + store.mode === "normal" && + vimState.isCopy() && + !!props.copy?.searchActive(), + bindings: [ + ...["backspace", "shift+backspace", "delete", "ctrl+h"].map((key) => ({ + key, + desc: "Delete search character", + group: "Copy mode", + cmd: () => { + props.copy?.searchBackspace() + return true + }, + })), + { + key: "return", + desc: "Submit search", + group: "Copy mode", + cmd: () => { + if (props.copy?.searchSubmit() === false) { + toast.show({ message: "Pattern not found", variant: "warning" }) + return true + } + const count = props.copy?.searchMatchCount() ?? 0 + if (count > 0) toast.show({ message: `${count} ${count === 1 ? "match" : "matches"}`, variant: "info" }) + return true + }, + }, + ], + })) + + useBindings(() => ({ + target: inputTarget, + priority: 200, + enabled: + inputTarget() !== undefined && + !props.disabled && + vimEnabled() && + store.mode === "normal" && + vimState.isCopy() && + !props.copy?.isVisual() && + !!props.copy?.searchHighlighted(), + bindings: [ + { + key: "escape", + desc: "Clear search highlights", + group: "Copy mode", + cmd: () => { + if (props.copy?.searchActive()) props.copy.searchCancel() + else props.copy?.searchClear() + return true + }, + }, + ], + })) + useBindings(() => ({ target: inputTarget, priority: 100, diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts index d1b38f1b8736..d9f758afe0a1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts @@ -84,6 +84,7 @@ export type VimEvent = { export type VimCopyMove = "up" | "down" | "left" | "right" type VimFindOperator = "f" | "F" | "t" | "T" +type VimSearchDirection = "forward" | "backward" type VimTextObjectScope = "inner" | "around" type VimKeyLike = { name?: string; shift?: boolean; sequence?: string; raw?: string } @@ -97,13 +98,14 @@ function vimEventText(event: VimKeyLike) { } function normalizedKeyName(event: VimKeyLike) { - if (event.name === "slash") return "/" + if (event.name === "backspace" || event.sequence === "\b" || event.sequence === "\x7f" || event.raw === "\b" || event.raw === "\x7f") return "backspace" + if (event.name === "slash") return event.shift ? "?" : "/" if (event.name === "at") return "@" if (event.name === "quote") return '"' if (event.name === "apostrophe") return "'" if (event.name === "backtick") return "`" const text = vimEventText(event) - if (text && (text === "/" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text + if (text && (text === "/" || text === "?" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text if (event.shift) { if (event.name === "9") return "(" if (event.name === "0") return ")" @@ -142,6 +144,16 @@ export function createVimHandler(input: { copyMatchingBracket?: () => boolean copyNextParagraph?: () => boolean copyPreviousParagraph?: () => boolean + copySearchStart?: (direction: VimSearchDirection) => void + copySearchAppend?: (value: string) => boolean + copySearchBackspace?: () => boolean + copySearchSubmit?: () => boolean + copySearchCancel?: () => void + copySearchClear?: () => boolean + copySearchActive?: () => boolean + copySearchHighlighted?: () => boolean + copySearchNext?: () => boolean + copySearchPrevious?: () => boolean copyText?: () => string copyCol?: () => number setCopyCol?: (offset: number) => void @@ -1320,6 +1332,31 @@ export function createVimHandler(input: { } function copy(event: VimEvent, key: string): boolean { + if (input.copySearchActive?.()) { + if (key === "return") { + input.copySearchSubmit?.() + event.preventDefault() + return true + } + if (key === "escape") { + input.copySearchCancel?.() + event.preventDefault() + return true + } + if (key === "backspace" || key === "delete" || (key === "h" && event.ctrl && !event.meta && !event.super)) { + input.copySearchBackspace?.() + event.preventDefault() + return true + } + if (!hasModifier(event) && isPrintable(event)) { + input.copySearchAppend?.(value(event)) + event.preventDefault() + return true + } + event.preventDefault() + return true + } + if (input.state.pending() === "" && isShifted(event, "y") && !hasModifier(event)) { if (input.copyIsVisual?.()) { input.copyYank?.() @@ -1400,6 +1437,10 @@ export function createVimHandler(input: { event.preventDefault() return true } + if (input.copySearchHighlighted?.() && input.copySearchClear?.()) { + event.preventDefault() + return true + } input.state.setMode("normal") event.preventDefault() return true @@ -1448,6 +1489,34 @@ export function createVimHandler(input: { return false } + if (pending === "" && key === "/") { + clearCopyPending() + input.copySearchStart?.("forward") + event.preventDefault() + return true + } + + if (pending === "" && key === "?") { + clearCopyPending() + input.copySearchStart?.("backward") + event.preventDefault() + return true + } + + if (pending === "" && key === "n" && !event.shift) { + clearCopyPending() + input.copySearchNext?.() + event.preventDefault() + return true + } + + if (pending === "" && isShifted(event, "n")) { + clearCopyPending() + input.copySearchPrevious?.() + event.preventDefault() + return true + } + if (isShifted(event, "h")) { clearCopyPending() input.copyJump?.("high") @@ -1760,7 +1829,7 @@ export function createVimHandler(input: { } if (input.state.isCopy()) { - const mapped = langmapped(event) + const mapped = input.copySearchActive?.() ? event : langmapped(event) return copy(mapped, normalizedKeyName(mapped)) } diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts index 97e0525e5e86..8cc5afab3096 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts @@ -6,12 +6,15 @@ export function useVimIndicator(input: { active: Accessor state: ReturnType copyVisual?: Accessor + copySearch?: Accessor }) { return createMemo(() => { if (!input.enabled() || !input.active()) return const key = input.state.pending() if (key && key !== "w") return (input.state.pendingDisplay() || key) + ".." if (input.state.isCopy()) { + const search = input.copySearch?.() + if (search !== undefined) return search if (input.copyVisual?.() === "char") return "-- VISUAL --" if (input.copyVisual?.() === "line") return "-- VISUAL LINE --" return "-- COPY --" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 87189801dd74..0fe29b28436b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -29,6 +29,8 @@ export type CopyHighlight = { left: number right: number text: string + kind?: "search" + current?: boolean } type CopyState = { @@ -40,6 +42,24 @@ type CopyState = { anchor: undefined | { idx: number; col: number } } +type CopySearchDirection = "forward" | "backward" + +type CopySearchOrigin = { + idx: number + col: number +} + +type CopySearch = { + query: string + direction: CopySearchDirection + origin: CopySearchOrigin +} + +type CopySearchMatch = { + idx: number + col: number +} + const empty: CopyState = { active: false, idx: -1, @@ -71,6 +91,8 @@ export function createCopyMode(input: { const [unified, setUnified] = createSignal(false) const [yankLineFlash, setYankLineFlash] = createSignal(undefined) const [yankRangeFlash, setYankRangeFlash] = createSignal<{ start: Endpoint; end: Endpoint } | undefined>(undefined) + const [activeSearch, setActiveSearch] = createSignal(undefined) + const [lastSearch, setLastSearch] = createSignal(undefined) let yankFlashTimer: ReturnType | undefined let lastCursor: CopyRow | undefined @@ -588,6 +610,11 @@ export function createCopyMode(input: { setTimeout(() => init(), 0) } + function clearSearchState() { + setLastSearch(undefined) + setActiveSearch(undefined) + } + function exit(scrollToBottom?: boolean) { if (scrollToBottom === false) { exitPreserveScroll() @@ -595,6 +622,7 @@ export function createCopyMode(input: { } lastCursor = undefined batch(() => { + clearSearchState() setState({ ...empty }) setUnified(false) }) @@ -605,6 +633,7 @@ export function createCopyMode(input: { lastCursor = row() const snap = snapshotScroll() batch(() => { + clearSearchState() setState((s) => ({ ...s, active: false, visual: undefined, anchor: undefined })) setUnified(false) }) @@ -745,6 +774,142 @@ export function createCopyMode(input: { return paragraphMove(copyPreviousParagraph) } + // --- search --- + + function childCache() { + return new Map(input.scroll().getChildren().map((c) => [c.id, c])) + } + + function searchMatches(query: string, list: CopyRow[], cache: Map): CopySearchMatch[] { + const needle = query + if (!needle) return [] + const sensitive = /[A-Z]/.test(needle) + const target = sensitive ? needle : needle.toLowerCase() + return list.flatMap((row, idx) => { + const text = rowText(row, cache) + const haystack = sensitive ? text : text.toLowerCase() + const min = copyMin(row, cache) + const matches: CopySearchMatch[] = [] + let from = 0 + while (from <= haystack.length) { + const found = haystack.indexOf(target, from) + if (found < 0) break + matches.push({ idx, col: min + found }) + from = found + Math.max(1, target.length) + } + return matches + }) + } + + function currentSearchMatches(query: string) { + return searchMatches(query, rows(), childCache()) + } + + function pickSearchMatch(matches: CopySearchMatch[], direction: CopySearchDirection, origin: CopySearchOrigin) { + if (direction === "forward") { + return matches.find((match) => match.idx > origin.idx || (match.idx === origin.idx && match.col > origin.col)) ?? matches[0] + } + return ( + matches.findLast((match) => match.idx < origin.idx || (match.idx === origin.idx && match.col < origin.col)) ?? + matches[matches.length - 1] + ) + } + + function moveToSearchMatch(match: CopySearchMatch) { + sync(match.idx) + const row = rows()[state().idx] + if (!row) return false + const min = copyMin(row) + setState((s) => ({ + ...s, + col: Math.max(min, match.col), + stick: Math.max(0, match.col - min), + visual: undefined, + anchor: undefined, + })) + return true + } + + function restoreSearchOrigin(search: CopySearch) { + sync(search.origin.idx) + setCol(search.origin.col) + } + + function search(query: string, direction: CopySearchDirection, origin: CopySearchOrigin = state(), commit = true) { + const matches = currentSearchMatches(query) + const match = pickSearchMatch(matches, direction, origin) + if (!match) return false + if (commit) setLastSearch({ query, direction, origin }) + return moveToSearchMatch(match) + } + + function startSearch(direction: CopySearchDirection) { + const s = state() + setActiveSearch({ query: "", direction, origin: { idx: s.idx, col: s.col } }) + } + + function updateSearch(query: string) { + const current = activeSearch() + if (!current) return false + setActiveSearch({ ...current, query }) + if (!query) { + setLastSearch(undefined) + restoreSearchOrigin(current) + return false + } + const found = search(query, current.direction, current.origin, false) + if (!found) restoreSearchOrigin(current) + return found + } + + function appendSearch(value: string) { + return updateSearch((activeSearch()?.query ?? "") + value) + } + + function backspaceSearch() { + return updateSearch((activeSearch()?.query ?? "").slice(0, -1)) + } + + function submitSearch() { + const current = activeSearch() + setActiveSearch(undefined) + if (!current?.query) { + setLastSearch(undefined) + return true + } + const found = currentSearchMatches(current.query).length > 0 + if (!found) restoreSearchOrigin(current) + setLastSearch(found ? current : undefined) + return found + } + + function cancelSearch() { + const current = activeSearch() + if (current) restoreSearchOrigin(current) + clearSearchState() + } + + function clearSearch() { + if (!activeSearch() && !lastSearch()) return false + clearSearchState() + return true + } + + function searchMatchCount() { + const query = activeSearch()?.query ?? lastSearch()?.query + if (!query) return 0 + return currentSearchMatches(query).length + } + + function repeatSearch(reverse = false) { + const previous = lastSearch() + if (!previous) return false + const direction = reverse ? (previous.direction === "forward" ? "backward" : "forward") : previous.direction + const moved = search(previous.query, direction) + if (moved && reverse) setLastSearch({ ...previous, origin: state() }) + return moved + } + // --- visual --- function visual(mode: "char" | "line") { @@ -960,13 +1125,22 @@ export function createCopyMode(input: { const out = new Map() if (!s.active) return out const flashIdx = yankLineFlash() - const addHighlight = (row: CopyRow, min: number, text: string, left: number, right: number) => { + const addHighlight = ( + row: CopyRow, + min: number, + text: string, + left: number, + right: number, + options?: Pick, + ) => { if (left > right) return - const entry = { + const entry: CopyHighlight = { line: row.line, left, right, text: text.slice(Math.max(0, left - min), Math.max(0, right - min + 1)), + ...(options?.kind ? { kind: options.kind } : {}), + ...(options?.current ? { current: true } : {}), } const arr = out.get(row.id) if (arr) arr.push(entry) @@ -975,12 +1149,21 @@ export function createCopyMode(input: { const flashRange = yankRangeFlash() const list = rows() - const cache = new Map( - input - .scroll() - .getChildren() - .map((c) => [c.id, c]), - ) + const cache = childCache() + + const searchQuery = activeSearch() ? activeSearch()?.query : lastSearch()?.query + if (searchQuery) { + for (const match of searchMatches(searchQuery, list, cache)) { + const row = list[match.idx] + if (!row) continue + const text = rowText(row, cache) || "" + const min = copyMin(row, cache) + addHighlight(row, min, text, match.col, match.col + searchQuery.length - 1, { + kind: "search", + current: match.idx === s.idx && match.col === s.col, + }) + } + } if (flashIdx !== undefined) { const row = list[flashIdx] @@ -1070,6 +1253,22 @@ export function createCopyMode(input: { matchingBracket, nextParagraph, previousParagraph, + searchStart: startSearch, + searchAppend: appendSearch, + searchBackspace: backspaceSearch, + searchSubmit: submitSearch, + searchCancel: cancelSearch, + searchClear: clearSearch, + searchActive: () => activeSearch() !== undefined, + searchHighlighted: () => activeSearch() !== undefined || lastSearch() !== undefined, + searchMatchCount, + searchDisplay: () => { + const search = activeSearch() + if (!search) return undefined + return `${search.direction === "forward" ? "/" : "?"}${search.query}` + }, + searchNext: () => repeatSearch(false), + searchPrevious: () => repeatSearch(true), text: copyText, col, setCol, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx index 9224bd865d96..f0b813a48754 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-overlay.tsx @@ -10,6 +10,8 @@ export function CopyOverlay(props: { copy?: CopyPosition; topOffset?: number; hi const { theme } = useTheme() const top = (line: number) => line + (props.topOffset ?? 0) const highlightFg = createMemo(() => selectedForeground(theme, theme.secondary)) + const searchHighlightFg = createMemo(() => selectedForeground(theme, theme.textMuted)) + const currentHighlightFg = createMemo(() => selectedForeground(theme, theme.primary)) const cursorFg = createMemo(() => selectedForeground(theme, theme.text)) return ( <> @@ -24,13 +26,23 @@ export function CopyOverlay(props: { copy?: CopyPosition; topOffset?: number; hi /> - {(highlight) => ( - - - {highlight.text || " "} - - - )} + {(highlight) => { + const background = () => + highlight.kind === "search" ? (highlight.current ? theme.primary : theme.textMuted) : theme.secondary + const foreground = () => + highlight.kind === "search" + ? highlight.current + ? currentHighlightFg() + : searchHighlightFg() + : highlightFg() + return ( + + + {highlight.text || " "} + + + ) + }} diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 71a6ee967056..54837095b532 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -207,6 +207,16 @@ function createHandler( const copyJumps: Array = [] const copyVisualCalls: Array<"char" | "line"> = [] const copyScrollCalls: Array<"center" | "top" | "bottom"> = [] + const copySearchCalls: Array<"forward" | "backward"> = [] + const copySearchAppends: string[] = [] + let copySearchBackspaces = 0 + let copySearchSubmits = 0 + let copySearchCancels = 0 + let copySearchClears = 0 + const [copySearchActive, setCopySearchActive] = createSignal(false) + const [copySearchHighlighted, setCopySearchHighlighted] = createSignal(false) + let copySearchNexts = 0 + let copySearchPreviouses = 0 let copyYanks = 0 let copyYankLines = 0 let copyCopies = 0 @@ -470,6 +480,44 @@ function createHandler( setCopyCol(col) return true }, + copySearchStart(direction) { + copySearchCalls.push(direction) + setCopySearchActive(true) + setCopySearchHighlighted(true) + }, + copySearchAppend(value) { + copySearchAppends.push(value) + return true + }, + copySearchBackspace() { + copySearchBackspaces++ + return true + }, + copySearchSubmit() { + copySearchSubmits++ + setCopySearchActive(false) + return true + }, + copySearchCancel() { + copySearchCancels++ + setCopySearchActive(false) + }, + copySearchClear() { + copySearchClears++ + setCopySearchActive(false) + setCopySearchHighlighted(false) + return true + }, + copySearchActive, + copySearchHighlighted, + copySearchNext() { + copySearchNexts++ + return true + }, + copySearchPrevious() { + copySearchPreviouses++ + return true + }, copyText() { return options?.copy?.texts?.[copyIdx()] ?? options?.copy?.text ?? "alpha beta gamma" }, @@ -510,6 +558,16 @@ function createHandler( copyVisual, copyVisualCalls, copyScrollCalls, + copySearchCalls, + copySearchAppends, + copySearchBackspaces: () => copySearchBackspaces, + copySearchSubmits: () => copySearchSubmits, + copySearchCancels: () => copySearchCancels, + copySearchClears: () => copySearchClears, + copySearchActive, + copySearchHighlighted, + copySearchNexts: () => copySearchNexts, + copySearchPreviouses: () => copySearchPreviouses, copyYanks: () => copyYanks, copyYankLines: () => copyYankLines, copyCopies: () => copyCopies, @@ -7541,6 +7599,129 @@ describe("copy mode", () => { expect(cm.highlights().get("text-part")).toBeUndefined() }) + test("search jumps forward and highlights current match", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + + expect(cm.state().idx).toBe(1) + expect(cm.state().col).toBe(12) + expect(cm.highlights().get("text-part")).toEqual([ + { line: 0, left: 7, right: 11, text: "alpha", kind: "search" }, + { line: 1, left: 12, right: 16, text: "alpha", kind: "search", current: true }, + { line: 2, left: 7, right: 11, text: "alpha", kind: "search" }, + ]) + }) + + test("incremental search keeps the original cursor as its origin", () => { + const cm = createRenderedCopyMode(["abcdef", "abcdef", "abcdef", "abcdef"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("a")).toBe(true) + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchAppend("b")).toBe(true) + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchAppend("c")).toBe(true) + expect(cm.state().idx).toBe(1) + }) + + test("search jumps backward and wraps", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("backward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + + expect(cm.state().idx).toBe(2) + expect(cm.state().col).toBe(7) + }) + + test("search repeat cycles through matches", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + cm.prompt.searchAppend("alpha") + expect(cm.prompt.searchSubmit()).toBe(true) + + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchNext()).toBe(true) + expect(cm.state().idx).toBe(2) + expect(cm.prompt.searchPrevious()).toBe(true) + expect(cm.state().idx).toBe(1) + }) + + test("failed search submit clears stale highlights", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + cm.prompt.searchAppend("alpha") + expect(cm.prompt.searchSubmit()).toBe(true) + expect(cm.highlights().get("text-part")?.length).toBe(3) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("missing")).toBe(false) + expect(cm.prompt.searchSubmit()).toBe(false) + + expect(cm.prompt.searchHighlighted()).toBe(false) + expect(cm.highlights().get("text-part")).toBeUndefined() + }) + + test("failed incremental search restores the original cursor", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + expect(cm.state().idx).toBe(1) + expect(cm.prompt.searchAppend("z")).toBe(false) + + expect(cm.state().idx).toBe(0) + expect(cm.state().col).toBe(7) + expect(cm.prompt.searchSubmit()).toBe(false) + expect(cm.state().idx).toBe(0) + expect(cm.state().col).toBe(7) + }) + + test("erasing incremental search before submit clears prefix highlights", () => { + const cm = createRenderedCopyMode(["sample", "example sample"]) + + cm.prompt.searchStart("forward") + Array.from("sample").forEach((char) => expect(cm.prompt.searchAppend(char)).toBe(true)) + Array.from("sample").forEach(() => cm.prompt.searchBackspace()) + expect(cm.prompt.searchSubmit()).toBe(true) + + expect(cm.prompt.searchHighlighted()).toBe(false) + expect(cm.highlights().get("text-part")).toBeUndefined() + expect(cm.prompt.searchNext()).toBe(false) + }) + + test("cancelled incremental search restores the original cursor", () => { + const cm = createRenderedCopyMode(["alpha", "beta alpha", "alpha"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("alpha")).toBe(true) + expect(cm.state().idx).toBe(1) + cm.prompt.searchCancel() + + expect(cm.state().idx).toBe(0) + expect(cm.state().col).toBe(7) + expect(cm.prompt.searchHighlighted()).toBe(false) + }) + + test("search uses smartcase matching", () => { + const cm = createRenderedCopyMode(["error", "Error"]) + + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("error")).toBe(true) + expect(cm.highlights().get("text-part")?.map((highlight) => highlight.text)).toEqual(["error", "Error"]) + + cm.prompt.searchCancel() + cm.prompt.searchStart("forward") + expect(cm.prompt.searchAppend("Error")).toBe(true) + expect(cm.highlights().get("text-part")).toEqual([ + { line: 1, left: 7, right: 11, text: "Error", kind: "search", current: true }, + ]) + }) + test("word motions use copy row minimum columns", () => { const cm = createRenderedCopyMode(["alpha beta", " gamma delta"]) @@ -7970,6 +8151,68 @@ describe("copy mode", () => { expect(ctx.copyFocusInputs()).toBe(0) }) + test("/ and ? start copy search", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + const slash = createEvent("/") + expect(ctx.handler.handleKey(slash.event)).toBe(true) + expect(slash.prevented()).toBe(true) + ctx.handler.handleKey(createEvent("escape").event) + + const question = createEvent("slash", { shift: true }) + expect(ctx.handler.handleKey(question.event)).toBe(true) + expect(question.prevented()).toBe(true) + + expect(ctx.copySearchCalls).toEqual(["forward", "backward"]) + expect(ctx.state.mode()).toBe("copy") + }) + + test("copy search updates as keys are typed", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + ctx.handler.handleKey(createEvent("/").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("b").event) + ctx.handler.handleKey(createEvent("backspace").event) + ctx.handler.handleKey(createEvent("", { raw: "\x7f" }).event) + ctx.handler.handleKey(createEvent("delete").event) + ctx.handler.handleKey(createEvent("h", { ctrl: true }).event) + ctx.handler.handleKey(createEvent("return").event) + + expect(ctx.copySearchCalls).toEqual(["forward"]) + expect(ctx.copySearchAppends).toEqual(["a", "b"]) + expect(ctx.copySearchBackspaces()).toBe(4) + expect(ctx.copySearchSubmits()).toBe(1) + expect(ctx.copySearchCancels()).toBe(0) + expect(ctx.copySearchActive()).toBe(false) + }) + + test("copy search input is not langmapped", () => { + const ctx = createHandler("abc", { mode: "copy", langmap: { д: "j" } }) + + ctx.handler.handleKey(createEvent("/").event) + ctx.handler.handleKey(createEvent("д").event) + + expect(ctx.copySearchAppends).toEqual(["д"]) + expect(ctx.copyMoves).toEqual([]) + }) + + test("n and N repeat copy search", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + const next = createEvent("n") + expect(ctx.handler.handleKey(next.event)).toBe(true) + expect(next.prevented()).toBe(true) + + const previous = createEvent("n", { shift: true }) + expect(ctx.handler.handleKey(previous.event)).toBe(true) + expect(previous.prevented()).toBe(true) + + expect(ctx.copySearchNexts()).toBe(1) + expect(ctx.copySearchPreviouses()).toBe(1) + expect(ctx.state.mode()).toBe("copy") + }) + test("i from copy mode starts undoable insert session", () => { const ctx = createHandler("ab", { mode: "copy" }) ctx.textarea.cursorOffset = 1 @@ -7995,6 +8238,25 @@ describe("copy mode", () => { expect(ctx.copyExitVisuals()).toBe(0) }) + test("first escape clears copy search highlights and second exits copy mode", () => { + const ctx = createHandler("abc", { mode: "copy" }) + + ctx.handler.handleKey(createEvent("/").event) + ctx.handler.handleKey(createEvent("a").event) + ctx.handler.handleKey(createEvent("return").event) + + const clear = createEvent("escape") + expect(ctx.handler.handleKey(clear.event)).toBe(true) + expect(clear.prevented()).toBe(true) + expect(ctx.copySearchClears()).toBe(1) + expect(ctx.state.mode()).toBe("copy") + + const exit = createEvent("escape") + expect(ctx.handler.handleKey(exit.event)).toBe(true) + expect(exit.prevented()).toBe(true) + expect(ctx.state.mode()).toBe("normal") + }) + test("escape exits visual submode without leaving copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { isVisual: true } }) @@ -8533,6 +8795,31 @@ describe("copy mode", () => { expect(ctx.copyCol()).toBe(6) }) + test("copy mode find target takes precedence over search keys", () => { + const cases = [ + { command: "f", target: "/", text: "abc/def", col: 3 }, + { command: "t", target: "/", text: "abc/def", col: 2 }, + { command: "f", target: "?", text: "abc?def", col: 3 }, + { command: "f", target: "n", text: "banana", col: 2 }, + ] as const + + for (const item of cases) { + const ctx = createHandler("abc", { mode: "copy", copy: { text: item.text, col: 0 } }) + + ctx.handler.handleKey(createEvent(item.command).event) + expect(ctx.state.pending()).toBe(item.command) + + const target = createEvent(item.target) + expect(ctx.handler.handleKey(target.event)).toBe(true) + expect(target.prevented()).toBe(true) + expect(ctx.copyCol()).toBe(item.col) + expect(ctx.state.pending()).toBe("") + expect(ctx.copySearchCalls).toEqual([]) + expect(ctx.copySearchNexts()).toBe(0) + expect(ctx.copySearchPreviouses()).toBe(0) + } + }) + test("copy mode ctrl scroll keys still scroll", () => { const ctx = createHandler("abc", { mode: "copy" }) const keys: Array<[string, VimScroll]> = [