diff --git a/README.md b/README.md index f82a2435..5b9a3f52 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ NeverWrite also writes local diagnostic logs under the app data `logs/` director ### Editing and reading - Markdown editing with CodeMirror 6 +- Optional Vim key bindings for modal editing, with Vim-style ex commands and relative line numbers - Wikilink suggestions, resolution, and navigation - Live preview with tasks, tables, embeds, math, and YouTube previews - Frontmatter/properties editing diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 0d006e08..aea11959 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -31,6 +31,7 @@ "@iconify-json/catppuccin": "^1.2.17", "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", + "@replit/codemirror-vim": "^6.3.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-web-links": "^0.12.0", @@ -2960,6 +2961,19 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@replit/codemirror-vim": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz", + "integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==", + "license": "MIT", + "peerDependencies": { + "@codemirror/commands": "6.x.x", + "@codemirror/language": "6.x.x", + "@codemirror/search": "6.x.x", + "@codemirror/state": "6.x.x", + "@codemirror/view": "6.x.x" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9c3f8add..14b708c9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -58,6 +58,7 @@ "@iconify-json/catppuccin": "^1.2.17", "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", + "@replit/codemirror-vim": "^6.3.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/apps/desktop/src/app/store/settingsStore.test.ts b/apps/desktop/src/app/store/settingsStore.test.ts index a5105e47..f91cf8ce 100644 --- a/apps/desktop/src/app/store/settingsStore.test.ts +++ b/apps/desktop/src/app/store/settingsStore.test.ts @@ -46,6 +46,39 @@ describe("settingsStore", () => { expect(useSettingsStore.getState().fileTreeStickyFolders).toBe(true); expect(useSettingsStore.getState().editorAutosaveDelayMs).toBe(300); expect(useSettingsStore.getState().fileTreeExtensionFilter).toEqual([]); + expect(useSettingsStore.getState().vimModeEnabled).toBe(false); + expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(false); + }); + + it("persists vim settings globally across vaults", () => { + useVaultStore.setState({ vaultPath: "/vaults/vim-one" }); + + useSettingsStore.getState().setSetting("vimModeEnabled", true); + useSettingsStore + .getState() + .setSetting("vimRelativeLineNumbers", true); + + expect(useSettingsStore.getState().vimModeEnabled).toBe(true); + expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(true); + expect( + JSON.parse(localStorage.getItem("neverwrite:settings") ?? ""), + ).toMatchObject({ + state: { + vimModeEnabled: true, + vimRelativeLineNumbers: true, + }, + }); + expect( + JSON.parse( + localStorage.getItem("neverwrite:settings:/vaults/vim-one") ?? + "", + ).state, + ).not.toHaveProperty("vimModeEnabled"); + + useVaultStore.setState({ vaultPath: "/vaults/vim-two" }); + + expect(useSettingsStore.getState().vimModeEnabled).toBe(true); + expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(true); }); it("persists settings per vault", () => { @@ -77,6 +110,34 @@ describe("settingsStore", () => { }); }); + it("loads vim settings from global storage over vault storage", () => { + localStorage.setItem( + "neverwrite:settings", + JSON.stringify({ + state: { + vimModeEnabled: true, + vimRelativeLineNumbers: true, + }, + }), + ); + localStorage.setItem( + "neverwrite:settings:/vaults/vim-legacy", + JSON.stringify({ + state: { + inlineReviewEnabled: false, + vimModeEnabled: false, + vimRelativeLineNumbers: false, + }, + }), + ); + + useVaultStore.setState({ vaultPath: "/vaults/vim-legacy" }); + + expect(useSettingsStore.getState().inlineReviewEnabled).toBe(false); + expect(useSettingsStore.getState().vimModeEnabled).toBe(true); + expect(useSettingsStore.getState().vimRelativeLineNumbers).toBe(true); + }); + it("persists terminal settings per vault", () => { useVaultStore.setState({ vaultPath: "/vaults/terminal" }); diff --git a/apps/desktop/src/app/store/settingsStore.ts b/apps/desktop/src/app/store/settingsStore.ts index 211e78f8..8e4c2332 100644 --- a/apps/desktop/src/app/store/settingsStore.ts +++ b/apps/desktop/src/app/store/settingsStore.ts @@ -28,6 +28,8 @@ export interface Settings { spellcheckSecondaryLanguage: SpellcheckSecondaryLanguage; grammarCheckEnabled: boolean; grammarCheckServerUrl: string; + vimModeEnabled: boolean; + vimRelativeLineNumbers: boolean; // Navigation fileTreeScale: number; // 90–140 @@ -58,6 +60,12 @@ interface SettingsStore extends Settings { const SETTINGS_KEY_PREFIX = "neverwrite:settings:"; const SETTINGS_KEY_FALLBACK = "neverwrite:settings"; const LAST_VAULT_KEY = "neverwrite:lastVaultPath"; +const GLOBAL_SETTING_KEYS = [ + "vimModeEnabled", + "vimRelativeLineNumbers", +] as const; + +type GlobalSettingKey = (typeof GLOBAL_SETTING_KEYS)[number]; export type EditorFontFamily = | "system" @@ -178,6 +186,8 @@ const defaults: Settings = { spellcheckSecondaryLanguage: null, grammarCheckEnabled: false, grammarCheckServerUrl: "", + vimModeEnabled: false, + vimRelativeLineNumbers: false, fileTreeScale: 114, agentsSidebarScale: 100, fileTreeStickyFolders: true, @@ -334,6 +344,36 @@ export function normalizeEditorFontFamily( : fallback; } +function extractPersistedState(raw: string | null): Partial | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as { state?: unknown }; + if (!parsed?.state || typeof parsed.state !== "object") return null; + return parsed.state as Partial; + } catch { + return null; + } +} + +function hasVaultScopedSettings(raw: string | null) { + const state = extractPersistedState(raw); + if (!state) return false; + + return Object.keys(state).some( + (key) => !GLOBAL_SETTING_KEYS.includes(key as GlobalSettingKey), + ); +} + +function hasStoredVimSettings(raw: string | null) { + const state = extractPersistedState(raw); + if (!state) return false; + + return GLOBAL_SETTING_KEYS.some((key) => + Object.prototype.hasOwnProperty.call(state, key), + ); +} + function extractSettingsFromStorage(raw: string | null): Settings | null { if (!raw) return null; @@ -402,6 +442,11 @@ function extractSettingsFromStorage(raw: string | null): Settings | null { typeof parsed.state.grammarCheckServerUrl === "string" ? parsed.state.grammarCheckServerUrl.trim() : defaults.grammarCheckServerUrl, + vimModeEnabled: + parsed.state.vimModeEnabled ?? defaults.vimModeEnabled, + vimRelativeLineNumbers: + parsed.state.vimRelativeLineNumbers ?? + defaults.vimRelativeLineNumbers, fileTreeScale: normalizeIntInRange( parsed.state.fileTreeScale, defaults.fileTreeScale, @@ -516,6 +561,8 @@ function pickSettings(state: SettingsStore): Settings { spellcheckSecondaryLanguage: state.spellcheckSecondaryLanguage, grammarCheckEnabled: state.grammarCheckEnabled, grammarCheckServerUrl: state.grammarCheckServerUrl, + vimModeEnabled: state.vimModeEnabled, + vimRelativeLineNumbers: state.vimRelativeLineNumbers, fileTreeScale: state.fileTreeScale, agentsSidebarScale: state.agentsSidebarScale, fileTreeStickyFolders: state.fileTreeStickyFolders, @@ -533,6 +580,35 @@ function pickSettings(state: SettingsStore): Settings { }; } +function pickVaultSettings(settings: Settings): Partial { + const vaultSettings: Partial = { ...settings }; + for (const key of GLOBAL_SETTING_KEYS) { + delete vaultSettings[key]; + } + return vaultSettings; +} + +function pickGlobalSettings( + settings: Settings, +): Pick { + return { + vimModeEnabled: settings.vimModeEnabled, + vimRelativeLineNumbers: settings.vimRelativeLineNumbers, + }; +} + +function mergeGlobalSettings(settings: Settings): Settings { + const global = extractSettingsFromStorage( + safeStorageGetItem(SETTINGS_KEY_FALLBACK), + ); + if (!global) return settings; + + return { + ...settings, + ...pickGlobalSettings(global), + }; +} + function getStorageKey(vaultPath: string | null): string { return vaultPath ? `${SETTINGS_KEY_PREFIX}${vaultPath}` @@ -543,15 +619,15 @@ function migrateGlobalSettings(vaultPath: string) { try { const vaultKey = getStorageKey(vaultPath); if (safeStorageGetItem(vaultKey)) return; // already migrated - const global = extractSettingsFromStorage( - safeStorageGetItem(SETTINGS_KEY_FALLBACK), - ); + const globalRaw = safeStorageGetItem(SETTINGS_KEY_FALLBACK); + if (!hasVaultScopedSettings(globalRaw)) return; + const global = extractSettingsFromStorage(globalRaw); if (!global) return; safeStorageSetItem( vaultKey, JSON.stringify({ state: { - ...global, + ...pickVaultSettings(global), editorSpellcheck: defaults.editorSpellcheck, }, }), @@ -593,6 +669,21 @@ function migrateGlobalSpellcheckToVault(vaultPath: string) { } } +function migrateVaultVimSettingsToGlobal(vaultRaw: string | null) { + try { + if (hasStoredVimSettings(safeStorageGetItem(SETTINGS_KEY_FALLBACK))) { + return; + } + if (!hasStoredVimSettings(vaultRaw)) return; + + const vaultSettings = extractSettingsFromStorage(vaultRaw); + if (!vaultSettings) return; + saveGlobalSettings(vaultSettings); + } catch { + // localStorage unavailable + } +} + function loadSettings(vaultPath: string | null): Settings { try { if (vaultPath) { @@ -600,7 +691,11 @@ function loadSettings(vaultPath: string | null): Settings { migrateGlobalSpellcheckToVault(vaultPath); } const raw = safeStorageGetItem(getStorageKey(vaultPath)); - return extractSettingsFromStorage(raw) ?? defaults; + if (vaultPath) { + migrateVaultVimSettingsToGlobal(raw); + } + const settings = extractSettingsFromStorage(raw) ?? defaults; + return vaultPath ? mergeGlobalSettings(settings) : settings; } catch { return defaults; } @@ -623,8 +718,17 @@ function getEffectiveVaultPath( function saveSettings(vaultPath: string | null, settings: Settings) { try { + if (vaultPath) { + safeStorageSetItem( + getStorageKey(vaultPath), + JSON.stringify({ state: pickVaultSettings(settings) }), + ); + saveGlobalSettings(settings); + return; + } + safeStorageSetItem( - getStorageKey(vaultPath), + SETTINGS_KEY_FALLBACK, JSON.stringify({ state: settings }), ); } catch { @@ -632,6 +736,22 @@ function saveSettings(vaultPath: string | null, settings: Settings) { } } +function saveGlobalSettings(settings: Settings) { + const existing = extractPersistedState( + safeStorageGetItem(SETTINGS_KEY_FALLBACK), + ); + + safeStorageSetItem( + SETTINGS_KEY_FALLBACK, + JSON.stringify({ + state: { + ...(existing ?? {}), + ...pickGlobalSettings(settings), + }, + }), + ); +} + // Read vault path synchronously at module load to avoid a flash of defaults. // In a settings window the vault is passed as a URL param; otherwise fall back to localStorage. function readInitialVaultPath(): string | null { diff --git a/apps/desktop/src/features/editor/Editor.tsx b/apps/desktop/src/features/editor/Editor.tsx index 1ba32845..decec4a5 100644 --- a/apps/desktop/src/features/editor/Editor.tsx +++ b/apps/desktop/src/features/editor/Editor.tsx @@ -56,8 +56,12 @@ import { import { getDesktopPlatform } from "../../app/utils/platform"; import { logError, logWarn } from "../../app/utils/runtimeLog"; -export const REQUEST_CLOSE_ACTIVE_TAB_EVENT = - "neverwrite:editor:request-close-active-tab"; +import { + REQUEST_CLOSE_ACTIVE_TAB_EVENT, + REQUEST_SAVE_ACTIVE_TAB_EVENT, +} from "./editorActionEvents"; +// Re-export for existing importers (e.g. UnifiedBar). +export { REQUEST_CLOSE_ACTIVE_TAB_EVENT }; import { wikilinkExtension } from "./extensions/wikilinks"; import { urlLinksExtension } from "./extensions/urlLinks"; import { imagePasteDropExtension } from "./extensions/imagePasteDrop"; @@ -108,14 +112,19 @@ import { spellcheckCompartment, spellcheckDecorationsCompartment, grammarDecorationsCompartment, + vimCompartment, + lineNumberCompartment, getSyntaxExtension, getLivePreviewExtension, getAlignmentExtension, getWrappingExtension, getSpellcheckExtension, + getVimExtension, + getLineNumberExtension, getEditorFontFamily, getEditorHorizontalInset, } from "./editorExtensions"; +import { registerVimExCommands } from "./extensions/vimCommands"; import { mergeViewCompartment } from "./extensions/mergeViewDiff"; import { syncMergeViewForPaths } from "./mergeViewSync"; import { resolveEditorTargetForOpenTab } from "./editorTargetResolver"; @@ -189,6 +198,10 @@ import { type NoteStateCacheCollection, } from "./noteStateCache"; +// Map vim ex-commands (:w, :q, :wq) onto NeverWrite's save/close actions. +// Idempotent and global to the vim engine, so register once at module load. +registerVimExCommands(); + type SavedNoteDetail = { id: string; path: string; @@ -466,6 +479,10 @@ export function Editor({ const livePreviewEnabled = useSettingsStore((s) => s.livePreviewEnabled); const inlineReviewEnabled = useSettingsStore((s) => s.inlineReviewEnabled); const tabSize = useSettingsStore((s) => s.tabSize); + const vimModeEnabled = useSettingsStore((s) => s.vimModeEnabled); + const vimRelativeLineNumbers = useSettingsStore( + (s) => s.vimRelativeLineNumbers, + ); const editorSpellcheck = useSettingsStore((s) => s.editorSpellcheck); const spellcheckPrimaryLanguage = useSettingsStore( (s) => s.spellcheckPrimaryLanguage, @@ -2285,6 +2302,19 @@ export function Editor({ return EditorState.create({ doc, extensions: [ + // Vim must come before the default keymaps so its modal + // bindings take precedence when enabled. + vimCompartment.of( + getVimExtension( + useSettingsStore.getState().vimModeEnabled, + ), + ), + lineNumberCompartment.of( + getLineNumberExtension( + useSettingsStore.getState().livePreviewEnabled, + useSettingsStore.getState().vimRelativeLineNumbers, + ), + ), history(), markdown({ base: markdownLanguage, @@ -3008,6 +3038,28 @@ export function Editor({ isInternalRef.current = false; if (!view) return; + // A restored cached state carries the vim/line-number compartment + // config it had when it was stashed. If the user toggled vim mode (or + // relative numbers) while another note was active, that config is now + // stale, so reconfigure it to the current settings. Freshly created + // states already read the live settings in createEditorState. + if (savedStateIsCurrent) { + const settings = useSettingsStore.getState(); + view.dispatch({ + effects: [ + vimCompartment.reconfigure( + getVimExtension(settings.vimModeEnabled), + ), + lineNumberCompartment.reconfigure( + getLineNumberExtension( + settings.livePreviewEnabled, + settings.vimRelativeLineNumbers, + ), + ), + ], + }); + } + // Re-insert scroll header if setState detached it if ( scrollHeaderRef.current && @@ -3206,6 +3258,12 @@ export function Editor({ livePreviewEnabled, ), ), + lineNumberCompartment.reconfigure( + getLineNumberExtension( + livePreviewEnabled, + useSettingsStore.getState().vimRelativeLineNumbers, + ), + ), ], }); if (view && activeNoteId && didModeChange) { @@ -3557,9 +3615,23 @@ export function Editor({ EditorState.tabSize.of(tabSize), indentUnit.of(" ".repeat(tabSize)), ]), + vimCompartment.reconfigure(getVimExtension(vimModeEnabled)), + lineNumberCompartment.reconfigure( + getLineNumberExtension( + livePreviewEnabled, + vimRelativeLineNumbers, + ), + ), ], }); - }, [justifyText, lineWrapping, tabSize]); + }, [ + justifyText, + lineWrapping, + tabSize, + vimModeEnabled, + vimRelativeLineNumbers, + livePreviewEnabled, + ]); useEffect(() => { const view = viewRef.current; @@ -3673,6 +3745,28 @@ export function Editor({ ); }, [closeActiveTabWithSave, isInteractionActive]); + useEffect(() => { + if (!isInteractionActive) return; + const handleSaveRequest = () => { + const { tabs, activeTabId } = getPaneSnapshot(); + const tab = tabs.find((item) => item.id === activeTabId); + if (!tab || !isNoteTab(tab)) return; + const content = + viewRef.current?.state.doc.toString() ?? tab.content; + void saveNow(tab, content); + }; + + window.addEventListener( + REQUEST_SAVE_ACTIVE_TAB_EVENT, + handleSaveRequest, + ); + return () => + window.removeEventListener( + REQUEST_SAVE_ACTIVE_TAB_EVENT, + handleSaveRequest, + ); + }, [getPaneSnapshot, isInteractionActive, saveNow]); + const editorShellStyle = { "--editor-font-size": `${editorFontSize}px`, "--editor-font-family": getEditorFontFamily(editorFontFamily), diff --git a/apps/desktop/src/features/editor/editorActionEvents.ts b/apps/desktop/src/features/editor/editorActionEvents.ts new file mode 100644 index 00000000..c1202394 --- /dev/null +++ b/apps/desktop/src/features/editor/editorActionEvents.ts @@ -0,0 +1,9 @@ +// Window-level events the editor listens to so decoupled callers (toolbar +// buttons, vim ex-commands) can trigger active-tab actions without holding a +// reference to the live EditorView. Mirrors the existing close-tab event. + +export const REQUEST_CLOSE_ACTIVE_TAB_EVENT = + "neverwrite:editor:request-close-active-tab"; + +export const REQUEST_SAVE_ACTIVE_TAB_EVENT = + "neverwrite:editor:request-save-active-tab"; diff --git a/apps/desktop/src/features/editor/editorExtensions.ts b/apps/desktop/src/features/editor/editorExtensions.ts index 4c2a3f47..f695574d 100644 --- a/apps/desktop/src/features/editor/editorExtensions.ts +++ b/apps/desktop/src/features/editor/editorExtensions.ts @@ -3,6 +3,8 @@ import { EditorView, type ViewUpdate, ViewPlugin, + gutter, + GutterMarker, lineNumbers, } from "@codemirror/view"; import { Compartment, RangeSetBuilder } from "@codemirror/state"; @@ -13,8 +15,10 @@ import type { SpellcheckLanguage, SpellcheckSecondaryLanguage, } from "../../app/store/settingsStore"; +import { vim } from "@replit/codemirror-vim"; import { useVaultStore } from "../../app/store/vaultStore"; import { livePreviewExtension } from "./extensions/livePreview"; +import { vimStatusBarExtension } from "./extensions/vimStatusBar"; import { resolveWikilink } from "./wikilinkResolution"; import { navigateWikilink, getNoteLinkTarget } from "./wikilinkNavigation"; import { resolveFrontendSpellcheckLanguage } from "../spellcheck/api"; @@ -161,6 +165,10 @@ export const spellcheckCompartment = new Compartment(); export const spellcheckDecorationsCompartment = new Compartment(); // Compartment for grammar check decorations export const grammarDecorationsCompartment = new Compartment(); +// Compartment for vim modal editing (keymap + mode status bar) +export const vimCompartment = new Compartment(); +// Compartment for the line-number gutter (absolute vs. vim relative numbering) +export const lineNumberCompartment = new Compartment(); const sourceHeadingDecoration = Decoration.mark({ class: "cm-source-heading", @@ -224,7 +232,6 @@ export function getLivePreviewExtension( EditorView.editorAttributes.of({ "data-live-preview": "false", }), - lineNumbers(), ]; } const vaultPath = useVaultStore.getState().vaultPath; @@ -241,6 +248,77 @@ export function getLivePreviewExtension( ]; } +// Vim modal editing. Must take precedence over the default keymap, so the +// caller places this compartment ahead of the default/history/search keymaps +// in the extension graph. Returns an empty extension when disabled so the +// editor reverts to its normal behavior on reconfigure. +export function getVimExtension(enabled: boolean) { + if (!enabled) return []; + return [vim(), vimStatusBarExtension]; +} + +function formatRelativeLineNumber(lineNo: number, cursorLine: number) { + return lineNo === cursorLine + ? String(lineNo) + : String(Math.abs(lineNo - cursorLine)); +} + +class RelativeLineNumberMarker extends GutterMarker { + private readonly text: string; + + constructor(text: string) { + super(); + this.text = text; + } + + eq(other: RelativeLineNumberMarker) { + return this.text === other.text; + } + + toDOM() { + return document.createTextNode(this.text); + } +} + +// The built-in `lineNumbers()` gutter only repaints its labels on document, +// viewport, or height changes — never on a bare selection change — and its +// `lineMarkerChange` hook is not exposed through the public config. Relative +// numbering reads the cursor line, so we build the gutter directly and force a +// redraw on `selectionSet`; otherwise relative numbers go stale until an +// unrelated edit/scroll/reconfigure triggers a repaint. +function relativeLineNumberGutter() { + return gutter({ + class: "cm-lineNumbers", + lineMarker(view, line) { + const lineNo = view.state.doc.lineAt(line.from).number; + const cursorLine = view.state.doc.lineAt( + view.state.selection.main.head, + ).number; + return new RelativeLineNumberMarker( + formatRelativeLineNumber(lineNo, cursorLine), + ); + }, + lineMarkerChange: (update) => + update.selectionSet || update.docChanged, + initialSpacer() { + return new RelativeLineNumberMarker("0"); + }, + }); +} + +// Line-number gutter. The gutter only renders in code (non–live-preview) mode, +// matching prior behavior. When vim relative line numbers are enabled, the +// current line shows its absolute number and others show their distance from +// the cursor (vim's hybrid `number relativenumber`). +export function getLineNumberExtension( + livePreviewEnabled: boolean, + relative: boolean, +) { + if (livePreviewEnabled) return []; + if (!relative) return lineNumbers(); + return relativeLineNumberGutter(); +} + export function getAlignmentExtension(enabled: boolean) { return enabled ? [ diff --git a/apps/desktop/src/features/editor/editorExtensions.vim.test.ts b/apps/desktop/src/features/editor/editorExtensions.vim.test.ts new file mode 100644 index 00000000..c7c14aa7 --- /dev/null +++ b/apps/desktop/src/features/editor/editorExtensions.vim.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { getVimExtension, getLineNumberExtension } from "./editorExtensions"; + +describe("getVimExtension", () => { + it("returns no extension when disabled", () => { + expect(getVimExtension(false)).toEqual([]); + }); + + it("returns a non-empty extension when enabled", () => { + const ext = getVimExtension(true); + expect(Array.isArray(ext)).toBe(true); + expect((ext as unknown[]).length).toBeGreaterThan(0); + // Should build into a valid editor state without throwing. + expect(() => + EditorState.create({ doc: "hello", extensions: ext }), + ).not.toThrow(); + }); +}); + +describe("getLineNumberExtension", () => { + it("renders no gutter in live-preview mode", () => { + expect(getLineNumberExtension(true, false)).toEqual([]); + expect(getLineNumberExtension(true, true)).toEqual([]); + }); + + it("builds an absolute gutter in code mode", () => { + expect(() => + EditorState.create({ + doc: "one\ntwo", + extensions: getLineNumberExtension(false, false), + }), + ).not.toThrow(); + }); + + it("builds a relative gutter in code mode without throwing", () => { + expect(() => + EditorState.create({ + doc: "one\ntwo\nthree", + selection: { anchor: 4 }, + extensions: getLineNumberExtension(false, true), + }), + ).not.toThrow(); + }); +}); + +describe("relative line number gutter", () => { + let view: EditorView | null = null; + const parent = document.createElement("div"); + document.body.appendChild(parent); + + afterEach(() => { + view?.destroy(); + view = null; + }); + + function gutterLabels(v: EditorView) { + return Array.from( + v.dom.querySelectorAll( + ".cm-lineNumbers .cm-gutterElement", + ), + ) + // Skip the hidden width-spacer element kept for layout stability. + .filter((el) => el.style.visibility !== "hidden") + .map((el) => el.textContent ?? ""); + } + + it("recomputes labels relative to the cursor when the selection moves", () => { + view = new EditorView({ + state: EditorState.create({ + doc: "one\ntwo\nthree\nfour", + selection: { anchor: 0 }, // line 1 + extensions: getLineNumberExtension(false, true), + }), + parent, + }); + + // Cursor on line 1: current line shows absolute "1", others show distance. + expect(gutterLabels(view)).toEqual(["1", "1", "2", "3"]); + + // Move cursor to line 3 ("three"): distances recompute around it. + view.dispatch({ selection: EditorSelection.cursor(8) }); + expect(gutterLabels(view)).toEqual(["2", "1", "3", "1"]); + }); +}); diff --git a/apps/desktop/src/features/editor/extensions/vimCommands.ts b/apps/desktop/src/features/editor/extensions/vimCommands.ts new file mode 100644 index 00000000..3884e6f7 --- /dev/null +++ b/apps/desktop/src/features/editor/extensions/vimCommands.ts @@ -0,0 +1,37 @@ +import { Vim } from "@replit/codemirror-vim"; +import { + REQUEST_CLOSE_ACTIVE_TAB_EVENT, + REQUEST_SAVE_ACTIVE_TAB_EVENT, +} from "../editorActionEvents"; + +function requestSave() { + window.dispatchEvent(new Event(REQUEST_SAVE_ACTIVE_TAB_EVENT)); +} + +function requestClose() { + window.dispatchEvent(new Event(REQUEST_CLOSE_ACTIVE_TAB_EVENT)); +} + +let registered = false; + +// Map the familiar vim ex-commands onto NeverWrite's existing save/close +// actions so muscle memory (:w, :q, :wq, :x) works. The active tab's editor +// listens for these window events; closing already saves first, so :wq just +// dispatches the close request. +export function registerVimExCommands() { + if (registered) return; + registered = true; + + Vim.defineEx("write", "w", () => { + requestSave(); + }); + Vim.defineEx("quit", "q", () => { + requestClose(); + }); + Vim.defineEx("wq", "wq", () => { + requestClose(); + }); + Vim.defineEx("xit", "x", () => { + requestClose(); + }); +} diff --git a/apps/desktop/src/features/editor/extensions/vimStatusBar.ts b/apps/desktop/src/features/editor/extensions/vimStatusBar.ts new file mode 100644 index 00000000..e2cabac1 --- /dev/null +++ b/apps/desktop/src/features/editor/extensions/vimStatusBar.ts @@ -0,0 +1,87 @@ +import { showPanel, type Panel, EditorView } from "@codemirror/view"; +import { getCM } from "@replit/codemirror-vim"; + +type VimModeChange = { mode: string; subMode?: string }; + +function formatMode(change: VimModeChange | null): string { + if (!change) return "NORMAL"; + const base = change.mode === "visual" ? "VISUAL" : change.mode.toUpperCase(); + if (change.mode === "visual" && change.subMode) { + if (change.subMode === "linewise") return "VISUAL LINE"; + if (change.subMode === "blockwise") return "VISUAL BLOCK"; + } + return base; +} + +// Bottom panel showing the current vim mode (NORMAL/INSERT/VISUAL/...), styled +// to match the editor chrome. The @replit/codemirror-vim engine renders the +// `:` ex-command input itself; this only adds the mode label. Included only +// when vim mode is active (see getVimExtension), so it disappears with vim. +function createVimStatusPanel(view: EditorView): Panel { + const dom = document.createElement("div"); + dom.className = "cm-vim-mode-bar"; + + const label = document.createElement("span"); + label.className = "cm-vim-mode-label"; + label.textContent = "NORMAL"; + dom.appendChild(label); + + const onModeChange = (change: VimModeChange) => { + label.textContent = formatMode(change); + label.dataset.mode = change.mode; + }; + + return { + dom, + top: false, + mount() { + const cm = getCM(view); + cm?.on("vim-mode-change", onModeChange); + }, + destroy() { + const cm = getCM(view); + cm?.off("vim-mode-change", onModeChange); + }, + }; +} + +const vimStatusBarTheme = EditorView.baseTheme({ + ".cm-vim-mode-bar": { + display: "flex", + alignItems: "center", + padding: "2px 10px", + fontSize: "11px", + fontFamily: "var(--editor-font-family)", + color: "var(--text-secondary, var(--text-primary))", + borderTop: "1px solid var(--app-border, transparent)", + backgroundColor: "transparent", + userSelect: "none", + }, + ".cm-vim-mode-label": { + fontWeight: "600", + letterSpacing: "0.05em", + }, + // The package leaves the ex-command (`:`) input uncolored, so it renders + // black on the dark editor theme. Color it like editor text. + ".cm-vim-panel": { + color: "var(--text-primary)", + fontFamily: "var(--editor-font-family)", + }, + ".cm-vim-panel input": { + color: "var(--text-primary)", + caretColor: "var(--text-primary)", + }, + // The block cursor inherits the font-size of the DOM node it sits on. On a + // live-preview list line that node is the zero-`font-size` hidden marker, + // which collapses the cursor to zero width. Give it a minimum width tied + // to the editor font size so it stays visible regardless of the underlying + // node's font-size. + ".cm-vimMode .cm-fat-cursor": { + minWidth: "calc(var(--editor-font-size, 14px) * 0.5)", + }, +}); + +export const vimStatusBarExtension = [ + showPanel.of(createVimStatusPanel), + vimStatusBarTheme, +]; diff --git a/apps/desktop/src/features/settings/SettingsPanel.tsx b/apps/desktop/src/features/settings/SettingsPanel.tsx index 7524d026..a16f45d1 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.tsx @@ -1095,6 +1095,8 @@ function EditorSettings({ searchQuery }: { searchQuery: SettingsSearchQuery }) { lineWrapping, justifyText, tabSize, + vimModeEnabled, + vimRelativeLineNumbers, setSetting, } = useSettingsStore(); const showTypography = sectionHasSettingsSearchMatches( @@ -1130,11 +1132,22 @@ function EditorSettings({ searchQuery }: { searchQuery: SettingsSearchQuery }) { ["Tab size", "Number of spaces inserted when pressing Tab.", 2, 4], ], ); + const showVim = sectionHasSettingsSearchMatches(searchQuery, "Vim", [ + [ + "Vim key bindings", + "Use modal (vim) editing in the note editor.", + "vim", + ], + [ + "Relative line numbers", + "Show line numbers as distance from the cursor line.", + ], + ]); const showLayout = sectionHasSettingsSearchMatches(searchQuery, "Layout", [ ["Text width", "Maximum width of the editor content, in pixels."], ]); - if (!showTypography && !showFormatting && !showLayout) { + if (!showTypography && !showFormatting && !showVim && !showLayout) { return ; } @@ -1254,6 +1267,35 @@ function EditorSettings({ searchQuery }: { searchQuery: SettingsSearchQuery }) { } /> + {showVim ? Vim : null} + setSetting("vimModeEnabled", v)} + /> + } + /> + + setSetting("vimRelativeLineNumbers", v) + } + /> + } + /> + {showLayout ? Layout : null}