diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..742e749874 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + +### Added + +- Expanded optional read-only Vim mode in desktop Monaco source editors toward `vim.tiny` navigation parity. + - Toggle with the visible `Vim` control or the command palette's **Toggle Vim mode** command. + - Supports `Normal`, `Insert`, and `Visual` modes, including character, line, and block selection. + - Supports core motions, marks, jump history, viewport commands, `/` and `?` searches, `n`, `Shift+N`, `*`, `#`, and repeat counts such as `10j`. + - Mutation commands, registers, clipboard, Ex commands, undo, and file saves remain unavailable because the editor is read-only. diff --git a/README.md b/README.md index b9198884b1..90f408cc1e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ For latest version, see the [Releases page](https://github.com/JetBrains/thinkra - **An IDE around the agent.** ThinkRail comes with Monaco editor tabs, a file tree integrated with git to show code changes as they happen, terminals, and document support. Rendered Markdown previews have a heading outline that follows the section you're reading; hide it or move it to either side. The pane you're typing in carries an accent line, a frame briefly lights the pane the keyboard takes you to, and VS Code-style keys move you around: `F6` steps between panes, `⌘L` jumps to the chat input, `⌘0` to Projects, `⌘1`–`⌘9` to an editor group, and `` ⌃` `` to the terminal; `⌘\` splits the editor and `⌃⌘←` / `⌃⌘→` move a tab between groups. `⌘?` opens a filterable cheatsheet of every shortcut, the ones for the pane you're in first (a Mac browser keeps `⌘?` for its Help menu, so use the keyboard icon at the bottom of the left tool rail or the palette there); tooltips show a button's shortcut, and Settings → Keyboard lists them all too. +- **Read-only Vim mode.** In a desktop Monaco source editor, turn Vim on with the `Vim` control or the command palette's **Toggle Vim mode** command. `Normal` supports `h`/`j`/`k`/`l`, `w`/`b`/`e`, `0`/`^`/`$`, `gg`/`G`, `f`/`F`/`t`/`T`, `%`, `H`/`M`/`L`, `zt`/`zz`/`zb`, marks, jump history, and repeat counts such as `10j`. `Visual` supports character (`v`), line (`V`), block (`Ctrl+V`), and `o`/`O` endpoint changes; `Insert` remains a read-only mode. Press `/` to search forward or `?` backward, `n` for the next match, `Shift+N` for the previous one, and `*`/`#` for the word under the cursor. Mutation commands, registers, clipboard, Ex commands, undo, and file saves remain unavailable. + +- **Read-only Vim mode.** In a desktop Monaco source editor, turn Vim on with the `Vim` control or the command palette's **Toggle Vim mode** command. `Normal` supports `h`/`j`/`k`/`l`, `w`/`b`/`e`, `0`/`^`/`$`, `gg`/`G`, `f`/`F`/`t`/`T`, `%`, `H`/`M`/`L`, `zt`/`zz`/`zb`, marks, jump history, and repeat counts such as `10j`. `Visual` supports character (`v`), line (`V`), block (`Ctrl+V`), and `o`/`O` endpoint changes; `Insert` remains a read-only mode. Press `/` to search forward or `?` backward, `n` for the next match, `Shift+N` for the previous one, and `*`/`#` for the word under the cursor. Mutation commands, registers, clipboard, Ex commands, undo, and file saves remain unavailable. + - **Powerful chat features.** You can open several concurrent work sessions per workspace, each with its own model. Send a prompt, or queue it as a follow-up, delegate to subagents, and work together on a shared plan: the agent updates it, and you review and edit. - **Find anything from one box.** `⌘K` (or a double tap of Shift) opens a palette that finds chats and the messages inside them, workspaces and projects, files in the current workspace, specs, and plans, with a preview pane. It also runs the app's commands, including the active tab's layout commands (split, move to another group or pane, new or remove group, close), and opens Settings sections, and with nothing typed it lists the chats waiting for you. Type a task and press `⌘↵` to open Create workspace with it as the prompt. Press `⌘⇧F` to find text inside the workspace's files as you type: matches are grouped by file, with match-case and whole-word toggles and a code preview, and `↵` opens the file at that line. diff --git a/apps/web/src/shell/SPEC.md b/apps/web/src/shell/SPEC.md index c9b0b2d98f..41e461c0dc 100644 --- a/apps/web/src/shell/SPEC.md +++ b/apps/web/src/shell/SPEC.md @@ -206,8 +206,8 @@ transition. ChatView's `/name` command is the independent keyboard entry point t - `Mod+B` toggles the left side, restoring local group/tab attention or an eligible singleton tool; - `Mod+J` does the same for the right side; - `Mod+Shift+J` toggles bottom, restoring local bottom attention, a bottom-targeted singleton, or the terminal creation surface; -- `Mod+N`, and its alias `Mod+Alt+N`, open `NewWorkspaceDialog` for the context project (the active workspace's project, else the selected project). The shell owns this one keyboard-opened instance as local state — it is not store state and not the `ProjectTree`/`WelcomePanel` instances, which keep their own return-focus and prompt-seeding semantics. `Mod+N` is the canonical chord and the one shortcut chrome advertises first; the alias exists because browsers reserve Cmd/Ctrl+N for a new window and never deliver it to the page, while the desktop webview does. -- `Mod+K`, double Shift and the topbar search button open the search palette ([[submodule-web-search]]). `SearchPaletteHost` mounts it only while `searchOpen` is true and composes what it runs: `paletteCatalog.ts` builds the command list for the current context (a command exists only where it applies; every chord-bound action shows its shortcut and runs the very function its chord runs — the side/bottom toggles and history open live in `chordActions.ts`, which `useGlobalHotkeys` binds too) and one row per Settings section, each opening its section (the palette never flips a setting in place). `SearchPaletteHost` supplies `renderFilePreview` — a `ResourcePreview` from `@thinkrail/resource-renderers` keyed by workspace + path, with a section's heading line as its `reveal`, under `AppResourceHost`'s **`PALETTE_PREVIEW_HOST`** (built once beside the app's `HOST`): its `readFile` and `bytesUrl` carry the palette's `maxBytes` (`FILE_TOO_LARGE` becomes the "too large to preview" message), it refuses a read while the host is unreachable, and its change subscription stays silent until this connection's welcome and then refreshes once — so the palette's file and spec previews are the preview tab's renderer, bounded. The host search context names the active workspace and the context project; the host resolves the root (Project Home searches the project's Default folder). `useSearchPalette` turns store state into the props-driven palette's items: work items from `buildWorkItems` over the normalized session snapshot, so chats in workspaces this client never opened appear, named by their state record's title; **Waiting for you** is those chats that need the user (`waitingItems`); the context project and active workspace are ranking boosts. The palette's Start work row and `Mod+↵` call `PaletteStart.openDialog(text)`, which opens the keyboard-owned `NewWorkspaceDialog` instance (the `Mod+N` one) with the text as `initialPrompt` — or, when that dialog or the store's Edit & retry dialog is already open (an attention notification can open the palette over it), keeps it as it is, its draft and any creation in flight untouched, and returns `false` so the palette returns focus to it; the palette's New workspace row keeps an open dialog the same way, but does not hand focus back to it. The dialog is the only start path, so the palette never creates a workspace or sends a prompt itself. The `#` scope's Ask the agent row and `Alt+↵` call `PaletteAsk.ask(text)` instead, which starts a new chat (`startChatInTab`) in the active workspace — on Project Home in the context project's Default, entered through `enterDefaultWorkspace` under a `paletteNavigator.beginPick()` guard like Work in project folder, so a later pick or navigation while the list loads keeps the user where they went and drops the ask — with the question and the spec-citing note as its first prompt. +- `Mod+N`, and its alias `Mod+Alt+N`, open `NewWorkspaceDialog` for the context project (the active workspace's project, else the selected project). The shell owns this one keyboard-opened instance as local state — it is not store state and not the `ProjectTree`/`WelcomePanel` instances, which keep their own return-focus and prompt-seeding semantics. `Mod+N` is the canonical chord and the one shortcut chrome advertises first; the alias exists because browsers reserve Cmd/Ctrl+N for a new window and never deliver it to the page, while the desktop webview does. This is the only chord that accepts Alt. +- `Mod+K`, double Shift and the topbar search button open the search palette ([[submodule-web-search]]). `SearchPaletteHost` mounts it only while `searchOpen` is true and composes what it runs: `paletteCatalog.ts` builds the command list for the current context (a command exists only where it applies; every chord-bound action shows its shortcut and runs the very function its chord runs — the side/bottom toggles and history open live in `chordActions.ts`, which `useGlobalHotkeys` binds too) and one row per Settings section, each opening its section (the palette never flips a setting in place). The view group also exposes `view.toggleVim`, which delegates to the focused desktop Monaco source editor through the renderer's public hook and is a no-op without one. `SearchPaletteHost` supplies `renderFilePreview` — a `ResourcePreview` from `@thinkrail/resource-renderers` keyed by workspace + path, with a section's heading line as its `reveal`, under `AppResourceHost`'s **`PALETTE_PREVIEW_HOST`** (built once beside the app's `HOST`): its `readFile` and `bytesUrl` carry the palette's `maxBytes` (`FILE_TOO_LARGE` becomes the "too large to preview" message), it refuses a read while the host is unreachable, and its change subscription stays silent until this connection's welcome and then refreshes once — so the palette's file and spec previews are the preview tab's renderer, bounded. The host search context names the active workspace and the context project; the host resolves the root (Project Home searches the project's Default folder). `useSearchPalette` turns store state into the props-driven palette's items: work items from `buildWorkItems` over the normalized session snapshot, so chats in workspaces this client never opened appear, named by their state record's title; **Waiting for you** is those chats that need the user (`waitingItems`); the context project and active workspace are ranking boosts. The palette's Start work row and `Mod+↵` call `PaletteStart.openDialog(text)`, which opens the keyboard-owned `NewWorkspaceDialog` instance (the `Mod+N` one) with the text as `initialPrompt` — or, when that dialog or the store's Edit & retry dialog is already open (an attention notification can open the palette over it), keeps it as it is, its draft and any creation in flight untouched, and returns `false` so the palette returns focus to it; the palette's New workspace row keeps an open dialog the same way, but does not hand focus back to it. The dialog is the only start path, so the palette never creates a workspace or sends a prompt itself. The `#` scope's Ask the agent row and `Alt+↵` call `PaletteAsk.ask(text)` instead, which starts a new chat (`startChatInTab`) in the active workspace — on Project Home in the context project's Default, entered through `enterDefaultWorkspace` under a `paletteNavigator.beginPick()` guard like Work in project folder, so a later pick or navigation while the list loads keeps the user where they went and drops the ask — with the question and the spec-citing note as its first prompt. - While the palette is open, `paletteBackfill.ts` makes collapsed projects' workspaces searchable: it lists, without diff stats, each open project whose workspace list this client never loaded or that lacks a workspace the snapshot reports (a peer created it while this client was away), and merges the reply in the host's order without replacing rows already held, so no worktree is diffed for it. It asks nothing while disconnected or before the welcome, keeps four requests in flight across openings, drops a reply from an earlier connection, and re-reads its queue when the connection, the welcome, the open projects or the reported workspaces change. No palette request names a chat; against an older host an unvisited chat reads as its first prompt. - A pick navigates through `activateWorkspaceFromRoute` / `selectProject`. A chat whose workspace row is missing first loads its project's list (`paletteNavigation.ts`, the same merge) and opens only if it is still the latest pick — a project pick counts — and no navigation happened meanwhile: a change of the active workspace, the selected project or a counted tab switch (the navigation tick) drops it even when it comes back (A → B → A), because the pick watches for the change (the store's `isNavigationChange`) as route restore and `chat`'s `openWorkspace` do; a tab an earlier route target opens on its own does not count. It opens only if the workspace is in that list — a stale hit or a failed list tells the user why instead of activating an id no row backs, and a chat whose deletion did not finish (`deletionPending`, held or just loaded) is refused the same way, as its sidebar row is disabled. A workspace or chat row that opened then calls `requestComposerFocus`, so the attended chat's input takes focus — for a chat row, only that chat's input. A destination showing a non-chat tab keeps its tab and focus, and the workbench drops the request then, so no chat can take it later. Host hits, project rows, the sidebar and the topbar switcher send no request: a file, spec or plan hit wants its own surface, and the sidebar keeps keyboard focus on its row. **Work in project folder** hands the same guard (`beginPick`) to `enterDefaultWorkspace` as its `stillWanted`, so a late `workspace.list` reply does not pull the user back to the Default workspace. A host hit opens through `openHostHit.ts` — a file as a tab, a spec in the Specs tool, a section there scrolled to its heading line (`requestSpecView` with the line), a transcript at its message, a plan with its chat and the Plan tool — each only after the navigator resolved `true`. - Open state lives in [[submodule-web-store]] (`searchOpen` + `openSearch` / `closeSearch`) so an aggregate attention notification from [[submodule-web-notifications]] can open the palette; the host keys it on `searchSession`, so that click resets an already-open palette to its Waiting view. It opens over the first-run wizard as the topbar Settings button does, and its project commands end the wizard the way the wizard's own project cards do. The open palette is the topmost chord layer and owns its keys (`Mod+K` and double Shift close it, Escape in its own order), so the global `Mod+K` is suppressed behind it like behind any modal; plain `Ctrl+R` is swallowed, not acted upon, while it is open (it reloads browsers on Windows/Linux). The global chord accepts neither Shift nor Alt and keeps winning over Monaco's `⌘K` chord prefix. diff --git a/apps/web/src/shell/paletteCatalog.test.ts b/apps/web/src/shell/paletteCatalog.test.ts index 6f654f0dc2..3e77d9d366 100644 --- a/apps/web/src/shell/paletteCatalog.test.ts +++ b/apps/web/src/shell/paletteCatalog.test.ts @@ -104,6 +104,7 @@ function deps(overrides: Partial = {}): PaletteCatalogDeps { toggleLeft: noop, toggleRight: noop, toggleBottom: noop, + toggleVim: noop, showTool: noop, showFiles: noop, focusRegion: noop, @@ -141,6 +142,10 @@ describe("palette project and Settings commands", () => { expect(row("project.openPath", deps())?.title).toBe("Open a path on the host…"); }); + test("Vim mode is available as a command-palette action", () => { + expect(row("view.toggleVim", deps())?.title).toBe("Toggle Vim mode"); + }); + test("Settings shows the native shortcut only where the native menu owns one", () => { expect(row("settings.open", deps())?.shortcut).toBeUndefined(); expect(row("settings.open", deps({ nativeShortcuts: true }))?.shortcut).toEqual({ diff --git a/apps/web/src/shell/paletteCatalog.ts b/apps/web/src/shell/paletteCatalog.ts index c3297770bc..6ddf68d6ea 100644 --- a/apps/web/src/shell/paletteCatalog.ts +++ b/apps/web/src/shell/paletteCatalog.ts @@ -110,6 +110,7 @@ export interface PaletteCatalogActions { readonly toggleLeft: () => void; readonly toggleRight: () => void; readonly toggleBottom: () => void; + readonly toggleVim: () => void; readonly showTool: (workspaceId: string, tool: PaletteTool) => void; readonly showFiles: (mode: "all" | "changed") => void; readonly focusRegion: (delta: 1 | -1) => void; @@ -375,6 +376,14 @@ function viewCommands(deps: PaletteCatalogDeps): Command[] { run: () => actions.showFiles("changed"), }); } + commands.push({ + id: "view.toggleVim", + title: t("commands.view.toggleVim"), + group: "view", + icon: RiKeyboardLine, + keywords: ["vim", "vi", "normal", "insert", "visual", "editor"], + run: actions.toggleVim, + }); commands.push({ id: "view.toggleLeft", title: t("commands.view.toggleLeft"), diff --git a/apps/web/src/shell/useSearchPalette.ts b/apps/web/src/shell/useSearchPalette.ts index 57dbab0688..e27ab17f42 100644 --- a/apps/web/src/shell/useSearchPalette.ts +++ b/apps/web/src/shell/useSearchPalette.ts @@ -1,4 +1,5 @@ import { t as globalT, useT } from "@thinkrail/locales/react"; +import { toggleActiveVimMode } from "@thinkrail/resource-renderers"; import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { openFindInFiles } from "../findInFiles"; @@ -277,6 +278,7 @@ export function useSearchPalette(options: SearchPaletteOptions): SearchPalettePr toggleLeft: () => toggleLeftSide(options.toggleProjectsHome), toggleRight: toggleRightSide, toggleBottom: toggleBottomPanel, + toggleVim: toggleActiveVimMode, showTool: (workspaceId, tool) => store().requestToolView(workspaceId, tool), showFiles, focusRegion: focusAdjacentRegion, diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 4f5b35892b..e1ba3fd9df 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -234,6 +234,37 @@ async function firstVisibleLine(page: Page) { }); } +test("read-only Vim mode switches modes and opens directional search", async ({ page }) => { + await openFixtureProject(page); + const workspace = await createWorkspaceViaDialog(page); + writeFileSync(join(workspace.worktreePath, "vim.ts"), longSource("vim")); + await revealWorkbenchTool(page, "files"); + await page.getByTestId("file-node").filter({ hasText: "vim.ts" }).dblclick(); + await expect(visibleEditor(page)).toContainText("vim0"); + + const toggle = page.getByTestId("vim-mode-toggle"); + await expect(toggle).toContainText("Vim: Off"); + await toggle.click(); + await expect(toggle).toContainText("Vim: Normal"); + + await visibleEditor(page).click(); + await page.keyboard.press("i"); + await expect(toggle).toContainText("Vim: Insert"); + await page.keyboard.press("Escape"); + await page.keyboard.press("v"); + await expect(toggle).toContainText("Vim: Visual"); + await page.keyboard.press("Escape"); + await expect(toggle).toContainText("Vim: Normal"); + + await page.keyboard.press("/"); + const search = page.getByTestId("vim-search-input"); + await expect(search).toBeFocused(); + await search.fill("vim10"); + await search.press("Enter"); + await expect(search).toHaveCount(0); + await expect(visibleEditor(page)).toContainText("vim10"); +}); + test("Monaco restores scroll across a tab switch", async ({ page }) => { await openFixtureProject(page); const workspace = await createWorkspaceViaDialog(page); diff --git a/goal-and-requirements.md b/goal-and-requirements.md index ae7bbc40a9..6042b6a262 100644 --- a/goal-and-requirements.md +++ b/goal-and-requirements.md @@ -94,7 +94,10 @@ What ThinkRail does, at product level; the linked spec owns the detail. and bottom tool panels, kept local to each window ([[submodule-web-shell-layout]]). The region that holds the keyboard carries an accent line, a keyboard move briefly frames the region it lands in, and VS Code-style chords move between regions, into the chat input, Projects, Files and the terminal, from - one keybinding table that the palette also shows ([[submodule-web-keybindings]]). + one keybinding table that the palette also shows ([[submodule-web-keybindings]]). Desktop source files also offer an optional, + read-only Vim interaction layer with normal, insert and visual modes, directional search and repeat + counts; its visible toggle and palette command never make the file editable + ([[module-resource-renderers]], [[submodule-web-shell]]). - **Chats** — concurrent `pi` sessions per workspace, each with its own model, pi-reported token/cost, steering and follow-ups mid-run, skill and prompt-template autocomplete, and history search ([[submodule-web-chat]], [[submodule-server-agent]]). diff --git a/packages/locales/en.json b/packages/locales/en.json index f132aae974..07231a5f5b 100644 --- a/packages/locales/en.json +++ b/packages/locales/en.json @@ -507,6 +507,7 @@ "commands.view.toggleBottom": "Toggle bottom panel", "commands.view.toggleLeft": "Toggle Projects panel", "commands.view.toggleRight": "Toggle right panel", + "commands.view.toggleVim": "Toggle Vim mode", "commands.view.zoomIn": "Zoom in", "commands.view.zoomOut": "Zoom out", "commands.view.zoomReset": "Reset zoom", @@ -3143,6 +3144,13 @@ "viewer.svg.diffTitle": "Vector diff of {{path}}", "viewer.svg.flexibleSize": "Flexible size", "viewer.svg.previewTitle": "Vector preview of {{path}}", + "viewer.vim.mode.insert": "Vim: Insert", + "viewer.vim.mode.normal": "Vim: Normal", + "viewer.vim.mode.visual": "Vim: Visual", + "viewer.vim.off": "Vim: Off", + "viewer.vim.search": "Vim search", + "viewer.vim.searchPlaceholder": "Search pattern", + "viewer.vim.toggle": "Toggle Vim mode", "viewer.visualDiff.commentOnNew": "Comment on new", "viewer.visualDiff.commentOnOld": "Comment on old", "viewer.visualDiff.difference": "Difference", diff --git a/packages/resource-renderers/SPEC.md b/packages/resource-renderers/SPEC.md index 0c49983d21..63ae714b1e 100644 --- a/packages/resource-renderers/SPEC.md +++ b/packages/resource-renderers/SPEC.md @@ -39,6 +39,8 @@ One barrel, `@thinkrail/resource-renderers` (`src/index.ts`): `ResourcePreferences`, `ResourceScope`, `ResourceFileReviewProps`, `ResourceReviewSendProps`, `ResourceMarkdown`, `ResourceCodeHost`, `ResourceCodeLanguage`, `ResourceMarkdownProps`, `MarkdownRehypePlugins` types; +- `toggleActiveVimMode` — a narrow host hook for the currently focused desktop Monaco source editor. Vim is + renderer-local, off by default, read-only, and never reaches the mobile Pierre view or file persistence; - `canonicalWorkspacePath` — the package's copy of the app's workspace-relative path identity (`lib`'s `projectRelativePath`: POSIX separators, no `.` or empty segments, inner `..` resolved; `apps/web/src/panels/pathIdentity.test.ts` keeps the two equal); `describeResource` and `ResourcePreview` @@ -385,6 +387,14 @@ prose styles target is unchanged; the boolean attributes the sanitizer lets thro standalone checkbox's `checked`/`disabled`) are mapped to `true` because React drops an empty-string boolean. +The desktop `thinkrail/code` Monaco view optionally exposes read-only Vim interaction. Its visible +control and the host hook toggle the local mode; `normal`, `insert`, and `visual` are UI/navigation modes, +not an editing or save path. The supported read-only subset follows the high-value core exposed by compact +Linux Vim builds: word/line/character/bracket motions, `gg`/`G`, viewport motions, marks and jump history, +character/line/block Visual selection, search and repeat counts. Mutation operators, registers, clipboard, +Ex commands, undo/redo and macros remain outside the contract. Search and count handling stay inside the +Monaco view so the renderer package owns the keyboard contract without importing the web shell. + `thinkrail/image` renders host-backed byte URLs with fit, natural-size, button/wheel zoom, intrinsic dimensions, and byte size, always on the `.media-backdrop` transparency checkerboard (the view's image and both diff frames; the difference blend keeps a flat canvas so the checker cannot leak into the diff --git a/packages/resource-renderers/src/index.ts b/packages/resource-renderers/src/index.ts index 241243c203..aef9ec6d42 100644 --- a/packages/resource-renderers/src/index.ts +++ b/packages/resource-renderers/src/index.ts @@ -71,3 +71,4 @@ export { } from "./support/rendererMount"; export { UnplacedReviewStrip } from "./support/UnplacedReviewStrip"; export { useScrollViewState, viewStateScrollTop } from "./support/useScrollViewState"; +export { toggleActiveVimMode } from "./support/vimMode"; diff --git a/packages/resource-renderers/src/support/MonacoEditor.tsx b/packages/resource-renderers/src/support/MonacoEditor.tsx index 82051a4b4f..7fc5652e37 100644 --- a/packages/resource-renderers/src/support/MonacoEditor.tsx +++ b/packages/resource-renderers/src/support/MonacoEditor.tsx @@ -1,5 +1,8 @@ +import { RiKeyboardLine } from "@remixicon/react"; import { useT } from "@thinkrail/locales/react"; +import { Button } from "@thinkrail/ui/button"; import { onFontsChange } from "@thinkrail/ui/fonts"; +import { isImeComposing } from "@thinkrail/ui/keyboard"; import type { editor } from "monaco-editor/editor/editor.api.js"; import { useEffect, @@ -32,6 +35,12 @@ import { type MonacoReviewZoneState, type ReviewCommenting, } from "./reviewWidgets"; +import { + createVimController, + initialVimState, + registerActiveVimToggle, + type VimState, +} from "./vimMode"; function focusLine(review: SurfaceReview): number | null { const range = review.focus?.anchor.selectors.find((selector) => selector.kind === "lineRange"); @@ -86,6 +95,11 @@ export default function MonacoEditor({ threads: [], composer: null, }); + const [vimState, setVimState] = useState(initialVimState); + const [searchQuery, setSearchQuery] = useState(""); + const vimControllerRef = useRef | null>(null); + const activeVimToggleRef = useRef | null>(null); + const searchInputRef = useRef(null); useLayoutEffect(() => { latestRef.current = { @@ -114,6 +128,11 @@ export default function MonacoEditor({ options: mount.options, }; const menuIcons = decorateEditorContextMenus(codeEditor); + const vimController = createVimController(codeEditor, setVimState); + const activeVimToggle = registerActiveVimToggle(vimController.toggle); + vimControllerRef.current = vimController; + activeVimToggleRef.current = activeVimToggle; + const focusListener = codeEditor.onDidFocusEditorText(activeVimToggle.activate); if (isEditorViewState(mount.viewState)) codeEditor.restoreViewState(mount.viewState); const threads = mount.review ? attachReviewThreads(codeEditor, setReviewZones) : null; const commenting = threads @@ -124,6 +143,11 @@ export default function MonacoEditor({ return () => { const saved = codeEditor.saveViewState(); if (saved) latestRef.current.onViewState?.(saved); + focusListener.dispose(); + activeVimToggle.dispose(); + vimController.dispose(); + vimControllerRef.current = null; + activeVimToggleRef.current = null; menuIcons.dispose(); commenting?.dispose(); threads?.dispose(); @@ -160,8 +184,22 @@ export default function MonacoEditor({ }, [options]); useEffect(() => remeasureEditorFonts(font), [font]); + useEffect(() => { + if (!vimState.searchOpen) return; + setSearchQuery(""); + searchInputRef.current?.focus(); + }, [vimState.searchOpen]); const closeComposer = () => threadsRef.current?.closeComposer(); + const vimModeLabel = + vimState.mode === "normal" + ? t("viewer.vim.mode.normal") + : vimState.mode === "insert" + ? t("viewer.vim.mode.insert") + : t("viewer.vim.mode.visual"); + const submitSearch = () => vimControllerRef.current?.submitSearch(searchQuery); + const cancelSearch = () => vimControllerRef.current?.cancelSearch(); + const layoutReviewZones = () => threadsRef.current?.layout(); useLayoutEffect(() => { @@ -204,14 +242,57 @@ export default function MonacoEditor({ }, [revealLine, revealColumn, revealLength, revealTick]); return ( - <> -
+
activeVimToggleRef.current?.activate()} + > +
+ + {vimState.searchOpen ? ( +
+ + setSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (isImeComposing(event.nativeEvent)) return; + if (event.key === "Enter") { + event.preventDefault(); + submitSearch(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelSearch(); + } + }} + aria-label={t("viewer.vim.search")} + data-testid="vim-search-input" + placeholder={t("viewer.vim.searchPlaceholder")} + className="h-20 w-[256px] bg-transparent text-text-default outline-none focus-visible:focus-ring tr-code-text" + /> +
+ ) : null} - +
); } diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts new file mode 100644 index 0000000000..cdc1012668 --- /dev/null +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, test } from "bun:test"; +import type { + editor, + IKeyboardEvent, + IPosition, + IRange, + ISelection, +} from "monaco-editor/editor/editor.api.js"; +import { + createVimController, + initialVimState, + registerActiveVimToggle, + toggleActiveVimMode, + type VimState, +} from "./vimMode"; + +type KeyListener = (event: IKeyboardEvent) => void; + +function position(lineNumber: number, column: number): IPosition { + return { lineNumber, column }; +} + +function makeEditor(lines: readonly string[]) { + let current = position(1, 1); + let selection: IRange | null = null; + let scrollTop = 0; + let listener: KeyListener | null = null; + let contentListener: (() => void) | null = null; + const text = lines.join("\n"); + const offsets: number[] = []; + for (const index of lines.keys()) { + const previous = offsets[index - 1] ?? 0; + const previousLine = lines[index - 1] ?? ""; + offsets.push(index === 0 ? 0 : previous + previousLine.length + 1); + } + const model = { + getValue: () => text, + getOffsetAt: (value: IPosition) => (offsets[value.lineNumber - 1] ?? 0) + value.column - 1, + getPositionAt: (offset: number) => { + const lineNumber = offsets.findLastIndex((start) => start <= offset) + 1; + return position(lineNumber, offset - (offsets[lineNumber - 1] ?? 0) + 1); + }, + getLineCount: () => lines.length, + getLineContent: (lineNumber: number) => lines[lineNumber - 1] ?? "", + getLineMaxColumn: (lineNumber: number) => (lines[lineNumber - 1] ?? "").length + 1, + getWordAtPosition: (value: IPosition) => { + const line = lines[value.lineNumber - 1] ?? ""; + const index = value.column - 1; + const match = /[A-Za-z0-9_]+/gu; + for (const item of line.matchAll(match)) { + const start = item.index ?? 0; + if (index >= start && index <= start + item[0].length) { + return { word: item[0], startColumn: start + 1, endColumn: start + item[0].length + 1 }; + } + } + return null; + }, + findMatches: (query: string) => { + const matches: Array<{ range: IRange }> = []; + for (const [lineIndex, line] of lines.entries()) { + let from = 0; + while (from <= line.length) { + const found = line.indexOf(query, from); + if (found < 0) break; + matches.push({ + range: { + startLineNumber: lineIndex + 1, + startColumn: found + 1, + endLineNumber: lineIndex + 1, + endColumn: found + query.length + 1, + getStartPosition: () => position(lineIndex + 1, found + 1), + getEndPosition: () => position(lineIndex + 1, found + query.length + 1), + } as IRange, + }); + from = found + Math.max(query.length, 1); + } + } + return matches; + }, + findNextMatch: (query: string, start: IPosition) => + model + .findMatches(query) + .find( + (candidate) => + candidate.range.startLineNumber * 1_000_000 + candidate.range.startColumn > + start.lineNumber * 1_000_000 + start.column, + ), + findPreviousMatch: (query: string, start: IPosition) => + model + .findMatches(query) + .findLast( + (candidate) => + candidate.range.startLineNumber * 1_000_000 + candidate.range.startColumn < + start.lineNumber * 1_000_000 + start.column, + ), + }; + const editor = { + onKeyDown: (next: KeyListener) => { + listener = next; + return { dispose: () => (listener = null) }; + }, + onDidChangeModelContent: (next: () => void) => { + contentListener = next; + return { dispose: () => (contentListener = null) }; + }, + getModel: () => model, + getPosition: () => current, + getSelection: () => { + const currentSelection = selection; + if (!currentSelection) return null; + return { + ...currentSelection, + getStartPosition: () => + position(currentSelection.startLineNumber, currentSelection.startColumn), + getEndPosition: () => position(currentSelection.endLineNumber, currentSelection.endColumn), + } as unknown as ISelection; + }, + setPosition: (next: IPosition) => { + current = next; + selection = null; + }, + setSelection: (next: IRange) => { + selection = next; + current = position(next.endLineNumber, next.endColumn); + }, + setSelections: (next: readonly ISelection[]) => { + const last = next[next.length - 1]; + if (!last) return; + selection = last as unknown as IRange; + current = position(last.positionLineNumber, last.positionColumn); + }, + getVisibleRanges: () => [ + { + startLineNumber: 1, + startColumn: 1, + endLineNumber: Math.min(lines.length, 5), + endColumn: 1, + } as IRange, + ], + getTopForLineNumber: (lineNumber: number) => (lineNumber - 1) * 20, + getLayoutInfo: () => ({ height: 100 }), + setScrollTop: (next: number) => { + scrollTop = next; + }, + revealPositionInCenter: () => {}, + revealRangeInCenter: () => {}, + revealLineNearTop: () => {}, + revealLineInCenter: () => {}, + focus: () => {}, + }; + return { + editor: editor as unknown as editor.IStandaloneCodeEditor, + get position() { + return current; + }, + get selection() { + return selection; + }, + get scrollTop() { + return scrollTop; + }, + emit(key: string, shiftKey = false, ctrlKey = false) { + const browserEvent = { + key, + shiftKey, + ctrlKey, + metaKey: false, + altKey: false, + isComposing: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as KeyboardEvent; + listener?.({ browserEvent } as unknown as IKeyboardEvent); + }, + modelChanged() { + contentListener?.(); + }, + }; +} + +describe("Vim mode", () => { + test("starts disabled and cycles normal, insert and visual modes", () => { + const fake = makeEditor(["one", "two"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + + expect(state).toEqual(initialVimState()); + controller.toggle(); + expect(state).toMatchObject({ enabled: true, mode: "normal" }); + fake.emit("i"); + expect(state.mode).toBe("insert"); + fake.emit("Escape"); + fake.emit("v"); + expect(state.mode).toBe("visual"); + fake.emit("Escape"); + expect(state.mode).toBe("normal"); + controller.dispose(); + }); + + test("repeats line movement with a count", () => { + const fake = makeEditor(Array.from({ length: 20 }, (_, index) => `line ${index}`)); + const controller = createVimController(fake.editor, () => {}); + controller.toggle(); + + for (const key of ["1", "0", "j"]) fake.emit(key); + expect(fake.position).toEqual(position(11, 1)); + controller.dispose(); + }); + + test("searches forward and backward with n and Shift+N", () => { + const fake = makeEditor(["alpha", "beta", "beta"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + controller.toggle(); + fake.emit("/"); + expect(state.searchOpen).toBe(true); + controller.submitSearch("beta"); + expect(fake.position).toEqual(position(2, 5)); + fake.emit("n"); + expect(fake.position).toEqual(position(3, 5)); + fake.emit("N", true); + expect(fake.position).toEqual(position(2, 5)); + controller.dispose(); + }); + + test("supports Vim tiny navigation, Visual variants, marks and viewport jumps", () => { + const fake = makeEditor([" alpha beta", "(gamma)", "delta"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + controller.toggle(); + + fake.emit("^"); + expect(fake.position).toEqual(position(1, 3)); + fake.emit("e"); + expect(fake.position).toEqual(position(1, 7)); + fake.emit("f"); + fake.emit("b"); + expect(fake.position).toEqual(position(1, 9)); + const counted = makeEditor(["a b c d"]); + const countedController = createVimController(counted.editor, () => {}); + countedController.toggle(); + counted.emit("2"); + counted.emit("t"); + counted.emit(" "); + expect(counted.position).toEqual(position(1, 3)); + countedController.dispose(); + fake.emit("G"); + expect(fake.position.lineNumber).toBe(3); + const viewport = makeEditor(Array.from({ length: 20 }, (_, index) => `line ${index}`)); + const viewportController = createVimController(viewport.editor, () => {}); + viewportController.toggle(); + viewport.emit("G"); + viewport.emit("z"); + viewport.emit("b"); + expect(viewport.scrollTop).toBeGreaterThan(0); + viewportController.dispose(); + fake.emit("g"); + fake.emit("g"); + expect(fake.position.lineNumber).toBe(1); + + fake.emit("m"); + fake.emit("a"); + fake.emit("G"); + fake.emit("`"); + fake.emit("a"); + expect(fake.position.lineNumber).toBe(1); + + fake.emit("V"); + expect(state.visualMode).toBe("line"); + fake.emit("a", false, true); + expect(state.mode).toBe("visual"); + fake.emit("Escape"); + fake.emit("v"); + expect(state.visualMode).toBe("character"); + expect(fake.selection).not.toBeNull(); + fake.emit("Escape"); + fake.emit("v"); + fake.emit("V", true); + expect(state.visualMode).toBe("line"); + fake.modelChanged(); + expect(state.visualMode).toBeNull(); + expect(state.mode).toBe("normal"); + fake.emit("Escape"); + controller.dispose(); + }); + + test("palette toggle reaches active editor", () => { + const fake = makeEditor(["one"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + const registration = registerActiveVimToggle(controller.toggle); + registration.activate(); + toggleActiveVimMode(); + expect(state.enabled).toBe(true); + registration.dispose(); + controller.dispose(); + }); +}); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts new file mode 100644 index 0000000000..5422885a26 --- /dev/null +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -0,0 +1,775 @@ +import type { + editor, + IKeyboardEvent, + IPosition, + ISelection, +} from "monaco-editor/editor/editor.api.js"; + +export type VimMode = "normal" | "insert" | "visual"; +export type VimVisualMode = "character" | "line" | "block"; +export type VimSearchDirection = "forward" | "backward"; + +export interface VimState { + enabled: boolean; + mode: VimMode; + visualMode: VimVisualMode | null; + searchOpen: boolean; + searchDirection: VimSearchDirection; +} + +const INITIAL_STATE: VimState = { + enabled: false, + mode: "normal", + visualMode: null, + searchOpen: false, + searchDirection: "forward", +}; + +const BRACKET_PAIRS: Readonly> = { + "(": ")", + ")": "(", + "[": "]", + "]": "[", + "{": "}", + "}": "{", +}; + +let activeOwner: symbol | null = null; +let activeToggle: (() => void) | null = null; + +export function registerActiveVimToggle(toggle: () => void): { + activate: () => void; + dispose: () => void; +} { + const owner = Symbol("vim-editor"); + return { + activate: () => { + activeOwner = owner; + activeToggle = toggle; + }, + dispose: () => { + if (activeOwner !== owner) return; + activeOwner = null; + activeToggle = null; + }, + }; +} + +export function toggleActiveVimMode(): void { + activeToggle?.(); +} + +function positionKey(position: IPosition): number { + return position.lineNumber * 1_000_000 + position.column; +} + +function isWordCharacter(value: string | undefined): boolean { + return value !== undefined && /[A-Za-z0-9_]/u.test(value); +} + +function moveWordForward(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = model.getOffsetAt(position); + while (offset < text.length && isWordCharacter(text[offset])) offset += 1; + while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; + return model.getPositionAt(offset); +} + +function moveWordBackward(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = Math.max(0, model.getOffsetAt(position) - 1); + while (offset > 0 && !isWordCharacter(text[offset])) offset -= 1; + while (offset > 0 && isWordCharacter(text[offset - 1])) offset -= 1; + return model.getPositionAt(offset); +} + +function moveWordEnd(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = model.getOffsetAt(position); + if (isWordCharacter(text[offset]) && isWordCharacter(text[offset + 1])) { + while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; + return model.getPositionAt(offset); + } + offset += 1; + while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; + while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; + return model.getPositionAt(Math.min(offset, text.length)); +} + +function movePosition( + model: editor.ITextModel, + position: IPosition, + key: string, + count: number, +): IPosition | null { + let next = position; + for (let index = 0; index < count; index += 1) { + if (key === "h") { + next = { lineNumber: next.lineNumber, column: Math.max(1, next.column - 1) }; + } else if (key === "l") { + next = { + lineNumber: next.lineNumber, + column: Math.min(model.getLineMaxColumn(next.lineNumber), next.column + 1), + }; + } else if (key === "j") { + const lineNumber = Math.min(model.getLineCount(), next.lineNumber + 1); + next = { + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), next.column), + }; + } else if (key === "k") { + const lineNumber = Math.max(1, next.lineNumber - 1); + next = { + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), next.column), + }; + } else if (key === "w") { + next = moveWordForward(model, next); + } else if (key === "b") { + next = moveWordBackward(model, next); + } else if (key === "e") { + next = moveWordEnd(model, next); + } else { + return null; + } + } + return next; +} + +function clampCount(value: string): number { + const count = Number.parseInt(value, 10); + return Number.isSafeInteger(count) && count > 0 ? Math.min(count, 9999) : 1; +} + +function consume(event: IKeyboardEvent): void { + event.browserEvent.preventDefault(); + event.browserEvent.stopPropagation(); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function lineStart( + model: editor.ITextModel, + lineNumber: number, + firstNonBlank: boolean, +): IPosition { + if (!firstNonBlank) return { lineNumber, column: 1 }; + const match = model.getLineContent(lineNumber).search(/\S/u); + return { lineNumber, column: match < 0 ? 1 : match + 1 }; +} + +function findCharacter( + model: editor.ITextModel, + position: IPosition, + character: string, + direction: "forward" | "backward", + before: boolean, + count: number, +): IPosition { + const line = model.getLineContent(position.lineNumber); + let cursor = direction === "forward" ? position.column : position.column - 2; + let found = -1; + for (let repetition = 0; repetition < count; repetition += 1) { + found = + direction === "forward" + ? line.indexOf(character, Math.max(0, cursor)) + : line.lastIndexOf(character, Math.max(0, cursor)); + if (found < 0) return position; + cursor = direction === "forward" ? found + 1 : found - 1; + } + const column = found + 1 + (before ? (direction === "forward" ? -1 : 1) : 0); + return { lineNumber: position.lineNumber, column: Math.max(1, column) }; +} + +function matchingBracket(model: editor.ITextModel, position: IPosition): IPosition | null { + const text = model.getValue(); + const offset = model.getOffsetAt(position); + let index = offset; + let character = text[index]; + if (!BRACKET_PAIRS[character ?? ""]) { + index = offset + 1; + while (index < text.length && !BRACKET_PAIRS[text[index] ?? ""]) index += 1; + character = text[index]; + } + const pair = BRACKET_PAIRS[character ?? ""]; + if (!pair) return null; + const forward = character === "(" || character === "[" || character === "{"; + let depth = 0; + for ( + let cursor = index; + forward ? cursor < text.length : cursor >= 0; + cursor += forward ? 1 : -1 + ) { + const current = text[cursor]; + if (current === character) depth += 1; + if (current === pair) { + depth -= 1; + if (depth === 0) return model.getPositionAt(cursor); + } + } + return null; +} + +function revealLineNearBottom(codeEditor: editor.IStandaloneCodeEditor, lineNumber: number): void { + const lineTop = codeEditor.getTopForLineNumber(lineNumber); + const nextLineTop = codeEditor.getTopForLineNumber(lineNumber + 1); + const lineHeight = Math.max(1, nextLineTop - lineTop); + const viewportHeight = codeEditor.getLayoutInfo().height; + codeEditor.setScrollTop(Math.max(0, lineTop - viewportHeight + lineHeight)); +} + +function visibleLine( + codeEditor: editor.IStandaloneCodeEditor, + kind: "top" | "middle" | "bottom", + count: number, +): number | null { + const range = codeEditor.getVisibleRanges()[0]; + if (!range) return null; + if (kind === "top") return Math.min(range.startLineNumber + count - 1, range.endLineNumber); + if (kind === "bottom") return Math.max(range.endLineNumber - count + 1, range.startLineNumber); + return Math.floor((range.startLineNumber + range.endLineNumber) / 2); +} + +type PendingCommand = + | { kind: "character"; direction: "forward" | "backward"; before: boolean; count: number } + | { kind: "g"; count: number } + | { kind: "z"; count: number } + | { kind: "mark-set" } + | { kind: "mark-jump"; exact: boolean }; + +export function createVimController( + codeEditor: editor.IStandaloneCodeEditor, + onStateChange: (state: VimState) => void, +): { + toggle: () => void; + submitSearch: (query: string) => void; + cancelSearch: () => void; + dispose: () => void; +} { + let state = INITIAL_STATE; + let count = ""; + let anchor: IPosition | null = null; + let visualPosition: IPosition | null = null; + let pending: PendingCommand | null = null; + let lastSearch: { query: string; direction: VimSearchDirection; wholeWord: boolean } | null = + null; + const marks = new Map(); + let jumpHistory: IPosition[] = []; + let jumpIndex = -1; + + const update = (patch: Partial) => { + state = { ...state, ...patch }; + onStateChange(state); + }; + + const currentPosition = () => codeEditor.getPosition(); + const motionPosition = () => + state.mode === "visual" ? (visualPosition ?? currentPosition()) : currentPosition(); + + const recordJump = (target: IPosition) => { + const current = currentPosition(); + if (!current) return; + if ( + jumpIndex >= 0 && + jumpHistory[jumpIndex]?.lineNumber === current.lineNumber && + jumpHistory[jumpIndex]?.column === current.column + ) { + jumpHistory = jumpHistory.slice(0, jumpIndex + 1); + } else { + jumpHistory = jumpHistory.slice(0, jumpIndex + 1); + jumpHistory.push(current); + } + jumpHistory.push(target); + jumpIndex = jumpHistory.length - 1; + }; + + const setPosition = (position: IPosition, jump = false) => { + if (jump) recordJump(position); + codeEditor.setPosition(position); + codeEditor.revealPositionInCenter(position); + }; + + const setVisualPosition = (position: IPosition) => { + if (!anchor) anchor = currentPosition() ?? position; + visualPosition = position; + if (state.visualMode === "line") { + const model = codeEditor.getModel(); + const end = Math.max(anchor.lineNumber, position.lineNumber); + const anchorEnd = model?.getLineMaxColumn(anchor.lineNumber) ?? anchor.column; + const positionEnd = model?.getLineMaxColumn(position.lineNumber) ?? position.column; + codeEditor.setSelection({ + selectionStartLineNumber: anchor.lineNumber, + selectionStartColumn: anchor.lineNumber <= position.lineNumber ? 1 : anchorEnd, + positionLineNumber: position.lineNumber, + positionColumn: position.lineNumber >= anchor.lineNumber ? positionEnd : 1, + }); + codeEditor.revealLineInCenter(end); + return; + } + if (state.visualMode === "block") { + const model = codeEditor.getModel(); + const startLine = Math.min(anchor.lineNumber, position.lineNumber); + const endLine = Math.max(anchor.lineNumber, position.lineNumber); + const startColumn = Math.min(anchor.column, position.column); + const endColumn = Math.max(anchor.column, position.column); + const endColumnExclusive = model + ? Math.min(model.getLineMaxColumn(endLine), endColumn + 1) + : endColumn + 1; + const selections: ISelection[] = []; + for (let line = startLine; line <= endLine; line += 1) { + selections.push({ + selectionStartLineNumber: line, + selectionStartColumn: startColumn, + positionLineNumber: line, + positionColumn: endColumnExclusive, + }); + } + codeEditor.setSelections(selections); + codeEditor.revealPositionInCenter(position); + return; + } + const model = codeEditor.getModel(); + const anchorIsBefore = positionKey(anchor) <= positionKey(position); + const start = anchorIsBefore ? anchor : position; + const end = anchorIsBefore ? position : anchor; + const endColumnExclusive = model + ? Math.min(model.getLineMaxColumn(end.lineNumber), end.column + 1) + : end.column + 1; + codeEditor.setSelection({ + selectionStartLineNumber: start.lineNumber, + selectionStartColumn: start.column, + positionLineNumber: end.lineNumber, + positionColumn: endColumnExclusive, + }); + codeEditor.revealPositionInCenter(position); + }; + + const applyPosition = (position: IPosition, jump = false) => { + if (state.mode === "visual") setVisualPosition(position); + else setPosition(position, jump); + }; + + const performSearch = ( + query: string, + direction: VimSearchDirection, + repetitions = 1, + wholeWord = false, + saveSearch = true, + ) => { + const model = codeEditor.getModel(); + const current = currentPosition(); + if (!model || !current || query.length === 0) return; + const selection = codeEditor.getSelection(); + let searchPosition: IPosition = + direction === "forward" + ? (selection?.getEndPosition() ?? current) + : (selection?.getStartPosition() ?? current); + const searchQuery = wholeWord ? `\\b${escapeRegex(query)}\\b` : query; + let match: ReturnType | null = null; + for (let index = 0; index < repetitions; index += 1) { + match = + direction === "forward" + ? model.findNextMatch(searchQuery, searchPosition, wholeWord, false, null, false) + : model.findPreviousMatch(searchQuery, searchPosition, wholeWord, false, null, false); + if (!match) { + searchPosition = + direction === "forward" + ? { lineNumber: 1, column: 1 } + : model.getPositionAt(model.getValueLength()); + match = + direction === "forward" + ? model.findNextMatch(searchQuery, searchPosition, wholeWord, false, null, false) + : model.findPreviousMatch(searchQuery, searchPosition, wholeWord, false, null, false); + } + if (!match) return; + searchPosition = + direction === "forward" ? match.range.getEndPosition() : match.range.getStartPosition(); + } + if (!match) return; + recordJump(match.range.getStartPosition()); + codeEditor.setSelection(match.range); + codeEditor.revealRangeInCenter(match.range); + if (saveSearch) lastSearch = { query, direction, wholeWord }; + }; + + const jumpHistoryBy = (delta: -1 | 1) => { + if (jumpHistory.length === 0) return; + const nextIndex = Math.max(0, Math.min(jumpHistory.length - 1, jumpIndex + delta)); + if (nextIndex === jumpIndex) return; + jumpIndex = nextIndex; + const position = jumpHistory[jumpIndex]; + if (position) applyPosition(position); + }; + + const submitSearch = (query: string) => { + update({ searchOpen: false }); + performSearch(query, state.searchDirection); + codeEditor.focus(); + }; + + const cancelSearch = () => { + update({ searchOpen: false }); + codeEditor.focus(); + }; + + const toggle = () => { + const enabled = !state.enabled; + count = ""; + anchor = null; + visualPosition = null; + pending = null; + update({ enabled, mode: "normal", visualMode: null, searchOpen: false }); + codeEditor.focus(); + }; + + const onKeyDown = (event: IKeyboardEvent) => { + if (!state.enabled || event.browserEvent.isComposing) return; + const key = event.browserEvent.key; + const lowerKey = key.toLowerCase(); + const hasModifier = + event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey; + + if (state.mode === "insert") { + if (key !== "Escape") return; + consume(event); + visualPosition = null; + update({ mode: "normal", visualMode: null }); + return; + } + + if (key === "Escape") { + consume(event); + count = ""; + anchor = null; + visualPosition = null; + pending = null; + update({ mode: "normal", visualMode: null }); + return; + } + + if ( + hasModifier && + event.browserEvent.ctrlKey && + (lowerKey === "v" || + lowerKey === "o" || + lowerKey === "i" || + lowerKey === "d" || + lowerKey === "u" || + lowerKey === "f" || + lowerKey === "b") + ) { + if (lowerKey === "v") { + consume(event); + count = ""; + anchor = currentPosition(); + visualPosition = anchor; + update({ mode: "visual", visualMode: "block" }); + if (visualPosition) setVisualPosition(visualPosition); + return; + } + if (lowerKey === "o" || lowerKey === "i") { + consume(event); + count = ""; + jumpHistoryBy(lowerKey === "o" ? -1 : 1); + return; + } + const ctrlCount = clampCount(count); + count = ""; + const visible = codeEditor.getVisibleRanges()[0]; + const current = motionPosition(); + if (visible && current) { + const page = Math.max(1, visible.endLineNumber - visible.startLineNumber); + const delta = + (lowerKey === "f" + ? page + : lowerKey === "b" + ? -page + : lowerKey === "d" + ? Math.floor(page / 2) + : lowerKey === "u" + ? -Math.floor(page / 2) + : 0) * ctrlCount; + consume(event); + if (delta !== 0) { + const model = codeEditor.getModel(); + if (model) { + const lineNumber = Math.max( + 1, + Math.min(model.getLineCount(), current.lineNumber + delta), + ); + applyPosition({ + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), current.column), + }); + } + } + return; + } + consume(event); + } + + if (hasModifier) return; + + if (pending) { + const command = pending; + pending = null; + if (command.kind === "character" && key.length === 1) { + const model = codeEditor.getModel(); + const position = motionPosition(); + if (model && position) + applyPosition( + findCharacter(model, position, key, command.direction, command.before, command.count), + ); + consume(event); + return; + } + if (command.kind === "g" && lowerKey === "g") { + const model = codeEditor.getModel(); + if (model) applyPosition(lineStart(model, command.count, false), true); + consume(event); + return; + } + if (command.kind === "z" && (lowerKey === "t" || lowerKey === "z" || lowerKey === "b")) { + const line = currentPosition()?.lineNumber ?? null; + if (line !== null) { + if (lowerKey === "t") codeEditor.revealLineNearTop(line); + else if (lowerKey === "b") revealLineNearBottom(codeEditor, line); + else codeEditor.revealLineInCenter(line); + } + consume(event); + return; + } + if (command.kind === "mark-set" && /^[a-z]$/u.test(key)) { + const position = currentPosition(); + if (position) marks.set(key, position); + consume(event); + return; + } + if (command.kind === "mark-jump" && /^[a-z]$/u.test(key)) { + const mark = marks.get(key); + if (mark) { + const model = codeEditor.getModel(); + applyPosition( + command.exact ? mark : model ? lineStart(model, mark.lineNumber, false) : mark, + true, + ); + } + consume(event); + return; + } + return; + } + + if (key >= "1" && key <= "9") { + consume(event); + count += key; + return; + } + if (key === "0" && count.length > 0) { + consume(event); + count += key; + return; + } + + if (key === "/" || key === "?") { + consume(event); + count = ""; + update({ searchOpen: true, searchDirection: key === "/" ? "forward" : "backward" }); + return; + } + + if (key === "i" || key === "a" || key === "A") { + consume(event); + count = ""; + update({ mode: "insert", visualMode: null }); + return; + } + + if (key === "v" || key === "V") { + consume(event); + count = ""; + anchor = currentPosition(); + visualPosition = anchor; + update({ mode: "visual", visualMode: key === "V" ? "line" : "character" }); + if (visualPosition) setVisualPosition(visualPosition); + return; + } + + if (state.mode === "visual" && (key === "o" || key === "O")) { + const position = motionPosition(); + if (position && anchor) { + const previousAnchor = anchor; + anchor = position; + visualPosition = previousAnchor; + setVisualPosition(previousAnchor); + } + consume(event); + return; + } + + if (lowerKey === "n" && lastSearch) { + consume(event); + const repetitions = clampCount(count); + count = ""; + performSearch( + lastSearch.query, + event.browserEvent.shiftKey + ? lastSearch.direction === "forward" + ? "backward" + : "forward" + : lastSearch.direction, + repetitions, + lastSearch.wholeWord, + false, + ); + return; + } + + if (key === "*" || key === "#") { + const model = codeEditor.getModel(); + const position = currentPosition(); + const word = model && position ? model.getWordAtPosition(position)?.word : null; + if (word) { + consume(event); + performSearch(word, key === "*" ? "forward" : "backward", clampCount(count), true); + } + count = ""; + return; + } + + if (key === "f" || key === "F" || key === "t" || key === "T") { + consume(event); + pending = { + kind: "character", + direction: key === "f" || key === "t" ? "forward" : "backward", + before: key === "t" || key === "T", + count: clampCount(count), + }; + count = ""; + return; + } + + if (key === "g") { + consume(event); + pending = { kind: "g", count: clampCount(count) }; + count = ""; + return; + } + + if (key === "z") { + consume(event); + pending = { kind: "z", count: clampCount(count) }; + count = ""; + return; + } + + if (key === "m") { + consume(event); + pending = { kind: "mark-set" }; + count = ""; + return; + } + if (key === "'" || key === "`") { + consume(event); + pending = { kind: "mark-jump", exact: key === "`" }; + count = ""; + return; + } + + if (key === "G") { + consume(event); + const model = codeEditor.getModel(); + if (model) { + const lineNumber = + count.length > 0 + ? Math.min(model.getLineCount(), clampCount(count)) + : model.getLineCount(); + applyPosition(lineStart(model, lineNumber, false), true); + } + count = ""; + return; + } + + if (key === "^" || key === "0" || key === "$") { + consume(event); + const position = motionPosition(); + const model = codeEditor.getModel(); + if (position && model) { + const next = + key === "$" + ? { + lineNumber: position.lineNumber, + column: model.getLineMaxColumn(position.lineNumber), + } + : lineStart(model, position.lineNumber, key === "^"); + applyPosition(next); + } + count = ""; + return; + } + + if (key === "%") { + const model = codeEditor.getModel(); + const position = currentPosition(); + const match = model && position ? matchingBracket(model, position) : null; + if (match) { + consume(event); + applyPosition(match, true); + } + count = ""; + return; + } + + if (key === "H" || key === "M" || key === "L") { + const line = visibleLine( + codeEditor, + key === "H" ? "top" : key === "L" ? "bottom" : "middle", + clampCount(count), + ); + const model = codeEditor.getModel(); + if (line !== null && model) { + consume(event); + applyPosition(lineStart(model, line, false)); + } + count = ""; + return; + } + + const model = codeEditor.getModel(); + const position = motionPosition(); + if (!model || !position) return; + const next = movePosition(model, position, lowerKey, clampCount(count)); + if (!next) return; + consume(event); + count = ""; + applyPosition(next); + }; + + const listener = codeEditor.onKeyDown(onKeyDown); + const modelContentListener = codeEditor.onDidChangeModelContent(() => { + count = ""; + anchor = null; + visualPosition = null; + pending = null; + lastSearch = null; + marks.clear(); + jumpHistory = []; + jumpIndex = -1; + update({ mode: "normal", visualMode: null, searchOpen: false }); + }); + return { + toggle, + submitSearch, + cancelSearch, + dispose: () => { + listener.dispose(); + modelContentListener.dispose(); + }, + }; +} + +export function initialVimState(): VimState { + return { ...INITIAL_STATE }; +}