Skip to content

Commit cdccd55

Browse files
committed
Fix(ui): with Vim mode off, nothing on screen names a Vim key
A user with Vim mode off clicked a folder in the sidebar and got a pane ring around the whole sidebar and an `m` chip on the row, for a key that did nothing. The same shape was everywhere a surface hints at its keys: the note list's ring, the connections panel's strip and cursor card (`j/k`, `p`), the comments panel's strip and the badges under a comment (`n a e r d`), and tooltips promising `(a)`, `(o)`, `(x)`, `(c)`, `(3)`, `(or m)` and `:w`. Each was gated on "panel focused and cursor row" but never on Vim mode, while every one of those keys lives in VimNav, which stands down entirely with Vim off. To someone who never asked for Vim it read as a terminal. Every such hint now reads `vimMode` next to where it renders (or takes it as a prop where the component has no store: TasksRow, TaskStateBox and the Kanban card, whose checkbox tooltip now names the list's real toggle binding through `toggleKey` and nothing with Vim off). The comments strip is no longer mounted with Vim off, so it does not hold a blank row open under the header. The cursor styling stays in both modes: the highlighted row is the row you clicked; only what names a key goes. With Vim on nothing changes. Two Tasks surfaces were the exception to the rule the lists follow (with Vim off, single-letter keys are disabled and only arrows, Enter, Escape, Tab and Space navigate): the Kanban board and the Tasks calendar kept h/l, j/k, x, g, the bracket and angle keys and the calendar's e, m, dd, T and a live with Vim off, and their hint lines said so. They now get the same one-line guard the lists have, and their lines name what is live: arrows, Space, Enter, Tab, drag and right-click. The calendar panel beside a note does the same, since its `m` grab mode showed an "h/j/k/l pick a day" banner. TasksCalendar's handler effect also lists `vimMode` in its dependencies, which the existing `i`/`c` gating had missed. Verified over CDP in the built app with both stores isolated (docs/releases/v2.55.0/tools/check-vim-off-hints.mjs, local): two launches, Vim off then on, 28 checks reading the DOM and, for the board, the note file after `x` and Space. app-core suite and `npm run typecheck` clean.
1 parent 6f42358 commit cdccd55

13 files changed

Lines changed: 141 additions & 58 deletions

