Vim mode (#3936)
This commit is contained in:
parent
aa71438684
commit
fbdc8d5ab3
|
@ -95,6 +95,17 @@ Slash commands provide meta-level control over the CLI itself.
|
|||
- **`/quit`** (or **`/exit`**)
|
||||
- **Description:** Exit Gemini CLI.
|
||||
|
||||
- **`/vim`**
|
||||
- **Description:** Toggle vim mode on or off. When vim mode is enabled, the input area supports vim-style navigation and editing commands in both NORMAL and INSERT modes.
|
||||
- **Features:**
|
||||
- **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`, `b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines with `G` (or `gg` for first line)
|
||||
- **INSERT mode:** Standard text input with escape to return to NORMAL mode
|
||||
- **Editing commands:** Delete with `x`, change with `c`, insert with `i`, `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw`
|
||||
- **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`)
|
||||
- **Repeat last command:** Use `.` to repeat the last editing operation
|
||||
- **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
|
||||
|
||||
### Custom Commands
|
||||
|
||||
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.
|
||||
|
|
|
@ -103,6 +103,11 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
|||
- **Default:** `"Default"`
|
||||
- **Example:** `"theme": "GitHub"`
|
||||
|
||||
- **`vimMode`** (boolean):
|
||||
- **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"vimMode": true`
|
||||
|
||||
- **`sandbox`** (boolean or string):
|
||||
- **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Gemini CLI uses a pre-built `gemini-cli-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing).
|
||||
- **Default:** `false`
|
||||
|
|
|
@ -799,9 +799,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
|
||||
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
|
||||
"version": "0.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz",
|
||||
"integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
@ -814,9 +814,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
|
||||
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
|
||||
"integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
|
@ -824,9 +824,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
||||
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
|
||||
"integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
@ -874,9 +874,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
|
||||
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
|
||||
"integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
@ -910,6 +910,19 @@
|
|||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
||||
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/gemini-cli": {
|
||||
"resolved": "packages/cli",
|
||||
"link": true
|
||||
|
@ -4770,19 +4783,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.31.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
|
||||
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz",
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.0",
|
||||
"@eslint/config-helpers": "^0.3.0",
|
||||
"@eslint/core": "^0.15.0",
|
||||
"@eslint/config-array": "^0.20.1",
|
||||
"@eslint/config-helpers": "^0.2.1",
|
||||
"@eslint/core": "^0.14.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.31.0",
|
||||
"@eslint/js": "9.29.0",
|
||||
"@eslint/plugin-kit": "^0.3.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
|
@ -11705,8 +11718,6 @@
|
|||
},
|
||||
"packages/cli/node_modules/@testing-library/dom": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
|
@ -11742,8 +11753,6 @@
|
|||
},
|
||||
"packages/cli/node_modules/@testing-library/react": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
|
||||
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -11795,8 +11804,6 @@
|
|||
},
|
||||
"packages/cli/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
|
@ -11806,8 +11813,6 @@
|
|||
},
|
||||
"packages/cli/node_modules/emoji-regex": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
|
||||
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/cli/node_modules/react-is": {
|
||||
|
@ -11820,8 +11825,6 @@
|
|||
},
|
||||
"packages/cli/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
|
@ -11918,8 +11921,6 @@
|
|||
},
|
||||
"packages/core/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
|
|
|
@ -86,7 +86,6 @@ export interface Settings {
|
|||
enableRecursiveFileSearch?: boolean;
|
||||
};
|
||||
|
||||
// UI setting. Does not display the ANSI-controlled terminal title.
|
||||
hideWindowTitle?: boolean;
|
||||
|
||||
hideTips?: boolean;
|
||||
|
@ -98,6 +97,8 @@ export interface Settings {
|
|||
// A map of tool names to their summarization settings.
|
||||
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
||||
|
||||
vimMode?: boolean;
|
||||
|
||||
// Add other settings here.
|
||||
ideMode?: boolean;
|
||||
memoryDiscoveryMaxDirs?: number;
|
||||
|
|
|
@ -28,6 +28,7 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
|||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
|
@ -66,6 +67,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
vimCommand,
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
|
|
|
@ -73,6 +73,8 @@ import { useGitBranchName } from './hooks/useGitBranchName.js';
|
|||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import * as fs from 'fs';
|
||||
import { UpdateNotification } from './components/UpdateNotification.js';
|
||||
import {
|
||||
|
@ -97,7 +99,9 @@ interface AppProps {
|
|||
|
||||
export const AppWrapper = (props: AppProps) => (
|
||||
<SessionStatsProvider>
|
||||
<App {...props} />
|
||||
<VimModeProvider settings={props.settings}>
|
||||
<App {...props} />
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
);
|
||||
|
||||
|
@ -374,6 +378,49 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
config.setFlashFallbackHandler(flashFallbackHandler);
|
||||
}, [config, addItem, userTier]);
|
||||
|
||||
// Terminal and UI setup
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const widthFraction = 0.9;
|
||||
const inputWidth = Math.max(
|
||||
20,
|
||||
Math.floor(terminalWidth * widthFraction) - 3,
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
// Utility callbacks
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
try {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPreferredEditor = useCallback(() => {
|
||||
const editorType = settings.merged.preferredEditor;
|
||||
const isValidEditor = isEditorAvailable(editorType);
|
||||
if (!isValidEditor) {
|
||||
openEditorDialog();
|
||||
return;
|
||||
}
|
||||
return editorType as EditorType;
|
||||
}, [settings, openEditorDialog]);
|
||||
|
||||
const onAuthError = useCallback(() => {
|
||||
setAuthError('reauth required');
|
||||
openAuthDialog();
|
||||
}, [openAuthDialog, setAuthError]);
|
||||
|
||||
// Core hooks and processors
|
||||
const {
|
||||
vimEnabled: vimModeEnabled,
|
||||
vimMode,
|
||||
toggleVimEnabled,
|
||||
} = useVimMode();
|
||||
|
||||
const {
|
||||
handleSlashCommand,
|
||||
slashCommands,
|
||||
|
@ -394,26 +441,41 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
toggleCorgiMode,
|
||||
setQuittingMessages,
|
||||
openPrivacyNotice,
|
||||
toggleVimEnabled,
|
||||
);
|
||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
const isInitialMount = useRef(true);
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
try {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const widthFraction = 0.9;
|
||||
const inputWidth = Math.max(
|
||||
20,
|
||||
Math.floor(terminalWidth * widthFraction) - 3,
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
history,
|
||||
addItem,
|
||||
setShowHelp,
|
||||
config,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
);
|
||||
|
||||
// Input handling
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedValue = submittedValue.trim();
|
||||
if (trimmedValue.length > 0) {
|
||||
submitQuery(trimmedValue);
|
||||
}
|
||||
},
|
||||
[submitQuery],
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
|
@ -424,6 +486,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
shellModeActive,
|
||||
});
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||
pendingHistoryItems.push(...pendingGeminiHistoryItems);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } =
|
||||
useLoadingIndicator(streamingState);
|
||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
|
||||
|
||||
const handleExit = useCallback(
|
||||
(
|
||||
pressedOnce: boolean,
|
||||
|
@ -489,57 +559,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
}
|
||||
}, [config]);
|
||||
|
||||
const getPreferredEditor = useCallback(() => {
|
||||
const editorType = settings.merged.preferredEditor;
|
||||
const isValidEditor = isEditorAvailable(editorType);
|
||||
if (!isValidEditor) {
|
||||
openEditorDialog();
|
||||
return;
|
||||
}
|
||||
return editorType as EditorType;
|
||||
}, [settings, openEditorDialog]);
|
||||
|
||||
const onAuthError = useCallback(() => {
|
||||
setAuthError('reauth required');
|
||||
openAuthDialog();
|
||||
}, [openAuthDialog, setAuthError]);
|
||||
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
history,
|
||||
addItem,
|
||||
setShowHelp,
|
||||
config,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
);
|
||||
pendingHistoryItems.push(...pendingGeminiHistoryItems);
|
||||
const { elapsedTime, currentLoadingPhrase } =
|
||||
useLoadingIndicator(streamingState);
|
||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedValue = submittedValue.trim();
|
||||
if (trimmedValue.length > 0) {
|
||||
submitQuery(trimmedValue);
|
||||
}
|
||||
},
|
||||
[submitQuery],
|
||||
);
|
||||
|
||||
const logger = useLogger();
|
||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||
|
||||
|
@ -697,6 +716,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
// Arbitrary threshold to ensure that items in the static area are large
|
||||
// enough but not too large to make the terminal hard to use.
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
const placeholder = vimModeEnabled
|
||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
||||
: ' Type your message or @path/to/file';
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={streamingState}>
|
||||
<Box flexDirection="column" marginBottom={1} width="90%">
|
||||
|
@ -938,6 +961,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
shellModeActive={shellModeActive}
|
||||
setShellModeActive={setShellModeActive}
|
||||
focus={isFocused}
|
||||
vimHandleInput={vimHandleInput}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -989,6 +1014,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
}
|
||||
promptTokenCount={sessionStats.lastPromptTokenCount}
|
||||
nightly={nightly}
|
||||
vimMode={vimModeEnabled ? vimMode : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
@ -58,6 +58,7 @@ export interface CommandContext {
|
|||
loadHistory: UseHistoryManagerReturn['loadHistory'];
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, SlashCommand } from './types.js';
|
||||
|
||||
export const vimCommand: SlashCommand = {
|
||||
name: 'vim',
|
||||
description: 'toggle vim mode on/off',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, _args) => {
|
||||
const newVimState = await context.ui.toggleVimEnabled();
|
||||
|
||||
const message = newVimState
|
||||
? 'Entered Vim mode. Run /vim again to exit.'
|
||||
: 'Exited Vim mode.';
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -25,6 +25,7 @@ interface FooterProps {
|
|||
showMemoryUsage?: boolean;
|
||||
promptTokenCount: number;
|
||||
nightly: boolean;
|
||||
vimMode?: string;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({
|
||||
|
@ -39,6 +40,7 @@ export const Footer: React.FC<FooterProps> = ({
|
|||
showMemoryUsage,
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
vimMode,
|
||||
}) => {
|
||||
const limit = tokenLimit(model);
|
||||
const percentage = promptTokenCount / limit;
|
||||
|
@ -46,6 +48,7 @@ export const Footer: React.FC<FooterProps> = ({
|
|||
return (
|
||||
<Box marginTop={1} justifyContent="space-between" width="100%">
|
||||
<Box>
|
||||
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
||||
{nightly ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>
|
||||
|
|
|
@ -171,6 +171,7 @@ describe('InputPrompt', () => {
|
|||
config: {
|
||||
getProjectRoot: () => path.join('test', 'project'),
|
||||
getTargetDir: () => path.join('test', 'project', 'src'),
|
||||
getVimMode: () => false,
|
||||
} as unknown as Config,
|
||||
slashCommands: mockSlashCommands,
|
||||
commandContext: mockCommandContext,
|
||||
|
@ -1076,4 +1077,48 @@ describe('InputPrompt', () => {
|
|||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim mode', () => {
|
||||
it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => {
|
||||
props.vimModeEnabled = true;
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it.
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => {
|
||||
props.vimModeEnabled = true;
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it.
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call handleInput when vim mode is disabled', async () => {
|
||||
// Mock vimHandleInput to return false (vim didn't handle the input)
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(false);
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -39,6 +39,7 @@ export interface InputPromptProps {
|
|||
suggestionsWidth: number;
|
||||
shellModeActive: boolean;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
}
|
||||
|
||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
@ -55,6 +56,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
}) => {
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
|
||||
|
@ -169,6 +171,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
key.sequence === '!' &&
|
||||
buffer.text === '' &&
|
||||
|
@ -347,6 +353,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
shellHistory,
|
||||
handleClipboardImage,
|
||||
resetCompletionState,
|
||||
vimHandleInput,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Viewport,
|
||||
TextBuffer,
|
||||
offsetToLogicalPos,
|
||||
logicalPosToOffset,
|
||||
textBufferReducer,
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
|
@ -1341,3 +1342,216 @@ describe('offsetToLogicalPos', () => {
|
|||
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
|
||||
});
|
||||
});
|
||||
|
||||
describe('logicalPosToOffset', () => {
|
||||
it('should convert row/col position to offset correctly', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
|
||||
// Line 0: "hello" (5 chars)
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello'
|
||||
|
||||
// Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline)
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world'
|
||||
|
||||
// Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1)
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123'
|
||||
expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123'
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const lines = ['a', '', 'c'];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a'
|
||||
expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a'
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c'
|
||||
});
|
||||
|
||||
it('should handle single empty line', () => {
|
||||
const lines = [''];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should be inverse of offsetToLogicalPos', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
const text = lines.join('\n');
|
||||
|
||||
// Test round-trip conversion
|
||||
for (let offset = 0; offset <= text.length; offset++) {
|
||||
const [row, col] = offsetToLogicalPos(text, offset);
|
||||
const convertedOffset = logicalPosToOffset(lines, row, col);
|
||||
expect(convertedOffset).toBe(offset);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle out-of-bounds positions', () => {
|
||||
const lines = ['hello'];
|
||||
|
||||
// Beyond end of line
|
||||
expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line
|
||||
|
||||
// Beyond array bounds - should clamp to the last line
|
||||
expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0)
|
||||
expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
|
||||
});
|
||||
});
|
||||
|
||||
describe('textBufferReducer vim operations', () => {
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete a single line including newline in multi-line text', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 2,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 2 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines when count > 1', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete line2 and line3, leaving line1 and line4
|
||||
expect(result.lines).toEqual(['line1', 'line4']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear single line content when only one line exists', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['only line'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 5,
|
||||
preferredCol: null,
|
||||
visualLines: [['only line']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 5 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should clear the line content but keep the line
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting the last line properly', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete the last line completely, not leave empty line
|
||||
expect(result.lines).toEqual(['line1']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
// Delete all 4 lines with 4dd
|
||||
const deleteAction: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 4 },
|
||||
};
|
||||
|
||||
const afterDelete = textBufferReducer(initialState, deleteAction);
|
||||
|
||||
// After deleting all lines, should have one empty line
|
||||
expect(afterDelete.lines).toEqual(['']);
|
||||
expect(afterDelete.cursorRow).toBe(0);
|
||||
expect(afterDelete.cursorCol).toBe(0);
|
||||
|
||||
// Now paste multiline content - this should work correctly
|
||||
const pasteAction: TextBufferAction = {
|
||||
type: 'insert',
|
||||
payload: 'new1\nnew2\nnew3\nnew4',
|
||||
};
|
||||
|
||||
const afterPaste = textBufferReducer(afterDelete, pasteAction);
|
||||
|
||||
// All lines including the first one should be present
|
||||
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);
|
||||
expect(afterPaste.cursorRow).toBe(3);
|
||||
expect(afterPaste.cursorCol).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
|
|||
import stringWidth from 'string-width';
|
||||
import { unescapePath } from '@google/gemini-cli-core';
|
||||
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
|
||||
import { handleVimAction, VimAction } from './vim-buffer-actions.js';
|
||||
|
||||
export type Direction =
|
||||
| 'left'
|
||||
|
@ -32,6 +33,283 @@ function isWordChar(ch: string | undefined): boolean {
|
|||
return !/[\s,.;!?]/.test(ch);
|
||||
}
|
||||
|
||||
// Vim-specific word boundary functions
|
||||
export const findNextWordStart = (
|
||||
text: string,
|
||||
currentOffset: number,
|
||||
): number => {
|
||||
let i = currentOffset;
|
||||
|
||||
if (i >= text.length) return i;
|
||||
|
||||
const currentChar = text[i];
|
||||
|
||||
// Skip current word/sequence based on character type
|
||||
if (/\w/.test(currentChar)) {
|
||||
// Skip current word characters
|
||||
while (i < text.length && /\w/.test(text[i])) {
|
||||
i++;
|
||||
}
|
||||
} else if (!/\s/.test(currentChar)) {
|
||||
// Skip current non-word, non-whitespace characters (like "/", ".", etc.)
|
||||
while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while (i < text.length && /\s/.test(text[i])) {
|
||||
i++;
|
||||
}
|
||||
|
||||
// If we reached the end of text and there's no next word,
|
||||
// vim behavior for dw is to delete to the end of the current word
|
||||
if (i >= text.length) {
|
||||
// Go back to find the end of the last word
|
||||
let endOfLastWord = text.length - 1;
|
||||
while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) {
|
||||
endOfLastWord--;
|
||||
}
|
||||
// For dw on last word, return position AFTER the last character to delete entire word
|
||||
return Math.max(currentOffset + 1, endOfLastWord + 1);
|
||||
}
|
||||
|
||||
return i;
|
||||
};
|
||||
|
||||
export const findPrevWordStart = (
|
||||
text: string,
|
||||
currentOffset: number,
|
||||
): number => {
|
||||
let i = currentOffset;
|
||||
|
||||
// If at beginning of text, return current position
|
||||
if (i <= 0) {
|
||||
return currentOffset;
|
||||
}
|
||||
|
||||
// Move back one character to start searching
|
||||
i--;
|
||||
|
||||
// Skip whitespace moving backwards
|
||||
while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) {
|
||||
i--;
|
||||
}
|
||||
|
||||
if (i < 0) {
|
||||
return 0; // Reached beginning of text
|
||||
}
|
||||
|
||||
const charAtI = text[i];
|
||||
|
||||
if (/\w/.test(charAtI)) {
|
||||
// We're in a word, move to its beginning
|
||||
while (i >= 0 && /\w/.test(text[i])) {
|
||||
i--;
|
||||
}
|
||||
return i + 1; // Return first character of word
|
||||
} else {
|
||||
// We're in punctuation, move to its beginning
|
||||
while (
|
||||
i >= 0 &&
|
||||
!/\w/.test(text[i]) &&
|
||||
text[i] !== ' ' &&
|
||||
text[i] !== '\t' &&
|
||||
text[i] !== '\n'
|
||||
) {
|
||||
i--;
|
||||
}
|
||||
return i + 1; // Return first character of punctuation sequence
|
||||
}
|
||||
};
|
||||
|
||||
export const findWordEnd = (text: string, currentOffset: number): number => {
|
||||
let i = currentOffset;
|
||||
|
||||
// If we're already at the end of a word, advance to next word
|
||||
if (
|
||||
i < text.length &&
|
||||
/\w/.test(text[i]) &&
|
||||
(i + 1 >= text.length || !/\w/.test(text[i + 1]))
|
||||
) {
|
||||
// We're at the end of a word, move forward to find next word
|
||||
i++;
|
||||
// Skip whitespace/punctuation to find next word
|
||||
while (i < text.length && !/\w/.test(text[i])) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're not on a word character, find the next word
|
||||
if (i < text.length && !/\w/.test(text[i])) {
|
||||
while (i < text.length && !/\w/.test(text[i])) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to end of current word
|
||||
while (i < text.length && /\w/.test(text[i])) {
|
||||
i++;
|
||||
}
|
||||
|
||||
// Move back one to be on the last character of the word
|
||||
return Math.max(currentOffset, i - 1);
|
||||
};
|
||||
|
||||
// Helper functions for vim operations
|
||||
export const getOffsetFromPosition = (
|
||||
row: number,
|
||||
col: number,
|
||||
lines: string[],
|
||||
): number => {
|
||||
let offset = 0;
|
||||
for (let i = 0; i < row; i++) {
|
||||
offset += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
offset += col;
|
||||
return offset;
|
||||
};
|
||||
|
||||
export const getPositionFromOffsets = (
|
||||
startOffset: number,
|
||||
endOffset: number,
|
||||
lines: string[],
|
||||
) => {
|
||||
let offset = 0;
|
||||
let startRow = 0;
|
||||
let startCol = 0;
|
||||
let endRow = 0;
|
||||
let endCol = 0;
|
||||
|
||||
// Find start position
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineLength = lines[i].length + 1; // +1 for newline
|
||||
if (offset + lineLength > startOffset) {
|
||||
startRow = i;
|
||||
startCol = startOffset - offset;
|
||||
break;
|
||||
}
|
||||
offset += lineLength;
|
||||
}
|
||||
|
||||
// Find end position
|
||||
offset = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 for newline except last line
|
||||
if (offset + lineLength >= endOffset) {
|
||||
endRow = i;
|
||||
endCol = endOffset - offset;
|
||||
break;
|
||||
}
|
||||
offset += lineLength;
|
||||
}
|
||||
|
||||
return { startRow, startCol, endRow, endCol };
|
||||
};
|
||||
|
||||
export const getLineRangeOffsets = (
|
||||
startRow: number,
|
||||
lineCount: number,
|
||||
lines: string[],
|
||||
) => {
|
||||
let startOffset = 0;
|
||||
|
||||
// Calculate start offset
|
||||
for (let i = 0; i < startRow; i++) {
|
||||
startOffset += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
|
||||
// Calculate end offset
|
||||
let endOffset = startOffset;
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const lineIndex = startRow + i;
|
||||
if (lineIndex < lines.length) {
|
||||
endOffset += lines[lineIndex].length;
|
||||
if (lineIndex < lines.length - 1) {
|
||||
endOffset += 1; // +1 for newline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { startOffset, endOffset };
|
||||
};
|
||||
|
||||
export const replaceRangeInternal = (
|
||||
state: TextBufferState,
|
||||
startRow: number,
|
||||
startCol: number,
|
||||
endRow: number,
|
||||
endCol: number,
|
||||
text: string,
|
||||
): TextBufferState => {
|
||||
const currentLine = (row: number) => state.lines[row] || '';
|
||||
const currentLineLen = (row: number) => cpLen(currentLine(row));
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
if (
|
||||
startRow > endRow ||
|
||||
(startRow === endRow && startCol > endCol) ||
|
||||
startRow < 0 ||
|
||||
startCol < 0 ||
|
||||
endRow >= state.lines.length ||
|
||||
(endRow < state.lines.length && endCol > currentLineLen(endRow))
|
||||
) {
|
||||
return state; // Invalid range
|
||||
}
|
||||
|
||||
const newLines = [...state.lines];
|
||||
|
||||
const sCol = clamp(startCol, 0, currentLineLen(startRow));
|
||||
const eCol = clamp(endCol, 0, currentLineLen(endRow));
|
||||
|
||||
const prefix = cpSlice(currentLine(startRow), 0, sCol);
|
||||
const suffix = cpSlice(currentLine(endRow), eCol);
|
||||
|
||||
const normalisedReplacement = text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n');
|
||||
const replacementParts = normalisedReplacement.split('\n');
|
||||
|
||||
// Replace the content
|
||||
if (startRow === endRow) {
|
||||
newLines[startRow] = prefix + normalisedReplacement + suffix;
|
||||
} else {
|
||||
const firstLine = prefix + replacementParts[0];
|
||||
if (replacementParts.length === 1) {
|
||||
// Single line of replacement text, but spanning multiple original lines
|
||||
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
|
||||
} else {
|
||||
// Multi-line replacement text
|
||||
const lastLine = replacementParts[replacementParts.length - 1] + suffix;
|
||||
const middleLines = replacementParts.slice(1, -1);
|
||||
newLines.splice(
|
||||
startRow,
|
||||
endRow - startRow + 1,
|
||||
firstLine,
|
||||
...middleLines,
|
||||
lastLine,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalCursorRow = startRow + replacementParts.length - 1;
|
||||
const finalCursorCol =
|
||||
(replacementParts.length > 1 ? 0 : sCol) +
|
||||
cpLen(replacementParts[replacementParts.length - 1]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
lines: newLines,
|
||||
cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1),
|
||||
cursorCol: Math.max(
|
||||
0,
|
||||
Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || '')),
|
||||
),
|
||||
preferredCol: null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip characters that can break terminal rendering.
|
||||
*
|
||||
|
@ -158,6 +436,33 @@ export function offsetToLogicalPos(
|
|||
return [row, col];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts logical row/col position to absolute text offset
|
||||
* Inverse operation of offsetToLogicalPos
|
||||
*/
|
||||
export function logicalPosToOffset(
|
||||
lines: string[],
|
||||
row: number,
|
||||
col: number,
|
||||
): number {
|
||||
let offset = 0;
|
||||
|
||||
// Clamp row to valid range
|
||||
const actualRow = Math.min(row, lines.length - 1);
|
||||
|
||||
// Add lengths of all lines before the target row
|
||||
for (let i = 0; i < actualRow; i++) {
|
||||
offset += cpLen(lines[i]) + 1; // +1 for newline
|
||||
}
|
||||
|
||||
// Add column offset within the target row
|
||||
if (actualRow >= 0 && actualRow < lines.length) {
|
||||
offset += Math.min(col, cpLen(lines[actualRow]));
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Helper to calculate visual lines and map cursor positions
|
||||
function calculateVisualLayout(
|
||||
logicalLines: string[],
|
||||
|
@ -376,7 +681,7 @@ function calculateVisualLayout(
|
|||
|
||||
// --- Start of reducer logic ---
|
||||
|
||||
interface TextBufferState {
|
||||
export interface TextBufferState {
|
||||
lines: string[];
|
||||
cursorRow: number;
|
||||
cursorCol: number;
|
||||
|
@ -390,7 +695,20 @@ interface TextBufferState {
|
|||
|
||||
const historyLimit = 100;
|
||||
|
||||
type TextBufferAction =
|
||||
export const pushUndo = (currentState: TextBufferState): TextBufferState => {
|
||||
const snapshot = {
|
||||
lines: [...currentState.lines],
|
||||
cursorRow: currentState.cursorRow,
|
||||
cursorCol: currentState.cursorCol,
|
||||
};
|
||||
const newStack = [...currentState.undoStack, snapshot];
|
||||
if (newStack.length > historyLimit) {
|
||||
newStack.shift();
|
||||
}
|
||||
return { ...currentState, undoStack: newStack, redoStack: [] };
|
||||
};
|
||||
|
||||
export type TextBufferAction =
|
||||
| { type: 'set_text'; payload: string; pushToUndo?: boolean }
|
||||
| { type: 'insert'; payload: string }
|
||||
| { type: 'backspace' }
|
||||
|
@ -419,24 +737,49 @@ type TextBufferAction =
|
|||
}
|
||||
| { type: 'move_to_offset'; payload: { offset: number } }
|
||||
| { type: 'create_undo_snapshot' }
|
||||
| { type: 'set_viewport_width'; payload: number };
|
||||
| { type: 'set_viewport_width'; payload: number }
|
||||
| { type: 'vim_delete_word_forward'; payload: { count: number } }
|
||||
| { type: 'vim_delete_word_backward'; payload: { count: number } }
|
||||
| { type: 'vim_delete_word_end'; payload: { count: number } }
|
||||
| { type: 'vim_change_word_forward'; payload: { count: number } }
|
||||
| { type: 'vim_change_word_backward'; payload: { count: number } }
|
||||
| { type: 'vim_change_word_end'; payload: { count: number } }
|
||||
| { type: 'vim_delete_line'; payload: { count: number } }
|
||||
| { type: 'vim_change_line'; payload: { count: number } }
|
||||
| { type: 'vim_delete_to_end_of_line' }
|
||||
| { type: 'vim_change_to_end_of_line' }
|
||||
| {
|
||||
type: 'vim_change_movement';
|
||||
payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
|
||||
}
|
||||
// New vim actions for stateless command handling
|
||||
| { type: 'vim_move_left'; payload: { count: number } }
|
||||
| { type: 'vim_move_right'; payload: { count: number } }
|
||||
| { type: 'vim_move_up'; payload: { count: number } }
|
||||
| { type: 'vim_move_down'; payload: { count: number } }
|
||||
| { type: 'vim_move_word_forward'; payload: { count: number } }
|
||||
| { type: 'vim_move_word_backward'; payload: { count: number } }
|
||||
| { type: 'vim_move_word_end'; payload: { count: number } }
|
||||
| { type: 'vim_delete_char'; payload: { count: number } }
|
||||
| { type: 'vim_insert_at_cursor' }
|
||||
| { type: 'vim_append_at_cursor' }
|
||||
| { type: 'vim_open_line_below' }
|
||||
| { type: 'vim_open_line_above' }
|
||||
| { type: 'vim_append_at_line_end' }
|
||||
| { type: 'vim_insert_at_line_start' }
|
||||
| { type: 'vim_move_to_line_start' }
|
||||
| { type: 'vim_move_to_line_end' }
|
||||
| { type: 'vim_move_to_first_nonwhitespace' }
|
||||
| { type: 'vim_move_to_first_line' }
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
||||
| { type: 'vim_escape_insert_mode' };
|
||||
|
||||
export function textBufferReducer(
|
||||
state: TextBufferState,
|
||||
action: TextBufferAction,
|
||||
): TextBufferState {
|
||||
const pushUndo = (currentState: TextBufferState): TextBufferState => {
|
||||
const snapshot = {
|
||||
lines: [...currentState.lines],
|
||||
cursorRow: currentState.cursorRow,
|
||||
cursorCol: currentState.cursorCol,
|
||||
};
|
||||
const newStack = [...currentState.undoStack, snapshot];
|
||||
if (newStack.length > historyLimit) {
|
||||
newStack.shift();
|
||||
}
|
||||
return { ...currentState, undoStack: newStack, redoStack: [] };
|
||||
};
|
||||
const pushUndoLocal = pushUndo;
|
||||
|
||||
const currentLine = (r: number): string => state.lines[r] ?? '';
|
||||
const currentLineLen = (r: number): number => cpLen(currentLine(r));
|
||||
|
@ -445,7 +788,7 @@ export function textBufferReducer(
|
|||
case 'set_text': {
|
||||
let nextState = state;
|
||||
if (action.pushToUndo !== false) {
|
||||
nextState = pushUndo(state);
|
||||
nextState = pushUndoLocal(state);
|
||||
}
|
||||
const newContentLines = action.payload
|
||||
.replace(/\r\n?/g, '\n')
|
||||
|
@ -462,7 +805,7 @@ export function textBufferReducer(
|
|||
}
|
||||
|
||||
case 'insert': {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = nextState.cursorRow;
|
||||
let newCursorCol = nextState.cursorCol;
|
||||
|
@ -504,7 +847,7 @@ export function textBufferReducer(
|
|||
}
|
||||
|
||||
case 'backspace': {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = nextState.cursorRow;
|
||||
let newCursorCol = nextState.cursorCol;
|
||||
|
@ -700,14 +1043,14 @@ export function textBufferReducer(
|
|||
const { cursorRow, cursorCol, lines } = state;
|
||||
const lineContent = currentLine(cursorRow);
|
||||
if (cursorCol < currentLineLen(cursorRow)) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol) +
|
||||
cpSlice(lineContent, cursorCol + 1);
|
||||
return { ...nextState, lines: newLines, preferredCol: null };
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
|
@ -722,7 +1065,7 @@ export function textBufferReducer(
|
|||
if (cursorCol === 0 && cursorRow === 0) return state;
|
||||
if (cursorCol === 0) {
|
||||
// Act as a backspace
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const prevLineContent = currentLine(cursorRow - 1);
|
||||
const currentLineContentVal = currentLine(cursorRow);
|
||||
const newCol = cpLen(prevLineContent);
|
||||
|
@ -737,7 +1080,7 @@ export function textBufferReducer(
|
|||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const arr = toCodePoints(lineContent);
|
||||
let start = cursorCol;
|
||||
|
@ -773,14 +1116,14 @@ export function textBufferReducer(
|
|||
return state;
|
||||
if (cursorCol >= arr.length) {
|
||||
// Act as a delete
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
newLines.splice(cursorRow + 1, 1);
|
||||
return { ...nextState, lines: newLines, preferredCol: null };
|
||||
}
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
let end = cursorCol;
|
||||
while (end < arr.length && !isWordChar(arr[end])) end++;
|
||||
while (end < arr.length && isWordChar(arr[end])) end++;
|
||||
|
@ -794,13 +1137,13 @@ export function textBufferReducer(
|
|||
const { cursorRow, cursorCol, lines } = state;
|
||||
const lineContent = currentLine(cursorRow);
|
||||
if (cursorCol < currentLineLen(cursorRow)) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
||||
return { ...nextState, lines: newLines };
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
// Act as a delete
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
|
@ -813,7 +1156,7 @@ export function textBufferReducer(
|
|||
case 'kill_line_left': {
|
||||
const { cursorRow, cursorCol } = state;
|
||||
if (cursorCol > 0) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = pushUndoLocal(state);
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
|
||||
|
@ -863,66 +1206,15 @@ export function textBufferReducer(
|
|||
|
||||
case 'replace_range': {
|
||||
const { startRow, startCol, endRow, endCol, text } = action.payload;
|
||||
if (
|
||||
startRow > endRow ||
|
||||
(startRow === endRow && startCol > endCol) ||
|
||||
startRow < 0 ||
|
||||
startCol < 0 ||
|
||||
endRow >= state.lines.length ||
|
||||
(endRow < state.lines.length && endCol > currentLineLen(endRow))
|
||||
) {
|
||||
return state; // Invalid range
|
||||
}
|
||||
|
||||
const nextState = pushUndo(state);
|
||||
const newLines = [...nextState.lines];
|
||||
|
||||
const sCol = clamp(startCol, 0, currentLineLen(startRow));
|
||||
const eCol = clamp(endCol, 0, currentLineLen(endRow));
|
||||
|
||||
const prefix = cpSlice(currentLine(startRow), 0, sCol);
|
||||
const suffix = cpSlice(currentLine(endRow), eCol);
|
||||
|
||||
const normalisedReplacement = text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n');
|
||||
const replacementParts = normalisedReplacement.split('\n');
|
||||
|
||||
// Replace the content
|
||||
if (startRow === endRow) {
|
||||
newLines[startRow] = prefix + normalisedReplacement + suffix;
|
||||
} else {
|
||||
const firstLine = prefix + replacementParts[0];
|
||||
if (replacementParts.length === 1) {
|
||||
// Single line of replacement text, but spanning multiple original lines
|
||||
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
|
||||
} else {
|
||||
// Multi-line replacement text
|
||||
const lastLine =
|
||||
replacementParts[replacementParts.length - 1] + suffix;
|
||||
const middleLines = replacementParts.slice(1, -1);
|
||||
newLines.splice(
|
||||
startRow,
|
||||
endRow - startRow + 1,
|
||||
firstLine,
|
||||
...middleLines,
|
||||
lastLine,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalCursorRow = startRow + replacementParts.length - 1;
|
||||
const finalCursorCol =
|
||||
(replacementParts.length > 1 ? 0 : sCol) +
|
||||
cpLen(replacementParts[replacementParts.length - 1]);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: finalCursorRow,
|
||||
cursorCol: finalCursorCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
const nextState = pushUndoLocal(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
case 'move_to_offset': {
|
||||
|
@ -940,9 +1232,44 @@ export function textBufferReducer(
|
|||
}
|
||||
|
||||
case 'create_undo_snapshot': {
|
||||
return pushUndo(state);
|
||||
return pushUndoLocal(state);
|
||||
}
|
||||
|
||||
// Vim-specific operations
|
||||
case 'vim_delete_word_forward':
|
||||
case 'vim_delete_word_backward':
|
||||
case 'vim_delete_word_end':
|
||||
case 'vim_change_word_forward':
|
||||
case 'vim_change_word_backward':
|
||||
case 'vim_change_word_end':
|
||||
case 'vim_delete_line':
|
||||
case 'vim_change_line':
|
||||
case 'vim_delete_to_end_of_line':
|
||||
case 'vim_change_to_end_of_line':
|
||||
case 'vim_change_movement':
|
||||
case 'vim_move_left':
|
||||
case 'vim_move_right':
|
||||
case 'vim_move_up':
|
||||
case 'vim_move_down':
|
||||
case 'vim_move_word_forward':
|
||||
case 'vim_move_word_backward':
|
||||
case 'vim_move_word_end':
|
||||
case 'vim_delete_char':
|
||||
case 'vim_insert_at_cursor':
|
||||
case 'vim_append_at_cursor':
|
||||
case 'vim_open_line_below':
|
||||
case 'vim_open_line_above':
|
||||
case 'vim_append_at_line_end':
|
||||
case 'vim_insert_at_line_start':
|
||||
case 'vim_move_to_line_start':
|
||||
case 'vim_move_to_line_end':
|
||||
case 'vim_move_to_first_nonwhitespace':
|
||||
case 'vim_move_to_first_line':
|
||||
case 'vim_move_to_last_line':
|
||||
case 'vim_move_to_line':
|
||||
case 'vim_escape_insert_mode':
|
||||
return handleVimAction(state, action as VimAction);
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = action;
|
||||
console.error(`Unknown action encountered: ${exhaustiveCheck}`);
|
||||
|
@ -1110,6 +1437,139 @@ export function useTextBuffer({
|
|||
dispatch({ type: 'kill_line_left' });
|
||||
}, []);
|
||||
|
||||
// Vim-specific operations
|
||||
const vimDeleteWordForward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_delete_word_forward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimDeleteWordBackward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_delete_word_backward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimDeleteWordEnd = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_delete_word_end', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimChangeWordForward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_change_word_forward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimChangeWordBackward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_change_word_backward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimChangeWordEnd = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_change_word_end', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimDeleteLine = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_delete_line', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimChangeLine = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_change_line', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimDeleteToEndOfLine = useCallback((): void => {
|
||||
dispatch({ type: 'vim_delete_to_end_of_line' });
|
||||
}, []);
|
||||
|
||||
const vimChangeToEndOfLine = useCallback((): void => {
|
||||
dispatch({ type: 'vim_change_to_end_of_line' });
|
||||
}, []);
|
||||
|
||||
const vimChangeMovement = useCallback(
|
||||
(movement: 'h' | 'j' | 'k' | 'l', count: number): void => {
|
||||
dispatch({ type: 'vim_change_movement', payload: { movement, count } });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// New vim navigation and operation methods
|
||||
const vimMoveLeft = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_move_left', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimMoveRight = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_move_right', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimMoveUp = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_move_up', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimMoveDown = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_move_down', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimMoveWordForward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_move_word_forward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimMoveWordBackward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_move_word_backward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimMoveWordEnd = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_move_word_end', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimDeleteChar = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_delete_char', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimInsertAtCursor = useCallback((): void => {
|
||||
dispatch({ type: 'vim_insert_at_cursor' });
|
||||
}, []);
|
||||
|
||||
const vimAppendAtCursor = useCallback((): void => {
|
||||
dispatch({ type: 'vim_append_at_cursor' });
|
||||
}, []);
|
||||
|
||||
const vimOpenLineBelow = useCallback((): void => {
|
||||
dispatch({ type: 'vim_open_line_below' });
|
||||
}, []);
|
||||
|
||||
const vimOpenLineAbove = useCallback((): void => {
|
||||
dispatch({ type: 'vim_open_line_above' });
|
||||
}, []);
|
||||
|
||||
const vimAppendAtLineEnd = useCallback((): void => {
|
||||
dispatch({ type: 'vim_append_at_line_end' });
|
||||
}, []);
|
||||
|
||||
const vimInsertAtLineStart = useCallback((): void => {
|
||||
dispatch({ type: 'vim_insert_at_line_start' });
|
||||
}, []);
|
||||
|
||||
const vimMoveToLineStart = useCallback((): void => {
|
||||
dispatch({ type: 'vim_move_to_line_start' });
|
||||
}, []);
|
||||
|
||||
const vimMoveToLineEnd = useCallback((): void => {
|
||||
dispatch({ type: 'vim_move_to_line_end' });
|
||||
}, []);
|
||||
|
||||
const vimMoveToFirstNonWhitespace = useCallback((): void => {
|
||||
dispatch({ type: 'vim_move_to_first_nonwhitespace' });
|
||||
}, []);
|
||||
|
||||
const vimMoveToFirstLine = useCallback((): void => {
|
||||
dispatch({ type: 'vim_move_to_first_line' });
|
||||
}, []);
|
||||
|
||||
const vimMoveToLastLine = useCallback((): void => {
|
||||
dispatch({ type: 'vim_move_to_last_line' });
|
||||
}, []);
|
||||
|
||||
const vimMoveToLine = useCallback((lineNumber: number): void => {
|
||||
dispatch({ type: 'vim_move_to_line', payload: { lineNumber } });
|
||||
}, []);
|
||||
|
||||
const vimEscapeInsertMode = useCallback((): void => {
|
||||
dispatch({ type: 'vim_escape_insert_mode' });
|
||||
}, []);
|
||||
|
||||
const openInExternalEditor = useCallback(
|
||||
async (opts: { editor?: string } = {}): Promise<void> => {
|
||||
const editor =
|
||||
|
@ -1273,6 +1733,39 @@ export function useTextBuffer({
|
|||
killLineLeft,
|
||||
handleInput,
|
||||
openInExternalEditor,
|
||||
// Vim-specific operations
|
||||
vimDeleteWordForward,
|
||||
vimDeleteWordBackward,
|
||||
vimDeleteWordEnd,
|
||||
vimChangeWordForward,
|
||||
vimChangeWordBackward,
|
||||
vimChangeWordEnd,
|
||||
vimDeleteLine,
|
||||
vimChangeLine,
|
||||
vimDeleteToEndOfLine,
|
||||
vimChangeToEndOfLine,
|
||||
vimChangeMovement,
|
||||
vimMoveLeft,
|
||||
vimMoveRight,
|
||||
vimMoveUp,
|
||||
vimMoveDown,
|
||||
vimMoveWordForward,
|
||||
vimMoveWordBackward,
|
||||
vimMoveWordEnd,
|
||||
vimDeleteChar,
|
||||
vimInsertAtCursor,
|
||||
vimAppendAtCursor,
|
||||
vimOpenLineBelow,
|
||||
vimOpenLineAbove,
|
||||
vimAppendAtLineEnd,
|
||||
vimInsertAtLineStart,
|
||||
vimMoveToLineStart,
|
||||
vimMoveToLineEnd,
|
||||
vimMoveToFirstNonWhitespace,
|
||||
vimMoveToFirstLine,
|
||||
vimMoveToLastLine,
|
||||
vimMoveToLine,
|
||||
vimEscapeInsertMode,
|
||||
};
|
||||
return returnValue;
|
||||
}
|
||||
|
@ -1387,4 +1880,134 @@ export interface TextBuffer {
|
|||
replacementText: string,
|
||||
) => void;
|
||||
moveToOffset(offset: number): void;
|
||||
|
||||
// Vim-specific operations
|
||||
/**
|
||||
* Delete N words forward from cursor position (vim 'dw' command)
|
||||
*/
|
||||
vimDeleteWordForward: (count: number) => void;
|
||||
/**
|
||||
* Delete N words backward from cursor position (vim 'db' command)
|
||||
*/
|
||||
vimDeleteWordBackward: (count: number) => void;
|
||||
/**
|
||||
* Delete to end of N words from cursor position (vim 'de' command)
|
||||
*/
|
||||
vimDeleteWordEnd: (count: number) => void;
|
||||
/**
|
||||
* Change N words forward from cursor position (vim 'cw' command)
|
||||
*/
|
||||
vimChangeWordForward: (count: number) => void;
|
||||
/**
|
||||
* Change N words backward from cursor position (vim 'cb' command)
|
||||
*/
|
||||
vimChangeWordBackward: (count: number) => void;
|
||||
/**
|
||||
* Change to end of N words from cursor position (vim 'ce' command)
|
||||
*/
|
||||
vimChangeWordEnd: (count: number) => void;
|
||||
/**
|
||||
* Delete N lines from cursor position (vim 'dd' command)
|
||||
*/
|
||||
vimDeleteLine: (count: number) => void;
|
||||
/**
|
||||
* Change N lines from cursor position (vim 'cc' command)
|
||||
*/
|
||||
vimChangeLine: (count: number) => void;
|
||||
/**
|
||||
* Delete from cursor to end of line (vim 'D' command)
|
||||
*/
|
||||
vimDeleteToEndOfLine: () => void;
|
||||
/**
|
||||
* Change from cursor to end of line (vim 'C' command)
|
||||
*/
|
||||
vimChangeToEndOfLine: () => void;
|
||||
/**
|
||||
* Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands)
|
||||
*/
|
||||
vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void;
|
||||
/**
|
||||
* Move cursor left N times (vim 'h' command)
|
||||
*/
|
||||
vimMoveLeft: (count: number) => void;
|
||||
/**
|
||||
* Move cursor right N times (vim 'l' command)
|
||||
*/
|
||||
vimMoveRight: (count: number) => void;
|
||||
/**
|
||||
* Move cursor up N times (vim 'k' command)
|
||||
*/
|
||||
vimMoveUp: (count: number) => void;
|
||||
/**
|
||||
* Move cursor down N times (vim 'j' command)
|
||||
*/
|
||||
vimMoveDown: (count: number) => void;
|
||||
/**
|
||||
* Move cursor forward N words (vim 'w' command)
|
||||
*/
|
||||
vimMoveWordForward: (count: number) => void;
|
||||
/**
|
||||
* Move cursor backward N words (vim 'b' command)
|
||||
*/
|
||||
vimMoveWordBackward: (count: number) => void;
|
||||
/**
|
||||
* Move cursor to end of Nth word (vim 'e' command)
|
||||
*/
|
||||
vimMoveWordEnd: (count: number) => void;
|
||||
/**
|
||||
* Delete N characters at cursor (vim 'x' command)
|
||||
*/
|
||||
vimDeleteChar: (count: number) => void;
|
||||
/**
|
||||
* Enter insert mode at cursor (vim 'i' command)
|
||||
*/
|
||||
vimInsertAtCursor: () => void;
|
||||
/**
|
||||
* Enter insert mode after cursor (vim 'a' command)
|
||||
*/
|
||||
vimAppendAtCursor: () => void;
|
||||
/**
|
||||
* Open new line below and enter insert mode (vim 'o' command)
|
||||
*/
|
||||
vimOpenLineBelow: () => void;
|
||||
/**
|
||||
* Open new line above and enter insert mode (vim 'O' command)
|
||||
*/
|
||||
vimOpenLineAbove: () => void;
|
||||
/**
|
||||
* Move to end of line and enter insert mode (vim 'A' command)
|
||||
*/
|
||||
vimAppendAtLineEnd: () => void;
|
||||
/**
|
||||
* Move to first non-whitespace and enter insert mode (vim 'I' command)
|
||||
*/
|
||||
vimInsertAtLineStart: () => void;
|
||||
/**
|
||||
* Move cursor to beginning of line (vim '0' command)
|
||||
*/
|
||||
vimMoveToLineStart: () => void;
|
||||
/**
|
||||
* Move cursor to end of line (vim '$' command)
|
||||
*/
|
||||
vimMoveToLineEnd: () => void;
|
||||
/**
|
||||
* Move cursor to first non-whitespace character (vim '^' command)
|
||||
*/
|
||||
vimMoveToFirstNonWhitespace: () => void;
|
||||
/**
|
||||
* Move cursor to first line (vim 'gg' command)
|
||||
*/
|
||||
vimMoveToFirstLine: () => void;
|
||||
/**
|
||||
* Move cursor to last line (vim 'G' command)
|
||||
*/
|
||||
vimMoveToLastLine: () => void;
|
||||
/**
|
||||
* Move cursor to specific line number (vim '[N]G' command)
|
||||
*/
|
||||
vimMoveToLine: (lineNumber: number) => void;
|
||||
/**
|
||||
* Handle escape from insert mode (moves cursor left if not at line start)
|
||||
*/
|
||||
vimEscapeInsertMode: () => void;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,796 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import type { TextBufferState } from './text-buffer.js';
|
||||
|
||||
// Helper to create test state
|
||||
const createTestState = (
|
||||
lines: string[] = ['hello world'],
|
||||
cursorRow = 0,
|
||||
cursorCol = 0,
|
||||
): TextBufferState => ({
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
preferredCol: null,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
clipboard: null,
|
||||
selectionAnchor: null,
|
||||
viewportWidth: 80,
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
describe('Movement commands', () => {
|
||||
describe('vim_move_left', () => {
|
||||
it('should move cursor left by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
expect(result.preferredCol).toBeNull();
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should wrap to previous line when at beginning', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
|
||||
});
|
||||
|
||||
it('should handle multiple line wrapping', () => {
|
||||
const state = createTestState(['abc', 'def', 'ghi'], 2, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
|
||||
});
|
||||
|
||||
it('should correctly handle h/l movement between lines', () => {
|
||||
// Start at end of first line at 'd' (position 10)
|
||||
let state = createTestState(['hello world', 'foo bar'], 0, 10);
|
||||
|
||||
// Move right - should go to beginning of next line
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(1);
|
||||
expect(state.cursorCol).toBe(0); // Should be on 'f'
|
||||
|
||||
// Move left - should go back to end of previous line on 'd'
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_right', () => {
|
||||
it('should move cursor right by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should not move past last character of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // Last character of 'hello'
|
||||
});
|
||||
|
||||
it('should wrap to next line when at end', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1'
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_up', () => {
|
||||
it('should move cursor up by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past first line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
});
|
||||
|
||||
it('should adjust column for shorter lines', () => {
|
||||
const state = createTestState(['short', 'very long line'], 1, 10);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(5); // End of 'short'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_down', () => {
|
||||
it('should move cursor down by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past last line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_forward', () => {
|
||||
it('should move to start of next word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(12); // Start of 'test'
|
||||
});
|
||||
|
||||
it('should handle punctuation correctly', () => {
|
||||
const state = createTestState(['hello, world!'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5); // Start of ','
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_backward', () => {
|
||||
it('should move to start of previous word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Start of 'hello'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_end', () => {
|
||||
it('should move to end of current word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // End of 'hello'
|
||||
});
|
||||
|
||||
it('should move to end of next word if already at word end', () => {
|
||||
const state = createTestState(['hello world'], 0, 4);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // End of 'world'
|
||||
});
|
||||
});
|
||||
|
||||
describe('Position commands', () => {
|
||||
it('vim_move_to_line_start should move to column 0', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_move_to_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line_end should move to last character', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => {
|
||||
const state = createTestState([' hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3); // Position of 'h'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_line should move to row 0', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
|
||||
const action = { type: 'vim_move_to_first_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_last_line should move to last row', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = { type: 'vim_move_to_last_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should move to specific line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // 0-indexed
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should clamp to valid range', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 10 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // Last line
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit commands', () => {
|
||||
describe('vim_delete_char', () => {
|
||||
it('should delete single character', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hllo');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should delete multiple characters', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('ho');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should not delete past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete to end if no more words', () => {
|
||||
const state = createTestState(['hello world'], 0, 6);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello ');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_backward', () => {
|
||||
it('should delete from cursor to previous word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello test');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete multiple words backward', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete current line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line3']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should leave empty line when deleting all lines', () => {
|
||||
const state = createTestState(['only line'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_to_end_of_line', () => {
|
||||
it('should delete from cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Insert mode commands', () => {
|
||||
describe('vim_insert_at_cursor', () => {
|
||||
it('should not change cursor position', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_insert_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_cursor', () => {
|
||||
it('should move cursor right by one', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_line_end', () => {
|
||||
it('should move cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 3);
|
||||
const action = { type: 'vim_append_at_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_insert_at_line_start', () => {
|
||||
it('should move to first non-whitespace character', () => {
|
||||
const state = createTestState([' hello world'], 0, 5);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should move to column 0 for line with only whitespace', () => {
|
||||
const state = createTestState([' '], 0, 1);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_below', () => {
|
||||
it('should insert newline at end of current line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_open_line_below' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at end of current line and cursor moves to column 0
|
||||
expect(result.lines[0]).toBe('hello world\n');
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_above', () => {
|
||||
it('should insert newline before current line', () => {
|
||||
const state = createTestState(['hello', 'world'], 1, 2);
|
||||
const action = { type: 'vim_open_line_above' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at beginning of current line
|
||||
expect(result.lines).toEqual(['hello', '\nworld']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_escape_insert_mode', () => {
|
||||
it('should move cursor left', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change commands', () => {
|
||||
describe('vim_change_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_change_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_line', () => {
|
||||
it('should delete entire line content', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_movement', () => {
|
||||
it('should change characters to the left', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'h', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel world');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should change characters to the right', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'l', count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should change multiple lines down', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'j', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The movement 'j' with count 2 changes 2 lines starting from cursor row
|
||||
// Since we're at cursor position 2, it changes lines starting from current row
|
||||
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
const state = createTestState([''], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single character line', () => {
|
||||
const state = createTestState(['a'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Should be last character position
|
||||
});
|
||||
|
||||
it('should handle empty lines in multi-line text', () => {
|
||||
const state = createTestState(['line1', '', 'line3'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// Should move to next line with content
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve undo stack in operations', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
|
||||
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,887 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
findNextWordStart,
|
||||
findPrevWordStart,
|
||||
findWordEnd,
|
||||
getOffsetFromPosition,
|
||||
getPositionFromOffsets,
|
||||
getLineRangeOffsets,
|
||||
replaceRangeInternal,
|
||||
pushUndo,
|
||||
} from './text-buffer.js';
|
||||
import { cpLen } from '../../utils/textUtils.js';
|
||||
|
||||
export type VimAction = Extract<
|
||||
TextBufferAction,
|
||||
| { type: 'vim_delete_word_forward' }
|
||||
| { type: 'vim_delete_word_backward' }
|
||||
| { type: 'vim_delete_word_end' }
|
||||
| { type: 'vim_change_word_forward' }
|
||||
| { type: 'vim_change_word_backward' }
|
||||
| { type: 'vim_change_word_end' }
|
||||
| { type: 'vim_delete_line' }
|
||||
| { type: 'vim_change_line' }
|
||||
| { type: 'vim_delete_to_end_of_line' }
|
||||
| { type: 'vim_change_to_end_of_line' }
|
||||
| { type: 'vim_change_movement' }
|
||||
| { type: 'vim_move_left' }
|
||||
| { type: 'vim_move_right' }
|
||||
| { type: 'vim_move_up' }
|
||||
| { type: 'vim_move_down' }
|
||||
| { type: 'vim_move_word_forward' }
|
||||
| { type: 'vim_move_word_backward' }
|
||||
| { type: 'vim_move_word_end' }
|
||||
| { type: 'vim_delete_char' }
|
||||
| { type: 'vim_insert_at_cursor' }
|
||||
| { type: 'vim_append_at_cursor' }
|
||||
| { type: 'vim_open_line_below' }
|
||||
| { type: 'vim_open_line_above' }
|
||||
| { type: 'vim_append_at_line_end' }
|
||||
| { type: 'vim_insert_at_line_start' }
|
||||
| { type: 'vim_move_to_line_start' }
|
||||
| { type: 'vim_move_to_line_end' }
|
||||
| { type: 'vim_move_to_first_nonwhitespace' }
|
||||
| { type: 'vim_move_to_first_line' }
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line' }
|
||||
| { type: 'vim_escape_insert_mode' }
|
||||
>;
|
||||
|
||||
export function handleVimAction(
|
||||
state: TextBufferState,
|
||||
action: VimAction,
|
||||
): TextBufferState {
|
||||
const { lines, cursorRow, cursorCol } = state;
|
||||
// Cache text join to avoid repeated calculations for word operations
|
||||
let text: string | null = null;
|
||||
const getText = () => text ?? (text = lines.join('\n'));
|
||||
|
||||
switch (action.type) {
|
||||
case 'vim_delete_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let endOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, delete to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
// Cursor is already at the correct position after deletion
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let searchOffset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, change to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset !== currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
Math.min(currentOffset, endOffset),
|
||||
Math.max(currentOffset, endOffset),
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToDelete = Math.min(count, lines.length - cursorRow);
|
||||
const totalLines = lines.length;
|
||||
|
||||
if (totalLines === 1 || linesToDelete >= totalLines) {
|
||||
// If there's only one line, or we're deleting all remaining lines,
|
||||
// clear the content but keep one empty line (text editors should never be completely empty)
|
||||
const nextState = pushUndo(state);
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = pushUndo(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow, linesToDelete);
|
||||
|
||||
// Adjust cursor position
|
||||
const newCursorRow = Math.min(cursorRow, newLines.length - 1);
|
||||
const newCursorCol = 0; // Vim places cursor at beginning of line after dd
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_change_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToChange = Math.min(count, lines.length - cursorRow);
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_delete_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_movement': {
|
||||
const { movement, count } = action.payload;
|
||||
const totalLines = lines.length;
|
||||
|
||||
switch (movement) {
|
||||
case 'h': {
|
||||
// Left
|
||||
// Change N characters to the left
|
||||
const startCol = Math.max(0, cursorCol - count);
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
startCol,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'j': {
|
||||
// Down
|
||||
const linesToChange = Math.min(count, totalLines - cursorRow);
|
||||
if (linesToChange > 0) {
|
||||
if (totalLines === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } =
|
||||
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'k': {
|
||||
// Up
|
||||
const upLines = Math.min(count, cursorRow + 1);
|
||||
if (upLines > 0) {
|
||||
if (state.lines.length === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const startRow = Math.max(0, cursorRow - count + 1);
|
||||
const linesToChange = cursorRow - startRow + 1;
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
startRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const {
|
||||
startRow: newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
} = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow: startRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'l': {
|
||||
// Right
|
||||
// Change N characters to the right
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count),
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
case 'vim_move_left': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (newCol > 0) {
|
||||
newCol--;
|
||||
} else if (newRow > 0) {
|
||||
// Move to end of previous line
|
||||
newRow--;
|
||||
const prevLine = lines[newRow] || '';
|
||||
const prevLineLength = cpLen(prevLine);
|
||||
// Position on last character, or column 0 for empty lines
|
||||
newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_right': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const currentLine = lines[newRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
// Don't move past the last character of the line
|
||||
// For empty lines, stay at column 0; for non-empty lines, don't go past last character
|
||||
if (lineLength === 0) {
|
||||
// Empty line - try to move to next line
|
||||
if (newRow < lines.length - 1) {
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
} else if (newCol < lineLength - 1) {
|
||||
newCol++;
|
||||
} else if (newRow < lines.length - 1) {
|
||||
// At end of line - move to beginning of next line
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_up': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.max(0, cursorRow - count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_down': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.min(lines.length - 1, cursorRow + count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_forward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), offset);
|
||||
if (nextWordOffset > offset) {
|
||||
offset = nextWordOffset;
|
||||
} else {
|
||||
// No more words to move to
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_backward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findPrevWordStart(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_end': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findWordEnd(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_delete_char': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
|
||||
if (cursorCol < lineLength) {
|
||||
const deleteCount = Math.min(count, lineLength - cursorCol);
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cursorCol + deleteCount,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_insert_at_cursor': {
|
||||
// Just return state - mode change is handled elsewhere
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_append_at_cursor': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_open_line_below': {
|
||||
const { cursorRow, lines } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at end of current line
|
||||
const endOfLine = cpLen(lines[cursorRow] || '');
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_open_line_above': {
|
||||
const { cursorRow } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at beginning of current line
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
0,
|
||||
cursorRow,
|
||||
0,
|
||||
'\n',
|
||||
);
|
||||
|
||||
// Move cursor to the new line above
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_append_at_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_insert_at_line_start': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_start': {
|
||||
return {
|
||||
...state,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength > 0 ? lineLength - 1 : 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_nonwhitespace': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_line': {
|
||||
return {
|
||||
...state,
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_last_line': {
|
||||
const { lines } = state;
|
||||
const lastRow = lines.length - 1;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: lastRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line': {
|
||||
const { lineNumber } = action.payload;
|
||||
const { lines } = state;
|
||||
const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: targetRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_escape_insert_mode': {
|
||||
// Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
|
||||
const { cursorCol } = state;
|
||||
const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// This should never happen if TypeScript is working correctly
|
||||
const _exhaustiveCheck: never = action;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
export type VimMode = 'NORMAL' | 'INSERT';
|
||||
|
||||
interface VimModeContextType {
|
||||
vimEnabled: boolean;
|
||||
vimMode: VimMode;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setVimMode: (mode: VimMode) => void;
|
||||
}
|
||||
|
||||
const VimModeContext = createContext<VimModeContextType | undefined>(undefined);
|
||||
|
||||
export const VimModeProvider = ({
|
||||
children,
|
||||
settings,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
settings: LoadedSettings;
|
||||
}) => {
|
||||
const initialVimEnabled = settings.merged.vimMode ?? false;
|
||||
const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
|
||||
const [vimMode, setVimMode] = useState<VimMode>(
|
||||
initialVimEnabled ? 'NORMAL' : 'INSERT',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize vimEnabled from settings on mount
|
||||
const enabled = settings.merged.vimMode ?? false;
|
||||
setVimEnabled(enabled);
|
||||
// When vim mode is enabled, always start in NORMAL mode
|
||||
if (enabled) {
|
||||
setVimMode('NORMAL');
|
||||
}
|
||||
}, [settings.merged.vimMode]);
|
||||
|
||||
const toggleVimEnabled = useCallback(async () => {
|
||||
const newValue = !vimEnabled;
|
||||
setVimEnabled(newValue);
|
||||
// When enabling vim mode, start in NORMAL mode
|
||||
if (newValue) {
|
||||
setVimMode('NORMAL');
|
||||
}
|
||||
await settings.setValue(SettingScope.User, 'vimMode', newValue);
|
||||
return newValue;
|
||||
}, [vimEnabled, settings]);
|
||||
|
||||
const value = {
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
toggleVimEnabled,
|
||||
setVimMode,
|
||||
};
|
||||
|
||||
return (
|
||||
<VimModeContext.Provider value={value}>{children}</VimModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVimMode = () => {
|
||||
const context = useContext(VimModeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useVimMode must be used within a VimModeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -111,6 +111,7 @@ describe('useSlashCommandProcessor', () => {
|
|||
vi.fn(), // toggleCorgiMode
|
||||
mockSetQuittingMessages,
|
||||
vi.fn(), // openPrivacyNotice
|
||||
vi.fn(), // toggleVimEnabled
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ export const useSlashCommandProcessor = (
|
|||
toggleCorgiMode: () => void,
|
||||
setQuittingMessages: (message: HistoryItem[]) => void,
|
||||
openPrivacyNotice: () => void,
|
||||
toggleVimEnabled: () => Promise<boolean>,
|
||||
) => {
|
||||
const session = useSessionStats();
|
||||
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
|
||||
|
@ -139,6 +140,7 @@ export const useSlashCommandProcessor = (
|
|||
pendingItem: pendingCompressionItemRef.current,
|
||||
setPendingItem: setPendingCompressionItem,
|
||||
toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
},
|
||||
session: {
|
||||
stats: session.stats,
|
||||
|
@ -158,6 +160,7 @@ export const useSlashCommandProcessor = (
|
|||
pendingCompressionItemRef,
|
||||
setPendingCompressionItem,
|
||||
toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
@ -147,12 +147,15 @@ export function useKeypress(
|
|||
|
||||
let rl: readline.Interface;
|
||||
if (usePassthrough) {
|
||||
rl = readline.createInterface({ input: keypressStream });
|
||||
rl = readline.createInterface({
|
||||
input: keypressStream,
|
||||
escapeCodeTimeout: 0,
|
||||
});
|
||||
readline.emitKeypressEvents(keypressStream, rl);
|
||||
keypressStream.on('keypress', handleKeypress);
|
||||
stdin.on('data', handleRawKeypress);
|
||||
} else {
|
||||
rl = readline.createInterface({ input: stdin });
|
||||
rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 });
|
||||
readline.emitKeypressEvents(stdin, rl);
|
||||
stdin.on('keypress', handleKeypress);
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,774 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useReducer, useEffect } from 'react';
|
||||
import type { Key } from './useKeypress.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
|
||||
export type VimMode = 'NORMAL' | 'INSERT';
|
||||
|
||||
// Constants
|
||||
const DIGIT_MULTIPLIER = 10;
|
||||
const DEFAULT_COUNT = 1;
|
||||
const DIGIT_1_TO_9 = /^[1-9]$/;
|
||||
|
||||
// Command types
|
||||
const CMD_TYPES = {
|
||||
DELETE_WORD_FORWARD: 'dw',
|
||||
DELETE_WORD_BACKWARD: 'db',
|
||||
DELETE_WORD_END: 'de',
|
||||
CHANGE_WORD_FORWARD: 'cw',
|
||||
CHANGE_WORD_BACKWARD: 'cb',
|
||||
CHANGE_WORD_END: 'ce',
|
||||
DELETE_CHAR: 'x',
|
||||
DELETE_LINE: 'dd',
|
||||
CHANGE_LINE: 'cc',
|
||||
DELETE_TO_EOL: 'D',
|
||||
CHANGE_TO_EOL: 'C',
|
||||
CHANGE_MOVEMENT: {
|
||||
LEFT: 'ch',
|
||||
DOWN: 'cj',
|
||||
UP: 'ck',
|
||||
RIGHT: 'cl',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper function to clear pending state
|
||||
const createClearPendingState = () => ({
|
||||
count: 0,
|
||||
pendingOperator: null as 'g' | 'd' | 'c' | null,
|
||||
});
|
||||
|
||||
// State and action types for useReducer
|
||||
type VimState = {
|
||||
mode: VimMode;
|
||||
count: number;
|
||||
pendingOperator: 'g' | 'd' | 'c' | null;
|
||||
lastCommand: { type: string; count: number } | null;
|
||||
};
|
||||
|
||||
type VimAction =
|
||||
| { type: 'SET_MODE'; mode: VimMode }
|
||||
| { type: 'SET_COUNT'; count: number }
|
||||
| { type: 'INCREMENT_COUNT'; digit: number }
|
||||
| { type: 'CLEAR_COUNT' }
|
||||
| { type: 'SET_PENDING_OPERATOR'; operator: 'g' | 'd' | 'c' | null }
|
||||
| {
|
||||
type: 'SET_LAST_COMMAND';
|
||||
command: { type: string; count: number } | null;
|
||||
}
|
||||
| { type: 'CLEAR_PENDING_STATES' }
|
||||
| { type: 'ESCAPE_TO_NORMAL' };
|
||||
|
||||
const initialVimState: VimState = {
|
||||
mode: 'NORMAL',
|
||||
count: 0,
|
||||
pendingOperator: null,
|
||||
lastCommand: null,
|
||||
};
|
||||
|
||||
// Reducer function
|
||||
const vimReducer = (state: VimState, action: VimAction): VimState => {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, mode: action.mode };
|
||||
|
||||
case 'SET_COUNT':
|
||||
return { ...state, count: action.count };
|
||||
|
||||
case 'INCREMENT_COUNT':
|
||||
return { ...state, count: state.count * DIGIT_MULTIPLIER + action.digit };
|
||||
|
||||
case 'CLEAR_COUNT':
|
||||
return { ...state, count: 0 };
|
||||
|
||||
case 'SET_PENDING_OPERATOR':
|
||||
return { ...state, pendingOperator: action.operator };
|
||||
|
||||
case 'SET_LAST_COMMAND':
|
||||
return { ...state, lastCommand: action.command };
|
||||
|
||||
case 'CLEAR_PENDING_STATES':
|
||||
return {
|
||||
...state,
|
||||
...createClearPendingState(),
|
||||
};
|
||||
|
||||
case 'ESCAPE_TO_NORMAL':
|
||||
// Handle escape - clear all pending states (mode is updated via context)
|
||||
return {
|
||||
...state,
|
||||
...createClearPendingState(),
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook that provides vim-style editing functionality for text input.
|
||||
*
|
||||
* Features:
|
||||
* - Modal editing (INSERT/NORMAL modes)
|
||||
* - Navigation: h,j,k,l,w,b,e,0,$,^,gg,G with count prefixes
|
||||
* - Editing: x,a,i,o,O,A,I,d,c,D,C with count prefixes
|
||||
* - Complex operations: dd,cc,dw,cw,db,cb,de,ce
|
||||
* - Command repetition (.)
|
||||
* - Settings persistence
|
||||
*
|
||||
* @param buffer - TextBuffer instance for text manipulation
|
||||
* @param onSubmit - Optional callback for command submission
|
||||
* @returns Object with vim state and input handler
|
||||
*/
|
||||
export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
const { vimEnabled, vimMode, setVimMode } = useVimMode();
|
||||
const [state, dispatch] = useReducer(vimReducer, initialVimState);
|
||||
|
||||
// Sync vim mode from context to local state
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_MODE', mode: vimMode });
|
||||
}, [vimMode]);
|
||||
|
||||
// Helper to update mode in both reducer and context
|
||||
const updateMode = useCallback(
|
||||
(mode: VimMode) => {
|
||||
setVimMode(mode);
|
||||
dispatch({ type: 'SET_MODE', mode });
|
||||
},
|
||||
[setVimMode],
|
||||
);
|
||||
|
||||
// Helper functions using the reducer state
|
||||
const getCurrentCount = useCallback(
|
||||
() => state.count || DEFAULT_COUNT,
|
||||
[state.count],
|
||||
);
|
||||
|
||||
/** Executes common commands to eliminate duplication in dot (.) repeat command */
|
||||
const executeCommand = useCallback(
|
||||
(cmdType: string, count: number) => {
|
||||
switch (cmdType) {
|
||||
case CMD_TYPES.DELETE_WORD_FORWARD: {
|
||||
buffer.vimDeleteWordForward(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_WORD_BACKWARD: {
|
||||
buffer.vimDeleteWordBackward(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_WORD_END: {
|
||||
buffer.vimDeleteWordEnd(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_WORD_FORWARD: {
|
||||
buffer.vimChangeWordForward(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_WORD_BACKWARD: {
|
||||
buffer.vimChangeWordBackward(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_WORD_END: {
|
||||
buffer.vimChangeWordEnd(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_CHAR: {
|
||||
buffer.vimDeleteChar(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_LINE: {
|
||||
buffer.vimDeleteLine(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_LINE: {
|
||||
buffer.vimChangeLine(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.LEFT:
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.DOWN:
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.UP:
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.RIGHT: {
|
||||
const movementMap: Record<string, 'h' | 'j' | 'k' | 'l'> = {
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.LEFT]: 'h',
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.DOWN]: 'j',
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.UP]: 'k',
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.RIGHT]: 'l',
|
||||
};
|
||||
const movementType = movementMap[cmdType];
|
||||
if (movementType) {
|
||||
buffer.vimChangeMovement(movementType, count);
|
||||
updateMode('INSERT');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_TO_EOL: {
|
||||
buffer.vimDeleteToEndOfLine();
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_TO_EOL: {
|
||||
buffer.vimChangeToEndOfLine();
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[buffer, updateMode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles key input in INSERT mode
|
||||
* @param normalizedKey - The normalized key input
|
||||
* @returns boolean indicating if the key was handled
|
||||
*/
|
||||
const handleInsertModeInput = useCallback(
|
||||
(normalizedKey: Key): boolean => {
|
||||
// Handle escape key immediately - switch to NORMAL mode on any escape
|
||||
if (normalizedKey.name === 'escape') {
|
||||
// Vim behavior: move cursor left when exiting insert mode (unless at beginning of line)
|
||||
buffer.vimEscapeInsertMode();
|
||||
dispatch({ type: 'ESCAPE_TO_NORMAL' });
|
||||
updateMode('NORMAL');
|
||||
return true;
|
||||
}
|
||||
|
||||
// In INSERT mode, let InputPrompt handle completion keys and special commands
|
||||
if (
|
||||
normalizedKey.name === 'tab' ||
|
||||
(normalizedKey.name === 'return' && !normalizedKey.ctrl) ||
|
||||
normalizedKey.name === 'up' ||
|
||||
normalizedKey.name === 'down'
|
||||
) {
|
||||
return false; // Let InputPrompt handle completion
|
||||
}
|
||||
|
||||
// Let InputPrompt handle Ctrl+V for clipboard image pasting
|
||||
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
|
||||
return false; // Let InputPrompt handle clipboard functionality
|
||||
}
|
||||
|
||||
// Special handling for Enter key to allow command submission (lower priority than completion)
|
||||
if (
|
||||
normalizedKey.name === 'return' &&
|
||||
!normalizedKey.ctrl &&
|
||||
!normalizedKey.meta
|
||||
) {
|
||||
if (buffer.text.trim() && onSubmit) {
|
||||
// Handle command submission directly
|
||||
const submittedValue = buffer.text;
|
||||
buffer.setText('');
|
||||
onSubmit(submittedValue);
|
||||
return true;
|
||||
}
|
||||
return true; // Handled by vim (even if no onSubmit callback)
|
||||
}
|
||||
|
||||
// useKeypress already provides the correct format for TextBuffer
|
||||
buffer.handleInput(normalizedKey);
|
||||
return true; // Handled by vim
|
||||
},
|
||||
[buffer, dispatch, updateMode, onSubmit],
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalizes key input to ensure all required properties are present
|
||||
* @param key - Raw key input
|
||||
* @returns Normalized key with all properties
|
||||
*/
|
||||
const normalizeKey = useCallback(
|
||||
(key: Key): Key => ({
|
||||
name: key.name || '',
|
||||
sequence: key.sequence || '',
|
||||
ctrl: key.ctrl || false,
|
||||
meta: key.meta || false,
|
||||
shift: key.shift || false,
|
||||
paste: key.paste || false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles change movement commands (ch, cj, ck, cl)
|
||||
* @param movement - The movement direction
|
||||
* @returns boolean indicating if command was handled
|
||||
*/
|
||||
const handleChangeMovement = useCallback(
|
||||
(movement: 'h' | 'j' | 'k' | 'l'): boolean => {
|
||||
const count = getCurrentCount();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
buffer.vimChangeMovement(movement, count);
|
||||
updateMode('INSERT');
|
||||
|
||||
const cmdTypeMap = {
|
||||
h: CMD_TYPES.CHANGE_MOVEMENT.LEFT,
|
||||
j: CMD_TYPES.CHANGE_MOVEMENT.DOWN,
|
||||
k: CMD_TYPES.CHANGE_MOVEMENT.UP,
|
||||
l: CMD_TYPES.CHANGE_MOVEMENT.RIGHT,
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: cmdTypeMap[movement], count },
|
||||
});
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
return true;
|
||||
},
|
||||
[getCurrentCount, dispatch, buffer, updateMode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles operator-motion commands (dw/cw, db/cb, de/ce)
|
||||
* @param operator - The operator type ('d' for delete, 'c' for change)
|
||||
* @param motion - The motion type ('w', 'b', 'e')
|
||||
* @returns boolean indicating if command was handled
|
||||
*/
|
||||
const handleOperatorMotion = useCallback(
|
||||
(operator: 'd' | 'c', motion: 'w' | 'b' | 'e'): boolean => {
|
||||
const count = getCurrentCount();
|
||||
|
||||
const commandMap = {
|
||||
d: {
|
||||
w: CMD_TYPES.DELETE_WORD_FORWARD,
|
||||
b: CMD_TYPES.DELETE_WORD_BACKWARD,
|
||||
e: CMD_TYPES.DELETE_WORD_END,
|
||||
},
|
||||
c: {
|
||||
w: CMD_TYPES.CHANGE_WORD_FORWARD,
|
||||
b: CMD_TYPES.CHANGE_WORD_BACKWARD,
|
||||
e: CMD_TYPES.CHANGE_WORD_END,
|
||||
},
|
||||
};
|
||||
|
||||
const cmdType = commandMap[operator][motion];
|
||||
executeCommand(cmdType, count);
|
||||
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: cmdType, count },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
|
||||
return true;
|
||||
},
|
||||
[getCurrentCount, executeCommand, dispatch],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): boolean => {
|
||||
if (!vimEnabled) {
|
||||
return false; // Let InputPrompt handle it
|
||||
}
|
||||
|
||||
let normalizedKey: Key;
|
||||
try {
|
||||
normalizedKey = normalizeKey(key);
|
||||
} catch (error) {
|
||||
// Handle malformed key inputs gracefully
|
||||
console.warn('Malformed key input in vim mode:', key, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle INSERT mode
|
||||
if (state.mode === 'INSERT') {
|
||||
return handleInsertModeInput(normalizedKey);
|
||||
}
|
||||
|
||||
// Handle NORMAL mode
|
||||
if (state.mode === 'NORMAL') {
|
||||
// Handle Escape key in NORMAL mode - clear all pending states
|
||||
if (normalizedKey.name === 'escape') {
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
return true; // Handled by vim
|
||||
}
|
||||
|
||||
// Handle count input (numbers 1-9, and 0 if count > 0)
|
||||
if (
|
||||
DIGIT_1_TO_9.test(normalizedKey.sequence) ||
|
||||
(normalizedKey.sequence === '0' && state.count > 0)
|
||||
) {
|
||||
dispatch({
|
||||
type: 'INCREMENT_COUNT',
|
||||
digit: parseInt(normalizedKey.sequence, 10),
|
||||
});
|
||||
return true; // Handled by vim
|
||||
}
|
||||
|
||||
const repeatCount = getCurrentCount();
|
||||
|
||||
switch (normalizedKey.sequence) {
|
||||
case 'h': {
|
||||
// Check if this is part of a change command (ch)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('h');
|
||||
}
|
||||
|
||||
// Normal left movement
|
||||
buffer.vimMoveLeft(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'j': {
|
||||
// Check if this is part of a change command (cj)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('j');
|
||||
}
|
||||
|
||||
// Normal down movement
|
||||
buffer.vimMoveDown(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'k': {
|
||||
// Check if this is part of a change command (ck)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('k');
|
||||
}
|
||||
|
||||
// Normal up movement
|
||||
buffer.vimMoveUp(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'l': {
|
||||
// Check if this is part of a change command (cl)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('l');
|
||||
}
|
||||
|
||||
// Normal right movement
|
||||
buffer.vimMoveRight(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'w': {
|
||||
// Check if this is part of a delete or change command (dw/cw)
|
||||
if (state.pendingOperator === 'd') {
|
||||
return handleOperatorMotion('d', 'w');
|
||||
}
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'w');
|
||||
}
|
||||
|
||||
// Normal word movement
|
||||
buffer.vimMoveWordForward(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'b': {
|
||||
// Check if this is part of a delete or change command (db/cb)
|
||||
if (state.pendingOperator === 'd') {
|
||||
return handleOperatorMotion('d', 'b');
|
||||
}
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'b');
|
||||
}
|
||||
|
||||
// Normal backward word movement
|
||||
buffer.vimMoveWordBackward(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'e': {
|
||||
// Check if this is part of a delete or change command (de/ce)
|
||||
if (state.pendingOperator === 'd') {
|
||||
return handleOperatorMotion('d', 'e');
|
||||
}
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'e');
|
||||
}
|
||||
|
||||
// Normal word end movement
|
||||
buffer.vimMoveWordEnd(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'x': {
|
||||
// Delete character under cursor
|
||||
buffer.vimDeleteChar(repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.DELETE_CHAR, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'i': {
|
||||
// Enter INSERT mode at current position
|
||||
buffer.vimInsertAtCursor();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'a': {
|
||||
// Enter INSERT mode after current position
|
||||
buffer.vimAppendAtCursor();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'o': {
|
||||
// Insert new line after current line and enter INSERT mode
|
||||
buffer.vimOpenLineBelow();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'O': {
|
||||
// Insert new line before current line and enter INSERT mode
|
||||
buffer.vimOpenLineAbove();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '0': {
|
||||
// Move to start of line
|
||||
buffer.vimMoveToLineStart();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '$': {
|
||||
// Move to end of line
|
||||
buffer.vimMoveToLineEnd();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '^': {
|
||||
// Move to first non-whitespace character
|
||||
buffer.vimMoveToFirstNonWhitespace();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'g': {
|
||||
if (state.pendingOperator === 'g') {
|
||||
// Second 'g' - go to first line (gg command)
|
||||
buffer.vimMoveToFirstLine();
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
} else {
|
||||
// First 'g' - wait for second g
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' });
|
||||
}
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'G': {
|
||||
if (state.count > 0) {
|
||||
// Go to specific line number (1-based) when a count was provided
|
||||
buffer.vimMoveToLine(state.count);
|
||||
} else {
|
||||
// Go to last line when no count was provided
|
||||
buffer.vimMoveToLastLine();
|
||||
}
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'I': {
|
||||
// Enter INSERT mode at start of line (first non-whitespace)
|
||||
buffer.vimInsertAtLineStart();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'A': {
|
||||
// Enter INSERT mode at end of line
|
||||
buffer.vimAppendAtLineEnd();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'd': {
|
||||
if (state.pendingOperator === 'd') {
|
||||
// Second 'd' - delete N lines (dd command)
|
||||
const repeatCount = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.DELETE_LINE, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.DELETE_LINE, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
} else {
|
||||
// First 'd' - wait for movement command
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'd' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'c': {
|
||||
if (state.pendingOperator === 'c') {
|
||||
// Second 'c' - change N entire lines (cc command)
|
||||
const repeatCount = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.CHANGE_LINE, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.CHANGE_LINE, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
} else {
|
||||
// First 'c' - wait for movement command
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'c' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'D': {
|
||||
// Delete from cursor to end of line (equivalent to d$)
|
||||
executeCommand(CMD_TYPES.DELETE_TO_EOL, 1);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.DELETE_TO_EOL, count: 1 },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'C': {
|
||||
// Change from cursor to end of line (equivalent to c$)
|
||||
executeCommand(CMD_TYPES.CHANGE_TO_EOL, 1);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: 1 },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '.': {
|
||||
// Repeat last command
|
||||
if (state.lastCommand) {
|
||||
const cmdData = state.lastCommand;
|
||||
|
||||
// All repeatable commands are now handled by executeCommand
|
||||
executeCommand(cmdData.type, cmdData.count);
|
||||
}
|
||||
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Check for arrow keys (they have different sequences but known names)
|
||||
if (normalizedKey.name === 'left') {
|
||||
// Left arrow - same as 'h'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('h');
|
||||
}
|
||||
|
||||
// Normal left movement (same as 'h')
|
||||
buffer.vimMoveLeft(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedKey.name === 'down') {
|
||||
// Down arrow - same as 'j'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('j');
|
||||
}
|
||||
|
||||
// Normal down movement (same as 'j')
|
||||
buffer.vimMoveDown(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedKey.name === 'up') {
|
||||
// Up arrow - same as 'k'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('k');
|
||||
}
|
||||
|
||||
// Normal up movement (same as 'k')
|
||||
buffer.vimMoveUp(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedKey.name === 'right') {
|
||||
// Right arrow - same as 'l'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('l');
|
||||
}
|
||||
|
||||
// Normal right movement (same as 'l')
|
||||
buffer.vimMoveRight(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unknown command, clear count and pending states
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
return true; // Still handled by vim to prevent other handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Not handled by vim
|
||||
},
|
||||
[
|
||||
vimEnabled,
|
||||
normalizeKey,
|
||||
handleInsertModeInput,
|
||||
state.mode,
|
||||
state.count,
|
||||
state.pendingOperator,
|
||||
state.lastCommand,
|
||||
dispatch,
|
||||
getCurrentCount,
|
||||
handleChangeMovement,
|
||||
handleOperatorMotion,
|
||||
buffer,
|
||||
executeCommand,
|
||||
updateMode,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
mode: state.mode,
|
||||
vimModeEnabled: vimEnabled,
|
||||
handleInput, // Expose the input handler for InputPrompt to use
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue