diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index c0f97a9b..0256aed9 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -22,7 +22,10 @@ import { resolveWikilinkTarget, suggestCreateNotePath } from '../lib/wikilinks' -import { classifyLocalAssetHref, resolveAssetVaultRelativePath } from '../lib/local-assets' +import { + classifyLocalAssetHref, + resolveAssetVaultRelativePath +} from '../lib/local-assets' import { buildMoveNotePrompt, parseMoveNoteTarget, @@ -35,15 +38,27 @@ import { focusPaneInDirection, focusPaneOrEdgePanel } from '../lib/pane-nav' import { requestPaneMode } from '../lib/pane-mode' import { getKeymapBinding, + getKeymapDefinitions, getSequenceTokens, type KeymapId, type KeymapOverrides } from '../lib/keymaps' import { navigateActiveBuffer } from '../lib/buffer-navigation' import { applyVimInsertEscape } from '../lib/vim-insert-escape' +import { + type AppliedVimMapping, + type VimMapCmd, + type VimUnmapCmd, + VIM_MAP_CMD_MODES, + VIM_NOREMAP_CMDS, + VIM_UNMAP_CMD_MODES, + VIM_UNMAP_CMDS, + toVimSequence +} from '../lib/vim-mappings' let vimCommandsRegistered = false let syncedVimBindings: Partial> = {} +let appliedVimMappings: AppliedVimMapping[] = [] const DEFAULT_VIM_MAPPINGS_TO_CLEAR = [ 'gd', @@ -71,56 +86,13 @@ function clearKnownVimMappings(): void { } } -function toVimKeyName(base: string): string { - if (base === 'Space') return 'Space' - if (base === 'Enter') return 'CR' - if (base === 'Esc' || base === 'Escape') return 'Esc' - if (base === 'Tab') return 'Tab' - if (base === 'ArrowUp') return 'Up' - if (base === 'ArrowDown') return 'Down' - if (base === 'ArrowLeft') return 'Left' - if (base === 'ArrowRight') return 'Right' - return base -} - -function toVimSequenceToken(token: string): string | null { - const parts = token - .split('+') - .map((part) => part.trim()) - .filter(Boolean) - if (parts.length === 0) return null - const base = parts.pop() - if (!base) return null - const keyName = toVimKeyName(base) - if (parts.length === 0) { - if (base.length === 1) return base - return `<${keyName}>` - } - const modifiers = parts - .map((part) => { - if (part === 'Ctrl') return 'C' - if (part === 'Alt') return 'A' - if (part === 'Shift') return 'S' - if (part === 'Meta' || part === 'Mod') return 'D' - return null - }) - .filter(Boolean) as string[] - const normalizedKey = base.length === 1 ? base.toLowerCase() : keyName - return `<${[...modifiers, normalizedKey].join('-')}>` -} - -function toVimSequence(binding: string): string | null { - const tokens = binding - .split(/\s+/) - .map((token) => token.trim()) - .filter(Boolean) - .map((token) => toVimSequenceToken(token)) - if (tokens.length === 0 || tokens.some((token) => !token)) return null - return tokens.join('') -} - -function paneMapBindings(overrides: KeymapOverrides, actionId: KeymapId): string[] { - const prefixBinding = toVimSequence(getKeymapBinding(overrides, 'vim.panePrefix')) +function paneMapBindings( + overrides: KeymapOverrides, + actionId: KeymapId +): string[] { + const prefixBinding = toVimSequence( + getKeymapBinding(overrides, 'vim.panePrefix') + ) const actionBinding = toVimSequence(getKeymapBinding(overrides, actionId)) if (!prefixBinding || !actionBinding) return [] const bindings = [`${prefixBinding}${actionBinding}`] @@ -161,111 +133,158 @@ function editorHalfPage(view: EditorView | undefined, forward: boolean): void { range = next } const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight) - const nextTop = Math.max(0, Math.min(maxTop, scroller.scrollTop + (forward ? half : -half))) + const nextTop = Math.max( + 0, + Math.min(maxTop, scroller.scrollTop + (forward ? half : -half)) + ) view.dispatch({ selection: { anchor: range.head } }) scroller.scrollTop = nextTop } -function syncVimKeymaps(overrides: KeymapOverrides): void { - const mappings: Array<{ id: KeymapId; action: string; bindings: string[] }> = [ - { - id: 'vim.goToDefinition', - action: 'goToDefinition', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.goToDefinition'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.paneFocusLeft', - action: 'focusPaneLeft', - bindings: paneMapBindings(overrides, 'vim.paneFocusLeft') - }, - { - id: 'vim.paneFocusDown', - action: 'focusPaneDown', - bindings: paneMapBindings(overrides, 'vim.paneFocusDown') - }, - { - id: 'vim.paneFocusUp', - action: 'focusPaneUp', - bindings: paneMapBindings(overrides, 'vim.paneFocusUp') - }, - { - id: 'vim.paneFocusRight', - action: 'focusPaneRight', - bindings: paneMapBindings(overrides, 'vim.paneFocusRight') - }, - { - id: 'vim.bufferPrevious', - action: 'previousBuffer', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.bufferPrevious'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.bufferNext', - action: 'nextBuffer', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.bufferNext'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.tabPrevious', - action: 'previousBuffer', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.tabPrevious'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.tabNext', - action: 'nextBuffer', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.tabNext'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.foldCurrent', - action: 'foldHeadingAtCursor', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.foldCurrent'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.unfoldCurrent', - action: 'unfoldHeadingAtCursor', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.unfoldCurrent'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.foldAll', - action: 'foldAllHeadings', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.foldAll'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'vim.unfoldAll', - action: 'unfoldAllHeadings', - bindings: [toVimSequence(getKeymapBinding(overrides, 'vim.unfoldAll'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'nav.halfPageDown', - action: 'zenHalfPageDown', - bindings: [toVimSequence(getKeymapBinding(overrides, 'nav.halfPageDown'))].filter( - (binding): binding is string => !!binding - ) - }, - { - id: 'nav.halfPageUp', - action: 'zenHalfPageUp', - bindings: [toVimSequence(getKeymapBinding(overrides, 'nav.halfPageUp'))].filter( - (binding): binding is string => !!binding - ) +function applyVimMappings(raw: string, overrides: KeymapOverrides): void { + for (const { lhs, mode } of appliedVimMappings) { + try { + Vim.unmap(lhs, mode) + } catch { + /* ignore */ + } + } + appliedVimMappings = [] + + const leaderKey = toVimSequence( + getKeymapBinding(overrides, 'vim.leaderPrefix') + )! + const resolve = (token: string): string => + token.replace(//gi, leaderKey) + + const sanitized = raw.trim() + for (const line of sanitized.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('"')) continue + const parts = trimmed.split(/\s+/) + const cmd = parts[0] + const lhs = parts[1] ? resolve(parts[1]) : undefined + const rhs = parts[2] ? resolve(parts[2]) : undefined + if (!lhs) continue + try { + if (VIM_UNMAP_CMDS.has(cmd as VimUnmapCmd)) { + Vim.unmap(lhs, VIM_UNMAP_CMD_MODES[cmd as VimUnmapCmd]) + } else { + const mode = VIM_MAP_CMD_MODES[cmd as VimMapCmd] + if (mode && rhs) { + VIM_NOREMAP_CMDS.has(cmd as VimMapCmd) + ? Vim.noremap(lhs, rhs, mode) + : Vim.map(lhs, rhs, mode) + appliedVimMappings.push({ lhs, mode }) + } + } + } catch { + /* ignore bad mappings */ } - ] + } +} + +function syncVimKeymaps(overrides: KeymapOverrides): void { + const mappings: Array<{ id: KeymapId; action: string; bindings: string[] }> = + [ + { + id: 'vim.goToDefinition', + action: 'goToDefinition', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.goToDefinition')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.paneFocusLeft', + action: 'focusPaneLeft', + bindings: paneMapBindings(overrides, 'vim.paneFocusLeft') + }, + { + id: 'vim.paneFocusDown', + action: 'focusPaneDown', + bindings: paneMapBindings(overrides, 'vim.paneFocusDown') + }, + { + id: 'vim.paneFocusUp', + action: 'focusPaneUp', + bindings: paneMapBindings(overrides, 'vim.paneFocusUp') + }, + { + id: 'vim.paneFocusRight', + action: 'focusPaneRight', + bindings: paneMapBindings(overrides, 'vim.paneFocusRight') + }, + { + id: 'vim.bufferPrevious', + action: 'previousBuffer', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.bufferPrevious')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.bufferNext', + action: 'nextBuffer', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.bufferNext')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.tabPrevious', + action: 'previousBuffer', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.tabPrevious')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.tabNext', + action: 'nextBuffer', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.tabNext')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.foldCurrent', + action: 'foldHeadingAtCursor', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.foldCurrent')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.unfoldCurrent', + action: 'unfoldHeadingAtCursor', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.unfoldCurrent')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.foldAll', + action: 'foldAllHeadings', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.foldAll')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'vim.unfoldAll', + action: 'unfoldAllHeadings', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'vim.unfoldAll')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'nav.halfPageDown', + action: 'zenHalfPageDown', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'nav.halfPageDown')) + ].filter((binding): binding is string => !!binding) + }, + { + id: 'nav.halfPageUp', + action: 'zenHalfPageUp', + bindings: [ + toVimSequence(getKeymapBinding(overrides, 'nav.halfPageUp')) + ].filter((binding): binding is string => !!binding) + } + ] for (const mapping of mappings) { for (const binding of syncedVimBindings[mapping.id] ?? []) { @@ -276,7 +295,13 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { } } for (const binding of mapping.bindings) { - Vim.mapCommand(binding, 'action', mapping.action, {}, { context: 'normal' }) + Vim.mapCommand( + binding, + 'action', + mapping.action, + {}, + { context: 'normal' } + ) } syncedVimBindings[mapping.id] = mapping.bindings } @@ -285,7 +310,8 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { function unwrapMdUrl(url: string): string { // Markdown wraps URLs with spaces in angle brackets: `[x]()`. const trimmed = url.trim() - if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1) + if (trimmed.startsWith('<') && trimmed.endsWith('>')) + return trimmed.slice(1, -1) return trimmed } @@ -457,10 +483,15 @@ function registerVimCommands(): void { const focusDir = (dir: 'h' | 'j' | 'k' | 'l'): void => { focusPaneInDirection(dir) } - Vim.defineEx('wincmd', 'winc', (_cm: unknown, params: { argString?: string } | undefined) => { - const dir = (params?.argString ?? '').trim().toLowerCase()[0] - if (dir === 'h' || dir === 'j' || dir === 'k' || dir === 'l') focusDir(dir) - }) + Vim.defineEx( + 'wincmd', + 'winc', + (_cm: unknown, params: { argString?: string } | undefined) => { + const dir = (params?.argString ?? '').trim().toLowerCase()[0] + if (dir === 'h' || dir === 'j' || dir === 'k' || dir === 'l') + focusDir(dir) + } + ) Vim.defineEx('pane_focus_left', 'pane_focus_left', () => focusDir('h')) Vim.defineEx('pane_focus_down', 'pane_focus_down', () => focusDir('j')) Vim.defineEx('pane_focus_up', 'pane_focus_up', () => focusDir('k')) @@ -525,15 +556,21 @@ function registerVimCommands(): void { try { const parsed = parseCreateNotePath(value) const existing = state.notes.find( - (note) => note.folder !== 'trash' && note.path.toLowerCase() === parsed.relPath.toLowerCase() + (note) => + note.folder !== 'trash' && + note.path.toLowerCase() === parsed.relPath.toLowerCase() ) if (existing) { await state.selectNote(existing.path) state.setFocusedPanel('editor') - requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) + requestAnimationFrame(() => + useStore.getState().editorViewRef?.focus() + ) return } - await state.createAndOpen(parsed.folder, parsed.subpath, { title: parsed.title }) + await state.createAndOpen(parsed.folder, parsed.subpath, { + title: parsed.title + }) state.setFocusedPanel('editor') requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) } catch (err) { @@ -589,7 +626,11 @@ function registerVimCommands(): void { * - `:h[elp]` open the built-in Help manual */ function registerVimNoteCommands(): void { - const getActiveLeaf = (): { id: string; tabs: string[]; activeTab: string | null } | null => { + const getActiveLeaf = (): { + id: string + tabs: string[] + activeTab: string | null + } | null => { const s = useStore.getState() const leaves = allLeavesFlat(s.paneLayout) return leaves.find((l) => l.id === s.activePaneId) ?? null @@ -642,7 +683,9 @@ function registerVimNoteCommands(): void { (_cm: unknown, params: { argString?: string } | undefined) => { const arg = (params?.argString ?? '').trim() if (!arg) { - void useStore.getState().createAndOpen('inbox', '', { focusTitle: true }) + void useStore + .getState() + .createAndOpen('inbox', '', { focusTitle: true }) return } void openOrCreateByPath(arg) @@ -657,7 +700,8 @@ function registerVimNoteCommands(): void { const value = raw.trim() let target = value if (!target) { - target = (await promptApp(buildMoveNotePrompt(active, state.folders))) ?? '' + target = + (await promptApp(buildMoveNotePrompt(active, state.folders))) ?? '' if (!target) return } @@ -670,18 +714,29 @@ function registerVimNoteCommands(): void { await state.moveNote(active.path, dest.folder, dest.subpath) } - const runMoveEx = (_cm: unknown, params: { argString?: string } | undefined): void => { + const runMoveEx = ( + _cm: unknown, + params: { argString?: string } | undefined + ): void => { void moveActiveNote(params?.argString ?? '') } Vim.defineEx('move', 'move', runMoveEx) Vim.defineEx('mv', 'mv', runMoveEx) - Vim.defineEx('bnext', 'bn', () => navigateActiveBuffer(useStore.getState(), 1)) - Vim.defineEx('bprev', 'bp', () => navigateActiveBuffer(useStore.getState(), -1)) + Vim.defineEx('bnext', 'bn', () => + navigateActiveBuffer(useStore.getState(), 1) + ) + Vim.defineEx('bprev', 'bp', () => + navigateActiveBuffer(useStore.getState(), -1) + ) // Vim tab aliases over the same active-pane tab navigation. - Vim.defineEx('tabnext', 'tabn', () => navigateActiveBuffer(useStore.getState(), 1)) - Vim.defineEx('tabprevious', 'tabp', () => navigateActiveBuffer(useStore.getState(), -1)) + Vim.defineEx('tabnext', 'tabn', () => + navigateActiveBuffer(useStore.getState(), 1) + ) + Vim.defineEx('tabprevious', 'tabp', () => + navigateActiveBuffer(useStore.getState(), -1) + ) // Vim aliases: :bNext and :bfirst/:blast — rare, skipped. const closeActiveTabLikeQuit = (): void => { @@ -735,22 +790,34 @@ function registerVimNoteCommands(): void { requestPaneMode(mode) }) } - Vim.defineEx('view', 'view', (_cm: unknown, params: { argString?: string } | undefined) => { - const nextMode = (params?.argString ?? '').trim().toLowerCase() - if (nextMode === 'edit' || nextMode === 'split' || nextMode === 'preview') { - setPaneMode(nextMode) - } - }) - Vim.defineEx('zen', 'zen', (_cm: unknown, params: { argString?: string } | undefined) => { - const nextMode = (params?.argString ?? '').trim().toLowerCase() - if (!nextMode || nextMode === 'toggle') { - setZenMode('toggle') - return + Vim.defineEx( + 'view', + 'view', + (_cm: unknown, params: { argString?: string } | undefined) => { + const nextMode = (params?.argString ?? '').trim().toLowerCase() + if ( + nextMode === 'edit' || + nextMode === 'split' || + nextMode === 'preview' + ) { + setPaneMode(nextMode) + } } - if (nextMode === 'on' || nextMode === 'off') { - setZenMode(nextMode) + ) + Vim.defineEx( + 'zen', + 'zen', + (_cm: unknown, params: { argString?: string } | undefined) => { + const nextMode = (params?.argString ?? '').trim().toLowerCase() + if (!nextMode || nextMode === 'toggle') { + setZenMode('toggle') + return + } + if (nextMode === 'on' || nextMode === 'off') { + setZenMode(nextMode) + } } - }) + ) Vim.defineEx('zenmode', 'zenmode', () => setZenMode('toggle')) Vim.defineEx('editmode', 'editmode', () => setPaneMode('edit')) Vim.defineEx('splitmode', 'splitmode', () => setPaneMode('split')) @@ -792,7 +859,9 @@ function registerVimNoteCommands(): void { // on the current heading; `:foldall` / `:unfoldall` cover the whole // note. We map vim's `zc` / `zo` / `zM` / `zR` keys explicitly so the // advertised fold chords work regardless of what CM-Vim ships by default. - const runFold = (cmd: (view: { state: unknown; dispatch: unknown }) => boolean): void => { + const runFold = ( + cmd: (view: { state: unknown; dispatch: unknown }) => boolean + ): void => { const view = useStore.getState().editorViewRef if (!view) return cmd(view as unknown as Parameters[0]) @@ -823,7 +892,8 @@ function allLeavesFlat( if (node.kind === 'leaf') { return [{ id: node.id, tabs: node.tabs, activeTab: node.activeTab }] } - const out: Array<{ id: string; tabs: string[]; activeTab: string | null }> = [] + const out: Array<{ id: string; tabs: string[]; activeTab: string | null }> = + [] for (const child of node.children) out.push(...allLeavesFlat(child)) return out } @@ -1036,7 +1106,11 @@ interface ExCompletionMatch { apply: string } -function renderWildmenu(matches: ExCompletionMatch[], cycleIdx: number, anchor: HTMLElement): void { +function renderWildmenu( + matches: ExCompletionMatch[], + cycleIdx: number, + anchor: HTMLElement +): void { const menu = ensureWildmenu() if (matches.length === 0) { menu.root.style.display = 'none' @@ -1162,7 +1236,12 @@ function installExTabCompletion(): void { // the app shouldn't clobber our state (modifier keys fire even // when the input is focused too, so we ignore those explicitly // instead of resetting on them). - if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') { + if ( + e.key === 'Shift' || + e.key === 'Control' || + e.key === 'Alt' || + e.key === 'Meta' + ) { return } const target = e.target as HTMLElement | null @@ -1192,7 +1271,8 @@ function installExTabCompletion(): void { e.stopImmediatePropagation() const step = e.shiftKey ? -1 : 1 - const fresh = !exCycle || exCycle.input !== target || exCycle.basePrefix === null + const fresh = + !exCycle || exCycle.input !== target || exCycle.basePrefix === null if (fresh) { exCycle = { @@ -1261,6 +1341,8 @@ export function Editor(): JSX.Element { const keymapOverrides = useStore((s) => s.keymapOverrides) const vimInsertEscape = useStore((s) => s.vimInsertEscape) const zenMode = useStore((s) => s.zenMode) + const vimMappings = useStore((s) => s.vimMappings) + const vimMode = useStore((s) => s.vimMode) useEffect(() => { registerVimCommands() @@ -1275,6 +1357,11 @@ export function Editor(): JSX.Element { applyVimInsertEscape(vimInsertEscape) }, [vimInsertEscape]) + useEffect(() => { + if (!vimMode) return + applyVimMappings(vimMappings, keymapOverrides) + }, [vimMappings, vimMode, keymapOverrides]) + return (
@@ -1362,7 +1449,9 @@ function PaneSplitView({ split }: { split: PaneSplit }): JSX.Element { out.push(
@@ -1384,7 +1473,10 @@ function PaneSplitView({ split }: { split: PaneSplit }): JSX.Element { return (
{nodes}
@@ -1411,9 +1503,7 @@ function ResizeDivider({ onMouseDown={onMouseDown} className={[ 'group relative z-10 shrink-0 select-none bg-paper-300/70 transition-colors hover:bg-accent/60 active:bg-accent', - isVertical - ? 'w-px cursor-col-resize' - : 'h-px cursor-row-resize' + isVertical ? 'w-px cursor-col-resize' : 'h-px cursor-row-resize' ].join(' ')} > {/* Wider hit zone centered on the divider line. */} diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 346064cc..53e9f59c 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -1,6 +1,16 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { createPortal } from 'react-dom' -import { DEFAULT_DAILY_NOTES_DIRECTORY, DEFAULT_WEEKLY_NOTES_DIRECTORY } from '@shared/ipc' +import { + DEFAULT_DAILY_NOTES_DIRECTORY, + DEFAULT_WEEKLY_NOTES_DIRECTORY +} from '@shared/ipc' import type { AppUpdateState, CliInstallStatus, @@ -20,9 +30,15 @@ import { } from '@shared/mcp-clients' import { useStore } from '../store' import type { LineNumberMode, WhichKeyHintMode } from '../store' -import type { KeymapDefinition, KeymapId, KeymapOverrides } from '../lib/keymaps' +import type { + KeymapDefinition, + KeymapId, + KeymapOverrides +} from '../lib/keymaps' import { + findConflictingKeymaps, formatKeymapBinding, + getAppVimSequenceMap, getKeymapBinding, getKeymapDefinitionsByGroup, getKeymapDisplay, @@ -30,13 +46,22 @@ import { sequenceTokenFromEvent, shortcutBindingFromEvent } from '../lib/keymaps' -import { resolveAuto, THEMES, type ThemeFamily, type ThemeMode } from '../lib/themes' +import { diagnoseVimMappings, toVimSequence } from '../lib/vim-mappings' +import { + resolveAuto, + THEMES, + type ThemeFamily, + type ThemeMode +} from '../lib/themes' import { hasSystemFontAccess, listSystemFonts } from '../lib/system-fonts' import { DEFAULT_SYSTEM_FOLDER_LABELS, getSystemFolderLabel } from '../lib/system-folder-labels' -import { normalizeDailyNotesDirectory, normalizeWeeklyNotesDirectory } from '../lib/vault-layout' +import { + normalizeDailyNotesDirectory, + normalizeWeeklyNotesDirectory +} from '../lib/vault-layout' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' import { composeTemplateFile, mergeTemplates } from '@shared/template-files' import { TemplateEditorModal } from './TemplateEditorModal' @@ -73,14 +98,19 @@ interface SettingsCategory extends SettingsSearchCategory { content: JSX.Element } -function settingsSearchTargetProps( - settingId: string | undefined -): { 'data-settings-search-id'?: string } { +function settingsSearchTargetProps(settingId: string | undefined): { + 'data-settings-search-id'?: string +} { return settingId ? { 'data-settings-search-id': settingId } : {} } -function findSettingsSearchTarget(root: HTMLElement, targetId: string): HTMLElement | null { - for (const element of root.querySelectorAll('[data-settings-search-id]')) { +function findSettingsSearchTarget( + root: HTMLElement, + targetId: string +): HTMLElement | null { + for (const element of root.querySelectorAll( + '[data-settings-search-id]' + )) { if (element.dataset.settingsSearchId === targetId) return element } return null @@ -100,7 +130,8 @@ function resolveVaultTextSearchBackend( ): ResolvedVaultTextSearchBackend | null { if (!capabilities) return null if (preferred === 'builtin') return 'builtin' - if (preferred === 'ripgrep') return capabilities.ripgrep ? 'ripgrep' : 'builtin' + if (preferred === 'ripgrep') + return capabilities.ripgrep ? 'ripgrep' : 'builtin' if (preferred === 'fzf') return capabilities.fzf ? 'fzf' : 'builtin' if (capabilities.fzf) return 'fzf' if (capabilities.ripgrep) return 'ripgrep' @@ -167,7 +198,12 @@ function formatBytes(bytes: number | null): string | null { value /= 1024 unit = units[i] } - const rounded = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2) + const rounded = + value >= 100 + ? value.toFixed(0) + : value >= 10 + ? value.toFixed(1) + : value.toFixed(2) return `${rounded} ${unit}` } @@ -191,11 +227,19 @@ function formatReleaseNotesForDisplay(notes: string | null): string | null { } } +function statusChipClass(tone: 'ok' | 'warn' | 'off'): string { + if (tone === 'ok') return 'border-accent/25 bg-accent/10 text-accent' + if (tone === 'warn') + return 'border-amber-500/30 bg-amber-500/10 text-amber-500' + return 'border-paper-300/70 bg-paper-100/85 text-ink-500' +} + export function SettingsModal(): JSX.Element { const zenBridge = getZenBridge() const appInfo = zenBridge.getAppInfo() const supportsRemoteWorkspace = - appInfo.runtime === 'desktop' && zenBridge.getCapabilities().supportsRemoteWorkspace + appInfo.runtime === 'desktop' && + zenBridge.getCapabilities().supportsRemoteWorkspace const setSettingsOpen = useStore((s) => s.setSettingsOpen) const vimMode = useStore((s) => s.vimMode) const setVimMode = useStore((s) => s.setVimMode) @@ -244,11 +288,19 @@ export function SettingsModal(): JSX.Element { const persistVaultSettings = useStore((s) => s.setVaultSettings) const openVaultPicker = useStore((s) => s.openVaultPicker) const connectRemoteWorkspace = useStore((s) => s.connectRemoteWorkspace) - const connectRemoteWorkspaceProfile = useStore((s) => s.connectRemoteWorkspaceProfile) - const changeRemoteWorkspaceVaultPath = useStore((s) => s.changeRemoteWorkspaceVaultPath) + const connectRemoteWorkspaceProfile = useStore( + (s) => s.connectRemoteWorkspaceProfile + ) + const changeRemoteWorkspaceVaultPath = useStore( + (s) => s.changeRemoteWorkspaceVaultPath + ) const disconnectRemoteWorkspace = useStore((s) => s.disconnectRemoteWorkspace) - const saveRemoteWorkspaceProfile = useStore((s) => s.saveRemoteWorkspaceProfile) - const deleteRemoteWorkspaceProfile = useStore((s) => s.deleteRemoteWorkspaceProfile) + const saveRemoteWorkspaceProfile = useStore( + (s) => s.saveRemoteWorkspaceProfile + ) + const deleteRemoteWorkspaceProfile = useStore( + (s) => s.deleteRemoteWorkspaceProfile + ) const openTodayDailyNote = useStore((s) => s.openTodayDailyNote) const openThisWeekWeeklyNote = useStore((s) => s.openThisWeekWeeklyNote) const autoCalendarPanel = useStore((s) => s.autoCalendarPanel) @@ -256,17 +308,24 @@ export function SettingsModal(): JSX.Element { const calendarWeekStart = useStore((s) => s.calendarWeekStart) const setCalendarWeekStart = useStore((s) => s.setCalendarWeekStart) const calendarShowWeekNumbers = useStore((s) => s.calendarShowWeekNumbers) - const setCalendarShowWeekNumbers = useStore((s) => s.setCalendarShowWeekNumbers) + const setCalendarShowWeekNumbers = useStore( + (s) => s.setCalendarShowWeekNumbers + ) const customTemplates = useStore((s) => s.customTemplates) const deleteCustomTemplate = useStore((s) => s.deleteCustomTemplate) const hideBuiltinTemplates = useStore((s) => s.hideBuiltinTemplates) const setHideBuiltinTemplates = useStore((s) => s.setHideBuiltinTemplates) const allTemplates = useMemo( - () => mergeTemplates(hideBuiltinTemplates ? [] : BUILTIN_TEMPLATES, customTemplates), + () => + mergeTemplates( + hideBuiltinTemplates ? [] : BUILTIN_TEMPLATES, + customTemplates + ), [customTemplates, hideBuiltinTemplates] ) const supportsCustomTemplates = - zenBridge.getCapabilities().supportsCustomTemplates && workspaceMode !== 'remote' + zenBridge.getCapabilities().supportsCustomTemplates && + workspaceMode !== 'remote' const [templateEditor, setTemplateEditor] = useState<{ initialRaw?: string sourcePath?: string @@ -393,7 +452,8 @@ export function SettingsModal(): JSX.Element { if (!cancelled) setVaultTextSearchCapabilities(capabilities) }, () => { - if (!cancelled) setVaultTextSearchCapabilities({ ripgrep: false, fzf: false }) + if (!cancelled) + setVaultTextSearchCapabilities({ ripgrep: false, fzf: false }) } ) return () => { @@ -420,7 +480,9 @@ export function SettingsModal(): JSX.Element { }, (error) => { const message = - error instanceof Error ? error.message : 'Could not check for updates.' + error instanceof Error + ? error.message + : 'Could not check for updates.' window.alert(message) } ) @@ -435,7 +497,7 @@ export function SettingsModal(): JSX.Element { }, []) const currentRemoteProfileId = - workspaceMode === 'remote' ? remoteWorkspaceInfo?.profileId ?? null : null + workspaceMode === 'remote' ? (remoteWorkspaceInfo?.profileId ?? null) : null const openCreateRemoteProfile = useCallback(() => { setEditingRemoteProfile({ @@ -444,24 +506,27 @@ export function SettingsModal(): JSX.Element { name: '', baseUrl: remoteWorkspaceInfo?.baseUrl ?? 'http://localhost:7878', authToken: null, - vaultPath: workspaceMode === 'remote' ? vault?.root ?? null : null + vaultPath: workspaceMode === 'remote' ? (vault?.root ?? null) : null }, hasStoredCredential: false }) }, [remoteWorkspaceInfo?.baseUrl, vault?.root, workspaceMode]) - const openEditRemoteProfile = useCallback((profile: RemoteWorkspaceProfile) => { - setEditingRemoteProfile({ - mode: 'edit', - value: { - id: profile.id, - name: profile.name, - baseUrl: profile.baseUrl, - vaultPath: profile.vaultPath - }, - hasStoredCredential: profile.hasCredential - }) - }, []) + const openEditRemoteProfile = useCallback( + (profile: RemoteWorkspaceProfile) => { + setEditingRemoteProfile({ + mode: 'edit', + value: { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + vaultPath: profile.vaultPath + }, + hasStoredCredential: profile.hasCredential + }) + }, + [] + ) const submitRemoteProfile = useCallback( async (input: RemoteWorkspaceProfileInput) => { @@ -475,7 +540,8 @@ export function SettingsModal(): JSX.Element { async (profile: RemoteWorkspaceProfile) => { const confirmed = await confirmApp({ title: `Remove “${profile.name}”?`, - description: 'This only removes the saved connection from ZenNotes. It does not delete anything on the server.', + description: + 'This only removes the saved connection from ZenNotes. It does not delete anything on the server.', confirmLabel: 'Remove', danger: true }) @@ -544,7 +610,9 @@ export function SettingsModal(): JSX.Element { * themeId may not match what's painted on screen. */ const renderedThemeId = useMemo( () => - themeMode === 'auto' ? resolveAuto(themeFamily, prefersDark, themeId) : themeId, + themeMode === 'auto' + ? resolveAuto(themeFamily, prefersDark, themeId) + : themeId, [themeId, themeFamily, themeMode, prefersDark] ) @@ -619,8 +687,11 @@ export function SettingsModal(): JSX.Element { const ref = useRef(null) const settingsSearchHighlightTimerRef = useRef(null) - const [activeCategory, setActiveCategory] = useState('appearance') - const [activeSearchResultId, setActiveSearchResultId] = useState(null) + const [activeCategory, setActiveCategory] = + useState('appearance') + const [activeSearchResultId, setActiveSearchResultId] = useState< + string | null + >(null) const [navQuery, setNavQuery] = useState('') const availableVaultTextSearchTools = [ vaultTextSearchCapabilities?.ripgrep ? 'ripgrep' : null, @@ -724,7 +795,17 @@ export function SettingsModal(): JSX.Element { id: 'theme-family', title: 'Theme family', description: 'Pick the visual system ZenNotes uses across the app.', - keywords: ['theme', 'family', 'apple', 'gruvbox', 'catppuccin', 'github', 'solarized', 'nord', 'tokyo night'] + keywords: [ + 'theme', + 'family', + 'apple', + 'gruvbox', + 'catppuccin', + 'github', + 'solarized', + 'nord', + 'tokyo night' + ] }, { id: 'theme-mode', @@ -742,12 +823,14 @@ export function SettingsModal(): JSX.Element { { id: 'dark-sidebar', title: 'Dark sidebar', - description: 'Tint the sidebar one step darker than the canvas so the chrome reads as a separate surface.' + description: + 'Tint the sidebar one step darker than the canvas so the chrome reads as a separate surface.' }, { id: 'sidebar-arrows', title: 'Sidebar arrows', - description: 'Show disclosure arrows for collapsible folders and sidebar sections.', + description: + 'Show disclosure arrows for collapsible folders and sidebar sections.', keywords: ['chevrons', 'disclosure'] } ], @@ -857,8 +940,22 @@ export function SettingsModal(): JSX.Element { { id: 'editor', title: 'Editor', - description: 'Vim, leader hints, live preview, tabs, and writing behavior.', - keywords: ['vim', 'leader', 'preview', 'tabs', 'wrap', 'pdf', 'quick note', 'quick capture', 'hotkey', 'shortcut', 'task', 'tasks'], + description: + 'Vim, leader hints, live preview, tabs, and writing behavior.', + keywords: [ + 'vim', + 'leader', + 'preview', + 'tabs', + 'wrap', + 'pdf', + 'quick note', + 'quick capture', + 'hotkey', + 'shortcut', + 'task', + 'tasks' + ], searchItems: [ { id: 'vim-mode', @@ -869,34 +966,39 @@ export function SettingsModal(): JSX.Element { { id: 'vim-insert-escape', title: 'Exit insert mode with', - description: 'Map a key sequence like jk or jj to Escape in insert mode.', + description: + 'Map a key sequence like jk or jj to Escape in insert mode.', keywords: ['vim', 'jk', 'jj', 'escape', 'insert mode', 'esc'] }, { id: 'leader-key-hints', title: 'Leader key hints', - description: 'Show a which-key style guide after pressing the Leader key so the next available actions stay visible.', + description: + 'Show a which-key style guide after pressing the Leader key so the next available actions stay visible.', keywords: ['leader', 'which-key'], targetId: leaderKeyHintsTargetId }, { id: 'leader-hint-behavior', title: 'Leader hint behavior', - description: 'Timed auto-hides after a short delay. Sticky keeps the leader overlay open until you dismiss it.', + description: + 'Timed auto-hides after a short delay. Sticky keeps the leader overlay open until you dismiss it.', keywords: ['leader', 'sticky', 'timed'], targetId: leaderHintBehaviorTargetId }, { id: 'leader-hint-duration', title: 'Leader hint duration', - description: 'How long the leader overlay stays visible, and how long the pending leader sequence remains armed.', + description: + 'How long the leader overlay stays visible, and how long the pending leader sequence remains armed.', keywords: ['leader', 'timeout', 'delay'], targetId: leaderHintDurationTargetId }, { id: 'vault-text-search-backend', title: 'Vault text search backend', - description: 'Auto prefers fzf when available, then ripgrep, and falls back to the built-in searcher.', + description: + 'Auto prefers fzf when available, then ripgrep, and falls back to the built-in searcher.', keywords: ['search', 'backend', 'ripgrep', 'rg', 'fzf', 'built-in'] }, { @@ -920,37 +1022,43 @@ export function SettingsModal(): JSX.Element { { id: 'note-tabs', title: 'Note tabs', - description: 'Open notes in tabs and allow split-friendly tab workflows.', + description: + 'Open notes in tabs and allow split-friendly tab workflows.', keywords: ['tabs'] }, { id: 'wrap-note-tabs', title: 'Wrap note tabs', - description: 'Move overflowing tabs onto additional rows instead of horizontal scrolling.', + description: + 'Move overflowing tabs onto additional rows instead of horizontal scrolling.', keywords: ['tabs', 'wrap', 'new line', 'overflow'] }, { id: 'word-wrap', title: 'Word wrap', - description: 'Wrap long lines to the editor width. Turn off to scroll horizontally instead.', + description: + 'Wrap long lines to the editor width. Turn off to scroll horizontally instead.', keywords: ['wrap', 'line wrap'] }, { id: 'smooth-preview-scroll', title: 'Smooth preview scroll', - description: 'Animate Ctrl+D / Ctrl+U half-page jumps in preview mode.', + description: + 'Animate Ctrl+D / Ctrl+U half-page jumps in preview mode.', keywords: ['preview', 'scroll'] }, { id: 'pdfs-in-edit-mode', title: 'PDFs in edit mode', - description: 'Compact keeps the editor focused. Full inlines the PDF viewer under your cursor.', + description: + 'Compact keeps the editor focused. Full inlines the PDF viewer under your cursor.', keywords: ['pdf', 'embed'] }, { id: 'date-titled-quick-notes', title: 'Date-titled Quick Notes', - description: 'New Quick Notes use YYYY-MM-DD instead of timestamp-style titles.', + description: + 'New Quick Notes use YYYY-MM-DD instead of timestamp-style titles.', keywords: ['quick note', 'date', 'title'] }, { @@ -962,7 +1070,8 @@ export function SettingsModal(): JSX.Element { { id: 'quick-capture-hotkey', title: 'Quick capture hotkey', - description: 'System-wide shortcut to open the floating capture window.', + description: + 'System-wide shortcut to open the floating capture window.', keywords: ['quick capture', 'hotkey', 'shortcut'] } ], @@ -1007,7 +1116,9 @@ export function SettingsModal(): JSX.Element { { value: 'timed', label: 'Timed' }, { value: 'sticky', label: 'Sticky' } ]} - onChange={(next) => setWhichKeyHintMode(next as WhichKeyHintMode)} + onChange={(next) => + setWhichKeyHintMode(next as WhichKeyHintMode) + } /> {whichKeyHintMode === 'timed' && ( setVaultTextSearchBackend(next as VaultTextSearchBackendPreference)} + onChange={(next) => + setVaultTextSearchBackend( + next as VaultTextSearchBackendPreference + ) + } /> setFzfBinaryPath(next)} /> - Runtime backend: {resolvedVaultTextSearchBackendLabel(resolvedVaultTextSearchBackend)} - - - {resolvedVaultTextSearchMessage} + Runtime backend:{' '} + {resolvedVaultTextSearchBackendLabel( + resolvedVaultTextSearchBackend + )} + {resolvedVaultTextSearchMessage} {vaultTextSearchCapabilities == null ? 'Checking configured search tools…' @@ -1165,12 +1281,23 @@ export function SettingsModal(): JSX.Element { { id: 'shortcut-editor', title: 'Shortcut editor', - description: 'Record a new key or sequence for the app’s keyboard-first actions.', - keywords: ['shortcuts', 'bindings', 'leader', 'vim', 'remap', 'keyboard'] + description: + 'Record a new key or sequence for the app’s keyboard-first actions.', + keywords: [ + 'shortcuts', + 'bindings', + 'leader', + 'vim', + 'remap', + 'keyboard' + ] } ], content: ( -
+
- {workspaceMode === 'remote' ? 'Remote workspace' : 'Vault location'} + {workspaceMode === 'remote' + ? 'Remote workspace' + : 'Vault location'}
{vault?.root ?? 'No vault selected'} @@ -1495,7 +1648,9 @@ export function SettingsModal(): JSX.Element { } className="shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-xs font-medium text-ink-800 transition-colors hover:bg-paper-200" > - {workspaceMode === 'remote' ? 'Change Remote Vault…' : 'Change…'} + {workspaceMode === 'remote' + ? 'Change Remote Vault…' + : 'Change…'} {workspaceMode === 'remote' && (
{!isCurrent && (
@@ -1891,7 +2067,9 @@ export function SettingsModal(): JSX.Element { {...settingsSearchTargetProps('templates-new')} >
-
Create a custom template
+
+ Create a custom template +
Stored as a markdown file in `.zennotes/templates`.
@@ -1907,13 +2085,16 @@ export function SettingsModal(): JSX.Element {
) : ( - Custom templates require a local vault. Built-in templates still work here. + Custom templates require a local vault. Built-in templates still + work here. )}
- {hideBuiltinTemplates ? 'Built-in templates are hidden' : 'Built-in templates'} + {hideBuiltinTemplates + ? 'Built-in templates are hidden' + : 'Built-in templates'}
{hideBuiltinTemplates @@ -1927,7 +2108,9 @@ export function SettingsModal(): JSX.Element { onClick={() => void toggleBuiltinTemplates()} className="shrink-0" > - {hideBuiltinTemplates ? 'Restore built-in templates' : 'Remove built-in templates'} + {hideBuiltinTemplates + ? 'Restore built-in templates' + : 'Remove built-in templates'}
{allTemplates.map((template) => ( @@ -1936,7 +2119,9 @@ export function SettingsModal(): JSX.Element { className="flex items-center justify-between gap-4 px-5 py-3" >
-
{template.name}
+
+ {template.name} +
{template.category} {template.description ? ` — ${template.description}` : ''} @@ -1953,34 +2138,34 @@ export function SettingsModal(): JSX.Element { Customized )} - {template.builtin - ? supportsCustomTemplates && ( - - ) - : ( - <> - - - - )} + {template.builtin ? ( + supportsCustomTemplates && ( + + ) + ) : ( + <> + + + + )}
))} @@ -2009,7 +2194,8 @@ export function SettingsModal(): JSX.Element { { id: 'mcp-server', title: 'MCP server', - description: 'ZenNotes bundles a local MCP server that connected clients use.', + description: + 'ZenNotes bundles a local MCP server that connected clients use.', keywords: ['mcp', 'server', 'runtime', 'command'] }, { @@ -2021,7 +2207,8 @@ export function SettingsModal(): JSX.Element { { id: 'mcp-instructions', title: 'MCP instructions', - description: 'Edit the system prompt ZenNotes ships to any connected MCP client.', + description: + 'Edit the system prompt ZenNotes ships to any connected MCP client.', keywords: ['mcp', 'prompt', 'instructions', 'system prompt'] } ], @@ -2050,8 +2237,17 @@ export function SettingsModal(): JSX.Element { { id: 'zen-command-line-tool', title: 'zen command-line tool', - description: 'Install the `zen` shell command for terminal-based note workflows.', - keywords: ['cli', 'command line', 'terminal', 'shell', 'zen', 'install', 'path'] + description: + 'Install the `zen` shell command for terminal-based note workflows.', + keywords: [ + 'cli', + 'command line', + 'terminal', + 'shell', + 'zen', + 'install', + 'path' + ] }, { id: 'cli-quick-reference', @@ -2062,7 +2258,8 @@ export function SettingsModal(): JSX.Element { { id: 'raycast-extension', title: 'Raycast Extension', - description: 'Install the ZenNotes Raycast extension locally from this app.', + description: + 'Install the ZenNotes Raycast extension locally from this app.', keywords: ['raycast', 'launcher', 'extension', 'install'] } ], @@ -2071,7 +2268,8 @@ export function SettingsModal(): JSX.Element { { id: 'about', title: 'About', - description: 'App identity, version, updater status, and company information.', + description: + 'App identity, version, updater status, and company information.', keywords: ['version', 'company', 'lumary', 'about', 'logo', 'updates'], searchItems: [ { @@ -2117,7 +2315,9 @@ export function SettingsModal(): JSX.Element { updatePhaseBadgeClass(appUpdateState?.phase ?? 'idle') ].join(' ')} > - {formatUpdatePhaseLabel(appUpdateState?.phase ?? 'idle')} + {formatUpdatePhaseLabel( + appUpdateState?.phase ?? 'idle' + )} {appUpdateState?.availableVersion && ( @@ -2160,7 +2360,10 @@ export function SettingsModal(): JSX.Element { )}

- {appUpdateState?.message ?? 'Check GitHub releases for a newer ZenNotes build.'} + {appUpdateState?.message ?? + 'Check GitHub releases for a newer ZenNotes build.'}

{appUpdateState?.phase === 'downloading' && (
- {Math.round(appUpdateState.progressPercent ?? 0)}% - {formatBytes(appUpdateState.transferredBytes) && formatBytes(appUpdateState.totalBytes) && ( + + {Math.round(appUpdateState.progressPercent ?? 0)}% + + {formatBytes(appUpdateState.transferredBytes) && + formatBytes(appUpdateState.totalBytes) && ( + + {formatBytes(appUpdateState.transferredBytes)} /{' '} + {formatBytes(appUpdateState.totalBytes)} + + )} + {formatBytes(appUpdateState.bytesPerSecond) && ( - {formatBytes(appUpdateState.transferredBytes)} / {formatBytes(appUpdateState.totalBytes)} + {formatBytes(appUpdateState.bytesPerSecond)}/s )} - {formatBytes(appUpdateState.bytesPerSecond) && ( - {formatBytes(appUpdateState.bytesPerSecond)}/s - )}
)} @@ -2204,7 +2416,8 @@ export function SettingsModal(): JSX.Element { )}
- In-app updates use the published GitHub release feed. For general users, that feed must be publicly reachable. + In-app updates use the published GitHub release feed. For + general users, that feed must be publicly reachable.

@@ -2262,9 +2475,7 @@ export function SettingsModal(): JSX.Element { searchResults.find((result) => result.category.id === activeCategory) ?? searchResults[0] ?? null - const visibleCategory = - visibleSearchResult?.category ?? - null + const visibleCategory = visibleSearchResult?.category ?? null return ( <> @@ -2277,121 +2488,124 @@ export function SettingsModal(): JSX.Element { className="grid h-[min(92vh,980px)] w-[min(1120px,96vw)] grid-cols-[252px_minmax(0,1fr)] overflow-hidden rounded-3xl border border-paper-300/70 bg-paper-100 shadow-float" onClick={(e) => e.stopPropagation()} > -

+
+ Settings save automatically on this device. +
+ -
-
-
-
- {visibleCategory ? visibleCategory.title : 'Settings'} +
+
+
+
+ {visibleCategory ? visibleCategory.title : 'Settings'} +
+

+ {visibleCategory?.title ?? 'Settings'} +

+

+ {visibleCategory?.description ?? + 'Search the navigation on the left to jump to a settings section.'} +

-

- {visibleCategory?.title ?? 'Settings'} -

-

- {visibleCategory?.description ?? - 'Search the navigation on the left to jump to a settings section.'} -

+
- -
-
- {visibleCategory ? ( - visibleCategory.content - ) : ( -
- Try a broader search term, or clear the search field to browse every settings section. -
- )} -
+
+ {visibleCategory ? ( + visibleCategory.content + ) : ( +
+ Try a broader search term, or clear the search field to browse + every settings section. +
+ )} +
@@ -2408,7 +2622,10 @@ export function SettingsModal(): JSX.Element { : 'Save a ZenNotes server so you can reconnect without re-entering the details.', initialValue: editingRemoteProfile.value, hasStoredCredential: editingRemoteProfile.hasStoredCredential, - submitLabel: editingRemoteProfile.mode === 'edit' ? 'Save Changes' : 'Save Remote' + submitLabel: + editingRemoteProfile.mode === 'edit' + ? 'Save Changes' + : 'Save Remote' }} onSubmit={submitRemoteProfile} onCancel={() => setEditingRemoteProfile(null)} @@ -2436,15 +2653,46 @@ function KeymapSettings({ onSetBinding: (id: KeymapId, binding: string | null) => void onResetAll: () => void }): JSX.Element { + const vimMappings = useStore((s) => s.vimMappings) + const setVimMappings = useStore((s) => s.setVimMappings) const [query, setQuery] = useState('') const [recording, setRecording] = useState(null) + const keymapDiagnostics = useMemo( + () => findConflictingKeymaps(overrides), + [overrides] + ) + + const vimMappingDiagnostics = useMemo(() => { + const leaderKey = + toVimSequence(getKeymapBinding(overrides, 'vim.leaderPrefix')) ?? + '' + const appSeqMap = getAppVimSequenceMap(overrides) + return diagnoseVimMappings(vimMappings, leaderKey, appSeqMap) + }, [vimMappings, overrides]) + + const vimrcOverriddenIds = useMemo( + () => + new Set( + vimMappingDiagnostics.reduce((acc, d) => { + if (d.kind === 'app-conflict' && d.keymapId) + acc.push(d.keymapId as KeymapId) + return acc + }, []) + ), + [vimMappingDiagnostics] + ) + const groups = useMemo(() => { const q = query.trim().toLowerCase() return getKeymapDefinitionsByGroup() .map((group) => { const items = group.items.filter((definition) => { - if (definition.vimOnly && !vimMode && definition.id !== 'global.searchNotesNonVim') { + if ( + definition.vimOnly && + !vimMode && + definition.id !== 'global.searchNotesNonVim' + ) { // Keep Vim-only bindings visible so users can prep their layout // before turning Vim mode back on, but still let the filter work. } @@ -2457,46 +2705,97 @@ function KeymapSettings({ }) return items.length > 0 ? { ...group, items } : null }) - .filter((group): group is ReturnType[number] => !!group) + .filter( + ( + group + ): group is ReturnType[number] => + !!group + ) }, [overrides, query, vimMode]) + const totalConflicts = keymapDiagnostics.size + vimrcOverriddenIds.size const hasOverrides = Object.keys(overrides).length > 0 + const hasConflicts = totalConflicts > 0 return ( -
+
+
+
+
Vimrc
+
+ Extend custom key mappings using Vimscript syntax, takes precedence + over vim keymaps. Supports noremap, map, and unmap across normal, + visual, and insert modes. +
+
+