‎packages/app-core/src/components/CalendarPanel.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export function CalendarPanel({
122122
}): JSX.Element {
123123
const notes = useStore((s) => s.notes)
124124
const vaultSettings = useStore((s) => s.vaultSettings)
125+
const vimMode = useStore((s) => s.vimMode)
125126
const openDailyNoteForDate = useStore((s) => s.openDailyNoteForDate)
126127
const openWeeklyNoteForDate = useStore((s) => s.openWeeklyNoteForDate)
127128
const vaultTasks = useStore((s) => s.vaultTasks)
@@ -538,6 +539,12 @@ export function CalendarPanel({
538539
const cur = tasks[Math.min(activeTaskIndex, Math.max(0, tasks.length - 1))]
539540

540541
if (e.metaKey || e.ctrlKey || e.altKey) return
542+
// With Vim off the single-character keys stay with the page, the rule
543+
// every list in the app follows: arrows, Enter, Space, Tab and Escape
544+
// are universal, the letters (and < > [ ]) are Vim's. Nothing below
545+
// then names a key that is not live, and a note typed into by mistake
546+
// is not toggled, moved or deleted by a stray letter.
547+
if (!vimMode && e.key.length === 1 && e.key !== ' ') return
541548

542549
if (grabbedTask) {
543550
if (e.key === 'Escape') {
@@ -733,6 +740,7 @@ export function CalendarPanel({
733740
return () => window.removeEventListener('keydown', handler, true)
734741
}, [
735742
dailyEnabled,
743+
vimMode,
736744
selectedDayTasks,
737745
activeTaskIndex,
738746
grabbedTask,

‎packages/app-core/src/components/CommentsPanel.tsx‎

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export function CommentsPanel({
8888
const panelRef = useRef<HTMLElement | null>(null)
8989
const notePathRef = useRef(note.path)
9090
const commentsFocused = focusedPanel === 'comments'
91+
// The strip and the badges name VimNav's keys, which stand down with Vim
92+
// off, so neither exists then: a row of letters that do nothing reads as
93+
// a terminal to someone who never asked for one, and a strip that can
94+
// never fade in would only hold its blank row open under the header.
95+
const vimMode = useStore((s) => s.vimMode)
9196

9297
useEffect(() => {
9398
void loadNoteComments(note.path)
@@ -255,22 +260,24 @@ export function CommentsPanel({
255260
<PlusIcon width={15} height={15} />
256261
</button>
257262
</div>
258-
<div
259-
aria-hidden={!commentsFocused}
260-
className={[
261-
'mt-3 flex h-5 items-center gap-1 overflow-hidden transition-opacity',
262-
commentsFocused ? 'opacity-100' : 'pointer-events-none opacity-0'
263-
].join(' ')}
264-
>
265-
<CommentKeyHint keyLabel="j/k" label="Move" />
266-
<CommentKeyHint keyLabel="↵" label="Jump" />
267-
<CommentKeyHint keyLabel="n" label="New" />
268-
<CommentKeyHint keyLabel="a" label="Reply" />
269-
<CommentKeyHint keyLabel="e" label="Edit" />
270-
<CommentKeyHint keyLabel="r" label="Resolve" />
271-
<CommentKeyHint keyLabel="d" label="Delete" />
272-
<CommentKeyHint keyLabel="esc" label="Back to note" />
273-
</div>
263+
{vimMode && (
264+
<div
265+
aria-hidden={!commentsFocused}
266+
className={[
267+
'mt-3 flex h-5 items-center gap-1 overflow-hidden transition-opacity',
268+
commentsFocused ? 'opacity-100' : 'pointer-events-none opacity-0'
269+
].join(' ')}
270+
>
271+
<CommentKeyHint keyLabel="j/k" label="Move" />
272+
<CommentKeyHint keyLabel="↵" label="Jump" />
273+
<CommentKeyHint keyLabel="n" label="New" />
274+
<CommentKeyHint keyLabel="a" label="Reply" />
275+
<CommentKeyHint keyLabel="e" label="Edit" />
276+
<CommentKeyHint keyLabel="r" label="Resolve" />
277+
<CommentKeyHint keyLabel="d" label="Delete" />
278+
<CommentKeyHint keyLabel="esc" label="Back to note" />
279+
</div>
280+
)}
274281

275282
{draft && (
276283
<div className="mt-4 rounded-lg border border-accent/35 bg-paper-100/72 p-3 shadow-[0_14px_32px_-28px_rgb(var(--z-shadow)/0.85)]">
@@ -475,7 +482,9 @@ function CommentCard({
475482
// Render the comment body as Markdown (sanitized). Cached by renderMarkdown,
476483
// memoized per-body so card re-renders (hover/selection) don't re-parse.
477484
const bodyHtml = useMemo(() => renderMarkdown(comment.body), [comment.body])
478-
const showActionShortcuts = active && commentsFocused && !editing
485+
// One subscription per card: the badges exist only for Vim's keys.
486+
const vimMode = useStore((s) => s.vimMode)
487+
const showActionShortcuts = vimMode && active && commentsFocused && !editing
479488
const handleCardClick = (event: MouseEvent<HTMLElement>): void => {
480489
const target = event.target as HTMLElement | null
481490
if (target?.closest('button, textarea, input, select, a, [data-comment-card-control]')) return
@@ -680,7 +689,7 @@ function CommentCard({
680689
<IconTextButton
681690
title="Jump"
682691
action="jump"
683-
shortcut="↵"
692+
shortcut={vimMode ? '↵' : undefined}
684693
showShortcut={showActionShortcuts}
685694
onClick={onJump}
686695
>
@@ -690,7 +699,7 @@ function CommentCard({
690699
<IconTextButton
691700
title="Reply"
692701
action="reply"
693-
shortcut="a"
702+
shortcut={vimMode ? 'a' : undefined}
694703
showShortcut={showActionShortcuts}
695704
onClick={onReply}
696705
>
@@ -701,7 +710,7 @@ function CommentCard({
701710
<IconTextButton
702711
title="Edit"
703712
action="edit"
704-
shortcut="e"
713+
shortcut={vimMode ? 'e' : undefined}
705714
showShortcut={showActionShortcuts}
706715
onClick={onEdit}
707716
>
@@ -711,7 +720,7 @@ function CommentCard({
711720
<IconTextButton
712721
title={resolved ? 'Reopen' : 'Resolve'}
713722
action="resolve"
714-
shortcut="r"
723+
shortcut={vimMode ? 'r' : undefined}
715724
showShortcut={showActionShortcuts}
716725
onClick={onResolve}
717726
>
@@ -720,7 +729,7 @@ function CommentCard({
720729
<IconTextButton
721730
title="Delete"
722731
action="delete"
723-
shortcut="d"
732+
shortcut={vimMode ? 'd' : undefined}
724733
showShortcut={showActionShortcuts}
725734
onClick={onDelete}
726735
danger

‎packages/app-core/src/components/ConnectionsPanel.tsx‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ export function ConnectionsPanel({
6464
const [scanLoading, setScanLoading] = useState(false)
6565
const isConnectionsFocused = focusedPanel === 'connections'
6666
const isHoverPreviewFocused = focusedPanel === 'hoverpreview'
67-
const showKeyboardHints = isConnectionsFocused || isHoverPreviewFocused
67+
const vimMode = useStore((s) => s.vimMode)
68+
// Every key on the strip is VimNav's, which stands down with Vim off, so
69+
// the strip does too rather than naming keys that do nothing.
70+
const showKeyboardHints = vimMode && (isConnectionsFocused || isHoverPreviewFocused)
6871

6972
const cancelScheduledClose = (): void => {
7073
if (!closeTimerRef.current) return
@@ -454,6 +457,9 @@ function ConnectionRow({
454457
active: boolean
455458
rowIndex: number
456459
}): JSX.Element {
460+
// The cursor row keeps its styling in both modes (it is the row you
461+
// clicked); the chip on it names VimNav's key, so it exists only in Vim mode.
462+
const vimMode = useStore((s) => s.vimMode)
457463
return (
458464
<button
459465
type="button"
@@ -493,7 +499,7 @@ function ConnectionRow({
493499
<div className={['mt-2 line-clamp-3 text-xs leading-5', active ? 'text-white/85' : 'text-ink-600'].join(' ')}>
494500
{summary}
495501
</div>
496-
{active && (
502+
{active && vimMode && (
497503
<div className="mt-2 flex justify-end">
498504
<ConnectionKeyHint keyLabel="p" label="preview" active />
499505
</div>
@@ -519,6 +525,7 @@ function AttachmentConnectionRow({
519525
rowIndex: number
520526
}): JSX.Element {
521527
const name = link.assetPath.split('/').pop() ?? link.assetPath
528+
const vimMode = useStore((s) => s.vimMode)
522529
return (
523530
<button
524531
type="button"
@@ -554,7 +561,7 @@ function AttachmentConnectionRow({
554561
<div className={['mt-2 line-clamp-2 text-xs leading-5', active ? 'text-white/85' : 'text-ink-600'].join(' ')}>
555562
A file in this vault, not a note. Click to open it.
556563
</div>
557-
{active && (
564+
{active && vimMode && (
558565
<div className="mt-2 flex justify-end">
559566
<ConnectionKeyHint keyLabel="↵" label="open" active />
560567
</div>
@@ -576,6 +583,7 @@ function MissingConnectionRow({
576583
active: boolean
577584
rowIndex: number
578585
}): JSX.Element {
586+
const vimMode = useStore((s) => s.vimMode)
579587
return (
580588
<button
581589
type="button"
@@ -612,7 +620,7 @@ function MissingConnectionRow({
612620
<div className={['mt-2 line-clamp-2 text-xs leading-5', active ? 'text-white/85' : 'text-ink-600'].join(' ')}>
613621
No note resolves this wikilink yet. Click to create it.
614622
</div>
615-
{active && (
623+
{active && vimMode && (
616624
<div className="mt-2 flex justify-end">
617625
<ConnectionKeyHint keyLabel="↵" label="create" active />
618626
</div>

‎packages/app-core/src/components/EditorPane.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4929,6 +4929,8 @@ function Breadcrumb({
49294929
const createAndOpen = useStore((s) => s.createAndOpen)
49304930
const createDrawingAndOpen = useStore((s) => s.createDrawingAndOpen)
49314931
const createFolder = useStore((s) => s.createFolder)
4932+
// `m` on a focused crumb is VimNav's, so the tooltip names it only in Vim mode.
4933+
const vimMode = useStore((s) => s.vimMode)
49324934
const [crumbMenu, setCrumbMenu] = useState<{ x: number; y: number; subpath: string } | null>(
49334935
null
49344936
)
@@ -5019,7 +5021,7 @@ function Breadcrumb({
50195021
setCrumbMenu({ x: e.clientX, y: e.clientY, subpath: c.subpath })
50205022
}}
50215023
className="truncate rounded px-1 hover:bg-paper-200/70 hover:text-ink-800"
5022-
title={`Go to ${c.label} — right-click (or m) to create here`}
5024+
title={`Go to ${c.label}, right-click${vimMode ? ' (or m)' : ''} to create here`}
50235025
>
50245026
{c.label}
50255027
</button>

‎packages/app-core/src/components/NoteList.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export function NoteList(): JSX.Element {
9595
const openDatabase = useStore((s) => s.openDatabase)
9696
const prefetchNotes = useStore((s) => s.prefetchNotes)
9797
const focusedPanel = useStore((s) => s.focusedPanel)
98+
const vimMode = useStore((s) => s.vimMode)
9899
const noteListCursorIndex = useStore((s) => s.noteListCursorIndex)
99100
const setFocusedPanel = useStore((s) => s.setFocusedPanel)
100101
const systemFolderLabels = useStore((s) => s.systemFolderLabels)
@@ -716,7 +717,9 @@ export function NoteList(): JSX.Element {
716717

717718
return (
718719
<section
719-
className={`glass-column relative flex shrink-0 flex-col${isNoteListFocused ? ' panel-focused' : ''}`}
720+
// The pane ring is Vim pane navigation's (see the sidebar): not drawn
721+
// for a click with Vim off.
722+
className={`glass-column relative flex shrink-0 flex-col${isNoteListFocused && vimMode ? ' panel-focused' : ''}`}
720723
style={{ width: noteListWidth }}
721724
onMouseDownCapture={() => setFocusedPanel('notelist')}
722725
onFocusCapture={() => setFocusedPanel('notelist')}

‎packages/app-core/src/components/QuickCaptureApp.tsx‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,7 @@ export function QuickCaptureApp(): JSX.Element {
727727
<CommandOverlay
728728
modKey={modKey}
729729
mode={mode}
730+
vimMode={prefs.vimMode}
730731
onCancel={() => {
731732
setOverlay('none')
732733
requestAnimationFrame(() => editorRef.current?.focus())
@@ -883,11 +884,14 @@ type CommandAction = 'save' | 'save-no-close' | 'new' | 'open'
883884
interface CommandOverlayProps {
884885
modKey: string
885886
mode: EditingMode
887+
/** Whether the capture editor runs Vim: its ex line is the only way to
888+
* `:w`, so that hint exists only then. */
889+
vimMode: boolean
886890
onAction: (action: CommandAction) => void
887891
onCancel: () => void
888892
}
889893

890-
function CommandOverlay({ modKey, mode, onAction, onCancel }: CommandOverlayProps): JSX.Element {
894+
function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOverlayProps): JSX.Element {
891895
const [query, setQuery] = useState('')
892896
const [active, setActive] = useState(0)
893897
const inputRef = useRef<HTMLInputElement | null>(null)
@@ -907,7 +911,7 @@ function CommandOverlay({ modKey, mode, onAction, onCancel }: CommandOverlayProp
907911
{
908912
id: 'save-no-close' as CommandAction,
909913
label: 'Save without hiding',
910-
hint: ':w',
914+
hint: vimMode ? ':w' : '',
911915
keywords: 'save write keep open'
912916
},
913917
{
@@ -923,7 +927,7 @@ function CommandOverlay({ modKey, mode, onAction, onCancel }: CommandOverlayProp
923927
keywords: 'open switch picker find search note'
924928
}
925929
],
926-
[mode.kind, modKey]
930+
[mode.kind, modKey, vimMode]
927931
)
928932

929933
const results = useMemo(() => {
@@ -988,9 +992,11 @@ function CommandOverlay({ modKey, mode, onAction, onCancel }: CommandOverlayProp
988992
].join(' ')}
989993
>
990994
<span className="truncate">{cmd.label}</span>
991-
<kbd className="ml-auto rounded bg-paper-200 px-1.5 py-0.5 text-2xs text-ink-500">
992-
{cmd.hint}
993-
</kbd>
995+
{cmd.hint && (
996+
<kbd className="ml-auto rounded bg-paper-200 px-1.5 py-0.5 text-2xs text-ink-500">
997+
{cmd.hint}
998+
</kbd>
999+
)}
9941000
</button>
9951001
)
9961002
})

‎packages/app-core/src/components/Sidebar.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ export function Sidebar(): JSX.Element {
449449
const allFolders = useStore((s) => s.folders);
450450
const hasAssetsDir = useStore((s) => s.hasAssetsDir);
451451
const focusedPanel = useStore((s) => s.focusedPanel);
452+
const vimMode = useStore((s) => s.vimMode);
452453
const sidebarCursorIndex = useStore((s) => s.sidebarCursorIndex);
453454
const activeNote = useStore((s) => s.activeNote);
454455
const activeDirty = useStore((s) => s.activeDirty);
@@ -2921,7 +2922,11 @@ export function Sidebar(): JSX.Element {
29212922
return (
29222923
<SidebarScrollerContext.Provider value={sidebarScrollRef}>
29232924
<aside
2924-
className={`glass-sidebar relative flex shrink-0 flex-col pt-3${isSidebarFocused ? " panel-focused" : ""}`}
2925+
// The pane ring answers "which pane do Ctrl+W h/j/k/l move from", a
2926+
// question only Vim mode asks; a click into the sidebar with Vim off
2927+
// used to draw it too, and read as a terminal to people who never use
2928+
// the keys it stands for.
2929+
className={`glass-sidebar relative flex shrink-0 flex-col pt-3${isSidebarFocused && vimMode ? " panel-focused" : ""}`}
29252930
style={{ width: sidebarWidth }}
29262931
// Programmatic focus target for focusSidebarPanel (the Focus Sidebar
29272932
// command); -1 keeps it out of the tab order.
@@ -6288,6 +6293,13 @@ function DateNotesNav({
62886293
return <div className="flex flex-col">{rows}</div>;
62896294
}
62906295

6296+
/**
6297+
* The key chip on the cursor row (`m` opens the row's menu). The key is
6298+
* Vim's: VimNav owns every single-letter shortcut and stands down entirely
6299+
* with Vim mode off, so with Vim off the chip named a key that did nothing.
6300+
* Read from the store here rather than threaded through six row components,
6301+
* which is one subscription, since only the cursor row mounts a chip.
6302+
*/
62916303
function RowKeyHint({
62926304
active,
62936305
keyLabel,
@@ -6298,7 +6310,9 @@ function RowKeyHint({
62986310
keyLabel: string;
62996311
label?: string;
63006312
compact?: boolean;
6301-
}): JSX.Element {
6313+
}): JSX.Element | null {
6314+
const vimMode = useStore((s) => s.vimMode);
6315+
if (!vimMode) return null;
63026316
return (
63036317
<span
63046318
className={[

‎packages/app-core/src/components/TagView.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ export function TagView(): JSX.Element {
395395
<button
396396
type="button"
397397
onClick={() => setSelectedTags([])}
398-
title="Clear all selected tags (c)"
398+
title={vimMode ? 'Clear all selected tags (c)' : 'Clear all selected tags'}
399399
className="rounded-md border border-paper-300/60 px-2 py-1 text-xs text-current/60 transition-colors hover:bg-paper-200/70 hover:text-current/90"
400400
>
401401
Clear all
@@ -660,7 +660,9 @@ export function TagView(): JSX.Element {
660660
</form>
661661
) : (
662662
<div className="border-t border-paper-300/50 px-4 py-1.5 text-xs text-current/40">
663-
j/k move · Enter/o open · click chips to toggle · c clear tags · / filter · : command · :q close
663+
{vimMode
664+
? 'j/k move · Enter/o open · click chips to toggle · c clear tags · / filter · : command · :q close'
665+
: '↑/↓ move · Enter open · click chips to toggle'}
664666
</div>
665667
)}
666668
{tagMenu && (

‎packages/app-core/src/components/TaskStateBox.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ interface Props {
2222
className?: string
2323
/** Stop the pointer events that would otherwise start a card drag. */
2424
stopPointerEvents?: boolean
25+
/** The key that toggles the task from the keyboard, named in the tooltip.
26+
* Left out with Vim mode off, where no such key exists: the tooltip then
27+
* promises only what the click does. */
28+
toggleKey?: string | null
2529
}
2630

2731
function stateLabel(task: VaultTask): string {
@@ -36,7 +40,8 @@ export function TaskStateBox({
3640
onToggle,
3741
idleClassName = 'border border-current/40 hover:bg-current/10',
3842
className = 'mt-0.5',
39-
stopPointerEvents = false
43+
stopPointerEvents = false,
44+
toggleKey = null
4045
}: Props): JSX.Element {
4146
const stopper = stopPointerEvents
4247
? {
@@ -50,7 +55,7 @@ export function TaskStateBox({
5055
role="checkbox"
5156
aria-checked={task.checked}
5257
draggable={false}
53-
title={`${stateLabel(task)}Toggle task (x)`}
58+
title={`${stateLabel(task)}Toggle task${toggleKey ? ` (${toggleKey})` : ''}`}
5459
{...stopper}
5560
onClick={(e) => {
5661
e.stopPropagation()

0 commit comments

Comments
 (0)