From 14ad7597667d77cd72a0dcd5ac1edc8aaecb8aa5 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 24 Sep 2026 18:24:02 -0500 Subject: [PATCH 01/20] Test(store): every note-integrity test settles its dirty buffers before the next one starts The 2.56.0 gate caught one test in this file leaking into another: typing arms the store's 350 ms debounced save, the test ended with the buffer dirty, and the timer fired while a later test was collecting writes into the file's shared writeCalls log. `2c3dd065` settled that one test by hand. Twenty-odd other tests here type and stop without settling; they pass because their timing has never lined up, which is not a reason. Each test's store module is now kept, and afterEach settles it: real timers back first (a fake-timer test's pending saves fall away with the fake clock), then persistNote on every dirty path, raced against 50 ms because a test may have left the bridge's writeNote gated forever. persistNote clears the pending timer before its first await, so the race can only cut short a write that goes to the finished test's own bridge anyway. Proven with a throwaway probe test that waited past the debounce after the dirty #852 test: it saw `unsaved typing` land with the guard neutered and nothing with it in place. The store is untouched. --- .../app-core/src/store-note-integrity.test.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/app-core/src/store-note-integrity.test.ts b/packages/app-core/src/store-note-integrity.test.ts index 7b2aa106..bd72ccc7 100644 --- a/packages/app-core/src/store-note-integrity.test.ts +++ b/packages/app-core/src/store-note-integrity.test.ts @@ -83,10 +83,17 @@ function installZen(): void { }) } +// The store module a test loaded. Each test gets a fresh one, but a timer the +// old one armed keeps running with real timers and calls whatever window.zen +// is current when it fires, so afterEach settles the old store before the +// next test installs its own bridge. +let loaded: Awaited> | null = null + async function loadStore() { vi.resetModules() localStorage.clear() - return import('./store') + loaded = await import('./store') + return loaded } async function flush(): Promise { @@ -97,8 +104,27 @@ beforeEach(() => { vi.restoreAllMocks() installZen() }) -afterEach(() => { +afterEach(async () => { + // Real timers first: a fake-timer test's pending saves are dropped with the + // fake clock, and the race below needs a real setTimeout. vi.useRealTimers() + // Typing arms the store's 350 ms debounced save. A test that ends with a + // dirty buffer would let that timer fire into a later test and push a write + // nobody there made into writeCalls (the #852 dirty-buffer test did exactly + // that under turbo's parallel load). persistNote clears the timer before + // its first await, so calling it is enough; the write it starts goes to + // this test's bridge, which may be gated forever, hence the race. + const store = loaded + loaded = null + if (!store) return + const state = store.useStore.getState() + const dirty = Object.entries(state.noteDirty) + .filter(([, isDirty]) => isDirty) + .map(([path]) => path) + await Promise.race([ + Promise.allSettled(dirty.map((path) => state.persistNote(path))), + new Promise((resolve) => setTimeout(resolve, 50)) + ]) }) function seedRootVault(useStore: { setState: (s: Record) => void }): void { @@ -259,9 +285,7 @@ describe('#852: a note body taken from disk moves its disk revision', () => { expect(useStore.getState().noteContents[a]?.body).toBe('unsaved typing') expect(noteDiskRevision(a)).toBe(0) - // The typing above armed the debounced save; settle it here rather than - // let it fire into a later test. The user's own write reaching disk is - // not a disk change either. + // The user's own write reaching disk is not a disk change either. await useStore.getState().persistNote(a) expect(vault.get(a)).toBe('unsaved typing') expect(noteDiskRevision(a)).toBe(0) From eada4bc80cce3768aef564b6cfb6a194652dc364 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 24 Sep 2026 18:24:02 -0500 Subject: [PATCH 02/20] Fix(tooling): the Vim editor smoke puts focus back before each check block On the 2.56.0 gate run the suite passed its first three checks and then failed four in a row, every one reporting focused:false over a cursor that had not moved: something took DOM focus from the editor after the `$` check, and from then on each key went nowhere. The second run passed 12 of 12, so the app was fine and the run was not. Each check block now starts with ensureEditorFocus: if the editor's .cm-content is not the active element and the ex prompt does not own focus on purpose, the script says so in a `note` line and calls the existing focusEditor before typing. gotoLine does the same, since five blocks begin there. One stolen focus therefore costs at most the block it happened in, and the log names it instead of four unrelated FAIL lines. Verified by injecting a focused button at the exact spot of the gate failure: the script logged the note and passed 12 of 12; without the injection it passes silently as before. --- tooling/scripts/vim-editor-smoke.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tooling/scripts/vim-editor-smoke.mjs b/tooling/scripts/vim-editor-smoke.mjs index b79e5b72..c909b5c7 100644 --- a/tooling/scripts/vim-editor-smoke.mjs +++ b/tooling/scripts/vim-editor-smoke.mjs @@ -294,6 +294,7 @@ async function keys(client, sequence) { } async function gotoLine(client, line) { + await ensureEditorFocus(client) await keys(client, String(line).split('')) await press(client, 'G', { shift: true }) await sleep(100) @@ -391,6 +392,26 @@ async function focusEditor(client) { await sleep(100) } +// Something outside the checks (a tooltip, a toast, the window's own +// activation) can take DOM focus from the editor between two checks. With +// focus gone every later key goes nowhere, and each check then fails for the +// same reason, which is what the 2.56.0 gate run showed: four failures in a +// row, all reporting focused:false over an unmoved cursor. Every check block +// starts by putting focus back, so one stolen focus costs at most the block +// it happened in. The ex prompt owns focus on purpose; leave that alone. +async function ensureEditorFocus(client) { + const state = await evaluate( + client, + `(() => ({ + editor: document.activeElement === document.querySelector('.cm-content'), + prompt: document.activeElement instanceof HTMLInputElement + }))()` + ) + if (state?.editor || state?.prompt) return + console.log(' note editor lost focus between checks; refocusing') + await focusEditor(client) +} + async function main() { await prepareBuild() const tempRoot = await mkdtemp(join(tmpdir(), 'zennotes-vim-editor-')) @@ -485,6 +506,7 @@ async function main() { if (!noteReady || !editorReady) throw new Error('Editor did not become ready') await focusEditor(client) + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0']) const lineStart = await editorSnapshot(client) await press(client, '$') @@ -495,6 +517,7 @@ async function main() { JSON.stringify({ lineStart, dollar }) ) + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0']) await press(client, 'A', { shift: true }) await press(client, 'X', { shift: true }) @@ -507,6 +530,7 @@ async function main() { ) await press(client, 'u') + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0', 'j']) await press(client, 'I', { shift: true }) await press(client, 'Y', { shift: true }) @@ -519,6 +543,7 @@ async function main() { ) await press(client, 'u') + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0']) const wrappedCountStart = await editorSnapshot(client) await keys(client, ['8', 'j']) From 94c05d2fbf0163fd0bc4f1143bce165ed29a569d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 24 Sep 2026 18:25:07 -0500 Subject: [PATCH 03/20] Test(store): name the loaded store module's type directly `14ad7597` typed the kept module through loadStore's return type while loadStore's return type came from that same variable, which tsc rejects as a circular annotation (TS2502, TS7023). vitest ran the file fine, so the suite alone did not catch it; the workspace typecheck did. Both now name `typeof import('./store')` outright. --- packages/app-core/src/store-note-integrity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-core/src/store-note-integrity.test.ts b/packages/app-core/src/store-note-integrity.test.ts index bd72ccc7..c79a0ec9 100644 --- a/packages/app-core/src/store-note-integrity.test.ts +++ b/packages/app-core/src/store-note-integrity.test.ts @@ -87,9 +87,9 @@ function installZen(): void { // old one armed keeps running with real timers and calls whatever window.zen // is current when it fires, so afterEach settles the old store before the // next test installs its own bridge. -let loaded: Awaited> | null = null +let loaded: typeof import('./store') | null = null -async function loadStore() { +async function loadStore(): Promise { vi.resetModules() localStorage.clear() loaded = await import('./store') From f8b24c09af1828b5242d572c7636df83236e2586 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 24 Sep 2026 18:56:07 -0500 Subject: [PATCH 04/20] Fix(vault): a host whose root is a label names the folder itself (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display name shipped in 2.56.0 falls back to the folder's name, and the app took that from the root's last path segment: right for desktop, the web client and a remote workspace, whose roots are paths. The phone shells describe a vault by a label instead ("On this device › ZenNotes › docs"), which has no path segment, so on a phone the whole label stood in for the folder name. The first vault-settings save on a vault with no display name (a favorite toggled is enough) then renamed the vault to that label in the drawer, since the store re-derives the name after every save, and clearing a display name landed on the label too. Reproduced against the store with a phone-style root before this fix. VaultInfo gains an optional `folderName`: the folder's own name on disk, for a host whose root is not a path. `vaultFolderName` moves from the renderer into shared-domain's vault-display-name module, prefers that field, and otherwise keeps taking the root's last segment, so no existing host changes behaviour; the store's fallback, the rename prompt and the Settings field's placeholder all go through it. The shells set `folderName` to the folder they opened. Desktop main is left alone: its roots are paths. The three core packages move to 2.56.1 for the phones' core-only release; the desktop app stays 2.56.0. --- package-lock.json | 6 +++--- packages/app-core/package.json | 2 +- .../app-core/src/components/SettingsModal.tsx | 4 ++-- packages/app-core/src/lib/rename-vault.ts | 9 +++------ packages/app-core/src/rename-vault.test.ts | 20 +++++++++++++++++++ packages/app-core/src/store.ts | 5 ++--- packages/bridge-contract/package.json | 2 +- packages/bridge-contract/src/ipc.ts | 10 ++++++++++ packages/shared-domain/package.json | 2 +- .../src/vault-display-name.test.ts | 19 ++++++++++++++++++ .../shared-domain/src/vault-display-name.ts | 12 +++++++++++ 11 files changed, 74 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76bbe24b..23b466bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.56.0", + "version": "2.56.1", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.56.0", + "version": "2.56.1", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.56.0", + "version": "2.56.1", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" diff --git a/packages/app-core/package.json b/packages/app-core/package.json index db9d902c..ecfd5065 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.56.0", + "version": "2.56.1", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index e001c6fa..84f22855 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -4152,9 +4152,9 @@ export function SettingsModal(): JSX.Element { {workspaceMode !== "remote" && vault && !vault.temporary && ( void renameVault(next)} diff --git a/packages/app-core/src/lib/rename-vault.ts b/packages/app-core/src/lib/rename-vault.ts index 0d7666c2..26cf85f9 100644 --- a/packages/app-core/src/lib/rename-vault.ts +++ b/packages/app-core/src/lib/rename-vault.ts @@ -6,14 +6,11 @@ * prompt; Settings has a field instead. */ import type { VaultInfo, WorkspaceMode } from '@shared/ipc' +import { vaultFolderName } from '@shared/vault-display-name' import { promptApp } from './prompt-requests' import { useStore } from '../store' -/** The folder a vault lives in, as it is shown beside a display name: the - * root's last segment, POSIX or Windows. */ -export function vaultFolderName(root: string): string { - return root.split(/[\\/]/).filter(Boolean).pop() ?? root -} +export { vaultFolderName } /** * Whether the open vault can take a display name: a local vault that is not @@ -34,7 +31,7 @@ export async function renameVaultWithPrompt(): Promise { const state = useStore.getState() const vault = state.vault if (!vault || !canRenameVault(state)) return false - const folder = vaultFolderName(vault.root) + const folder = vaultFolderName(vault) const next = await promptApp({ title: 'Rename vault', description: `Shown in the sidebar and the vault switcher. The folder stays ${folder} on disk; leave the name empty to go back to it.`, diff --git a/packages/app-core/src/rename-vault.test.ts b/packages/app-core/src/rename-vault.test.ts index 1a6fa410..040d6043 100644 --- a/packages/app-core/src/rename-vault.test.ts +++ b/packages/app-core/src/rename-vault.test.ts @@ -133,6 +133,26 @@ describe('store.renameVault', () => { expect(useStore.getState().closedTabStack).toHaveLength(0) }) + it('a host whose root is a label keeps the folder name it gave through every settings save', async () => { + // The phone shells hand over a VaultInfo whose root reads + // "On this device › ZenNotes › docs" and whose folderName is "docs". + // Before folderName existed, the first settings save (a favorite toggled + // is enough) renamed the vault to the whole label, because the fallback + // took the root's last path segment and a label has none. + const useStore = await loadStore() + const phone = { root: 'On this device › ZenNotes › docs', name: 'docs', folderName: 'docs' } + useStore.setState({ vault: phone } as never) + + await useStore.getState().setVaultSettings({ ...DEFAULT_VAULT_SETTINGS, favorites: ['inbox/a.md'] }) + expect(useStore.getState().vault?.name).toBe('docs') + + expect(await useStore.getState().renameVault('Acme API docs')).toBe(true) + expect(useStore.getState().vault?.name).toBe('Acme API docs') + + expect(await useStore.getState().renameVault('')).toBe(true) + expect(useStore.getState().vault?.name).toBe('docs') + }) + it('an external vault.json change renames the open vault too', async () => { const useStore = await loadStore() ;(window.zen.getVaultSettings as ReturnType).mockResolvedValue({ diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 9c47f145..24fd0cc6 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -11,7 +11,7 @@ import { type EditorCursorPosition } from './lib/editor-cursor-position' import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' -import { normalizeVaultDisplayName, resolveVaultName } from '@shared/vault-display-name' +import { normalizeVaultDisplayName, resolveVaultName, vaultFolderName } from '@shared/vault-display-name' import { DEFAULT_HARPER_DIALECT, isHarperDialect, @@ -4821,8 +4821,7 @@ function withoutNoteInWorkspace(s: Store, path: string): Partial { function applyVaultNameFromSettings(settings: VaultSettings): void { const vault = useStore.getState().vault if (!vault) return - const folder = vault.root.split(/[\\/]/).filter(Boolean).pop() ?? vault.root - const name = resolveVaultName(settings.displayName, folder) + const name = resolveVaultName(settings.displayName, vaultFolderName(vault)) if (name === vault.name) return useStore.setState({ vault: { ...vault, name } }) } diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 1792b755..82ee79a5 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.56.0", + "version": "2.56.1", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index b11ee679..82e19f4e 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -761,6 +761,16 @@ export interface PastedImageInput { export interface VaultInfo { root: string name: string + /** + * The folder's own name on disk, for a host whose `root` is not a path + * (the phone shells describe a vault as "On this device › ZenNotes › docs"). + * The display name (#692) falls back to this when it is cleared, and the + * Vault name field shows it as the placeholder; without it the app takes + * the root's last path segment, which is right for every host whose root + * is a path (desktop, the web client, a remote workspace) and wrong for + * one whose root is a label. + */ + folderName?: string /** True when this is a temporary folder session (a folder dropped on the app * to read, not opened as a vault): edits save to the files in place, but no * ZenNotes state is written into the folder and it isn't remembered. The diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index aa59a248..067bac8e 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.56.0", + "version": "2.56.1", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/vault-display-name.test.ts b/packages/shared-domain/src/vault-display-name.test.ts index 240a4169..c39d2e7d 100644 --- a/packages/shared-domain/src/vault-display-name.test.ts +++ b/packages/shared-domain/src/vault-display-name.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { VAULT_DISPLAY_NAME_MAX_LENGTH, normalizeVaultDisplayName, + vaultFolderName, resolveVaultName } from './vault-display-name' @@ -56,3 +57,21 @@ describe('resolveVaultName', () => { expect(resolveVaultName(undefined, 'docs')).toBe('docs') }) }) + +describe('vaultFolderName', () => { + it('takes the last segment of a path root, POSIX or Windows', () => { + expect(vaultFolderName({ root: '/Users/me/repos/acme/docs' })).toBe('docs') + expect(vaultFolderName({ root: 'C:\\Users\\me\\repos\\acme\\docs\\' })).toBe('docs') + expect(vaultFolderName({ root: '/docs' })).toBe('docs') + }) + + it('prefers the folder name a host gave, which a label root cannot provide', () => { + // The phone shells describe a vault by a label, not a path: without the + // host's folder name the whole label would stand in for it. + const label = 'On this device › ZenNotes › docs' + expect(vaultFolderName({ root: label, folderName: 'docs' })).toBe('docs') + expect(vaultFolderName({ root: label })).toBe(label) + // A host that gives it for a path root is simply believed. + expect(vaultFolderName({ root: '/Users/me/repos/acme/docs', folderName: 'docs' })).toBe('docs') + }) +}) diff --git a/packages/shared-domain/src/vault-display-name.ts b/packages/shared-domain/src/vault-display-name.ts index 5b7779f1..c61d6230 100644 --- a/packages/shared-domain/src/vault-display-name.ts +++ b/packages/shared-domain/src/vault-display-name.ts @@ -55,3 +55,15 @@ function cutAtUtf16Units(text: string, limit: number): string { export function resolveVaultName(displayName: unknown, folderName: string): string { return normalizeVaultDisplayName(displayName) ?? folderName } + +/** + * The folder a vault lives in, as it is shown beside a display name and as + * the name to fall back to: the host's `folderName` when it gave one, else + * the root's last segment, POSIX or Windows. A host whose root is a label + * rather than a path (the phone shells) must give the folder name, or the + * whole label would stand in for it. + */ +export function vaultFolderName(vault: { root: string; folderName?: string }): string { + if (vault.folderName) return vault.folderName + return vault.root.split(/[\\/]/).filter(Boolean).pop() ?? vault.root +} From 02ef25cd35fe1d77712ad76c136eb4b3177af265 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 25 Sep 2026 08:42:31 -0500 Subject: [PATCH 05/20] Fix(shell): the snapshot carries the vault's folder name (#692) The shell snapshot's vault is typed as a VaultInfo, but getShellSnapshot rebuilt it from three named fields: root, name and temporary. The folderName that f8b24c09 added for hosts whose root is a label never got through, so a phone shell reading it from the snapshot got undefined with no error, and its vault switcher could not tell which folder was open once a display name made the shown name a different string. Found while driving the iPhone shell's switcher on the simulator; the shells now take the folder from their own bridge, which holds the open vault by folder, so nothing waits on this, but the type's promise should hold. The rebuild names folderName too, and the change check compares it, so a host that hands over a different folder gets a fresh frozen snapshot. A test pins both. --- packages/app-core/src/shell.test.ts | 17 +++++++++++++++++ packages/app-core/src/shell.ts | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/packages/app-core/src/shell.test.ts b/packages/app-core/src/shell.test.ts index d756119b..1882af7e 100644 --- a/packages/app-core/src/shell.test.ts +++ b/packages/app-core/src/shell.test.ts @@ -158,6 +158,23 @@ describe('public shell snapshots', () => { expect(s.getShellSnapshot().vault).toBeNull() }) + it('carries the folder name a host gave, and notices when it changes', async () => { + // The phone shells describe a vault by a label root and name the folder + // separately (#692); a snapshot that dropped folderName made the + // switcher unable to tell which folder is open. + const s = await setup() + s.useStore.setState({ + vault: { root: 'On this device › ZenNotes › docs', name: 'Acme API docs', folderName: 'docs' } + }) + const first = s.getShellSnapshot() + expect(first.vault).toMatchObject({ name: 'Acme API docs', folderName: 'docs' }) + s.useStore.setState({ + vault: { root: 'On this device › ZenNotes › docs', name: 'Acme API docs', folderName: 'docs2' } + }) + expect(s.getShellSnapshot().vault?.folderName).toBe('docs2') + expect(s.getShellSnapshot().vault).not.toBe(first.vault) + }) + it('observes the current state after an earlier subscriber corrects a transition', async () => { const s = await setup() disposers.push( diff --git a/packages/app-core/src/shell.ts b/packages/app-core/src/shell.ts index 450a5571..9ed3c4d1 100644 --- a/packages/app-core/src/shell.ts +++ b/packages/app-core/src/shell.ts @@ -94,11 +94,16 @@ export function getShellSnapshot(): ShellSnapshot { else if ( vault?.root !== state.vault.root || vault.name !== state.vault.name || + vault.folderName !== state.vault.folderName || vault.temporary !== state.vault.temporary ) { + // Every field of VaultInfo, by name: the snapshot is typed as one, and a + // host that reads a field this list forgot gets undefined with no error + // (folderName, for the phone shells, was missing here in 2.56.1). vault = Object.freeze({ root: state.vault.root, name: state.vault.name, + folderName: state.vault.folderName, temporary: state.vault.temporary }) } From 5f8095b25f4367324a072633e5c71f0d87f7cd30 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 27 Sep 2026 11:35:36 -0500 Subject: [PATCH 06/20] Fix(mcp): the bundled MCP follows the app to another vault, and never carries work across runMcpServer resolved the vault once and kept it for the session, so an agent on the bundled MCP (every Windows install, and any client set up without the managed zn) kept working in the vault the app had left until the client restarted the server. The Go zn mcp did the same (Kevin on Discord, 2026-09-25: the app went from his server back to a local vault and the agent kept hitting the server). The pin was deliberate, since an agent that read notes in one vault and writes after the user switched would change a note at the same path in the other one. VaultSession mirrors the Go fix (ZenNotes/tui 35a77be, session.go): the target is resolved before every tool call; after a switch every tool but vault_info fails with VaultSwitchedError, which names both vaults and runs nothing, and calls resolve one at a time so a parallel batch stops as one; vault_info moves the session and its notes say which vault it left. The startup check still reports an unresolvable --vault on stderr (#831), but the session starts at the first call it serves, so a vault the app left before any call is no switch. sameVault compares servers by normalized URL and directories by device and inode. A saved server is named in quotes with its URL after "at", because the app often names a profile after its host already. The test that pinned the first vault now expects the refusal, and two new ones drive a real in-memory client through a switch, a refused write that never lands, and vault_info. The MCP and CLI cards in the manual say how the server follows a switch and how a server token reaches zn: `zn connect --no-default` once, or ZENNOTES_REMOTE_TOKEN. The token half describes zn's fallback (ZenNotes/tui 86e2149), which desktop-managed installs get with the next terminal-release.json pin; this engine still reads its token from the environment only. Verified with the dev app driven through its own UI and both MCP engines attached, plus a headless Claude Code agent that recovered from the refusal on its own. --- apps/desktop/src/cli/vault-target.test.ts | 43 ++++++- apps/desktop/src/cli/vault-target.ts | 25 ++++ apps/desktop/src/mcp/server.test.ts | 98 +++++++++++++-- apps/desktop/src/mcp/server.ts | 144 ++++++++++++++++++---- packages/app-core/src/lib/help.ts | 4 +- 5 files changed, 274 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/cli/vault-target.test.ts b/apps/desktop/src/cli/vault-target.test.ts index 2639caf2..a36c3523 100644 --- a/apps/desktop/src/cli/vault-target.test.ts +++ b/apps/desktop/src/cli/vault-target.test.ts @@ -9,7 +9,9 @@ import { resolveDefaultTarget, resolveTarget, resolveServerTarget, - resolveVaultTarget + resolveVaultTarget, + sameVault, + type VaultTarget } from './vault-target' let tmpDir: string @@ -290,3 +292,42 @@ describe('the default target follows the workspace the app has open (#688)', () expect(await resolveDefaultTarget(process.env)).toEqual({ kind: 'local', root: workVault }) }) }) + +describe('sameVault: a vault switch is a different vault, not new credentials', () => { + const local = (root: string): VaultTarget => ({ kind: 'local', root }) + const server = (baseUrl: string, name = '', authToken: string | null = null): VaultTarget => ({ + kind: 'remote', + name, + baseUrl, + authToken + }) + + it('matches a directory however its path is spelled, symlinks included', async () => { + const other = path.join(tmpDir, 'other') + await fsp.mkdir(other, { recursive: true }) + // Windows only allows symlinks with developer mode or elevation. + const link = path.join(tmpDir, 'work-link') + const linked = await fsp.symlink(workVault, link, 'dir').then( + () => link, + () => workVault + ) + expect(await sameVault(local(workVault), local(workVault))).toBe(true) + expect(await sameVault(local(workVault), local(`${other}/../work/`))).toBe(true) + expect(await sameVault(local(workVault), local(linked))).toBe(true) + expect(await sameVault(local(workVault), local(other))).toBe(false) + expect(await sameVault(local(path.join(tmpDir, 'a')), local(path.join(tmpDir, 'b')))).toBe(false) + }) + + it('matches a server by URL whatever the token or profile name', async () => { + expect( + await sameVault( + server('https://notes.example.com', 'home', 'old'), + server('https://notes.example.com', '', 'new') + ) + ).toBe(true) + expect(await sameVault(server('https://Notes.Example.com/'), server('https://notes.example.com'))).toBe(true) + expect(await sameVault(server('notes.example.com:7878'), server('http://notes.example.com:7878'))).toBe(true) + expect(await sameVault(server('http://notes.example.com:7878'), server('http://notes.example.com:7879'))).toBe(false) + expect(await sameVault(local(workVault), server('https://notes.example.com'))).toBe(false) + }) +}) diff --git a/apps/desktop/src/cli/vault-target.ts b/apps/desktop/src/cli/vault-target.ts index 6a3cc79c..c38461fc 100644 --- a/apps/desktop/src/cli/vault-target.ts +++ b/apps/desktop/src/cli/vault-target.ts @@ -18,6 +18,8 @@ * is immediately nameable from the CLI with nothing else to configure. */ +import { promises as fsp } from 'node:fs' +import path from 'node:path' import { normalizeBaseUrl } from '../main/remote/connection.js' import { readActiveWorkspaceFromConfig, @@ -32,6 +34,29 @@ export type VaultTarget = | { kind: 'local'; root: string } | { kind: 'remote'; name: string; baseUrl: string; authToken: string | null } +/** Two targets reach the same vault: the same server URL, or the same + * directory however its path is spelled. Tokens and profile names do not + * matter. */ +export async function sameVault(a: VaultTarget, b: VaultTarget): Promise { + if (a.kind === 'remote' || b.kind === 'remote') { + return ( + a.kind === 'remote' && + b.kind === 'remote' && + normalizeBaseUrl(a.baseUrl).toLowerCase() === normalizeBaseUrl(b.baseUrl).toLowerCase() + ) + } + if (path.resolve(a.root) === path.resolve(b.root)) return true + try { + const [left, right] = await Promise.all([ + fsp.stat(a.root, { bigint: true }), + fsp.stat(b.root, { bigint: true }) + ]) + return left.dev === right.dev && left.ino === right.ino + } catch { + return false + } +} + /** Token precedence, loudest first: an explicit `--token`, then the * environment (the CI / headless case, where nothing is stored on disk), * then whatever the saved profile carries. Mirrors how `--vault` beats diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index c1d08ee7..8b55f386 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -15,6 +15,7 @@ import { describeToolError, listToolNames, runMcpServer, + VaultSwitchedError, type McpServerOptions } from './server' @@ -154,6 +155,19 @@ describe('describeToolError', () => { }) }) +describe('VaultSwitchedError', () => { + it('names a saved server by its quoted name and URL, and never the token', () => { + const err = new VaultSwitchedError( + { kind: 'remote', name: 'Server Vault (10.0.0.5:7878)', baseUrl: 'http://10.0.0.5:7878', authToken: 'tok-secret' }, + { kind: 'local', root: '/notes' } + ) + expect(err.message).toContain( + 'it was the server "Server Vault (10.0.0.5:7878)" at http://10.0.0.5:7878 and is now the local vault /notes.' + ) + expect(err.message).not.toContain('tok-secret') + }) +}) + describe('comment tools (#738)', () => { it('lists the four comment tools', () => { const names = listToolNames() @@ -270,12 +284,16 @@ describe('runMcpServer follows the target it is given (#831)', () => { await runMcpServer({ ...options, transport: serverTransport }) const client = new Client({ name: 'server-test', version: '0' }) await client.connect(clientTransport) + const call = async (name: string, args: Record = {}) => { + const result = await client.callTool({ name, arguments: args }) + return { text: (result.content as Array<{ text: string }>)[0].text, isError: !!result.isError } + } return { stderr, + call, vaultInfo: async () => { - const result = await client.callTool({ name: 'vault_info', arguments: {} }) - const text = (result.content as Array<{ text: string }>)[0].text - return result.isError ? { error: text } : { info: JSON.parse(text) as Record } + const { text, isError } = await call('vault_info') + return isError ? { error: text } : { info: JSON.parse(text) as Record } }, close: () => client.close() } @@ -347,13 +365,13 @@ describe('runMcpServer follows the target it is given (#831)', () => { } }) - it('pins the first vault that resolves and retries only after a failure', async () => { - // One attempt at startup (warned), one per failing tool call, then the - // session keeps the first vault that resolved. + it('retries until a vault resolves, and starts the session at the first call it serves', async () => { + // One attempt at startup (warned), then one per tool call. const outcomes: Array = [ new Error('not at startup'), new Error('not yet'), { kind: 'local', root: beta }, + { kind: 'local', root: beta }, { kind: 'local', root: alpha } ] let calls = 0 @@ -366,10 +384,70 @@ describe('runMcpServer follows the target it is given (#831)', () => { }) expect(s.stderr.join('')).toContain('not at startup') expect((await s.vaultInfo()).error).toBe('Error: not yet') - expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta }) - // A further call must not move the session to alpha: the target is pinned. - expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta }) - expect(calls).toBe(3) + // Nothing was served before, so beta is no switch. + const first = (await s.vaultInfo()).info + expect(first).toMatchObject({ vaultRoot: beta }) + expect(String(first?.notes)).not.toContain('The vault changed') + expect((await s.call('list_notes')).isError).toBe(false) + // The app moved to alpha: the next call stops instead of running there. + const moved = await s.call('list_notes') + expect(moved.isError).toBe(true) + expect(moved.text).toContain(`it was the local vault ${beta} and is now the local vault ${alpha}`) + expect(calls).toBe(5) + await s.close() + }) + + it('a vault the app left before the first call is no switch', async () => { + const s = await session() + await writeConfig({ vaultRoot: beta, localVaults: [{ root: beta, name: 'beta' }] }) + const listed = await s.call('list_notes') + expect(listed.isError).toBe(false) await s.close() }) + + it('follows the app to another vault only after vault_info confirms it', async () => { + await fsp.writeFile(path.join(alpha, 'inbox', 'Alpha.md'), '# Alpha\n') + await fsp.writeFile(path.join(beta, 'inbox', 'Beta.md'), '# Beta\n') + try { + const s = await session() + const before = await s.call('list_notes') + expect(before.isError).toBe(false) + expect(before.text).toContain('inbox/Alpha.md') + + await writeConfig({ vaultRoot: beta, localVaults: [{ root: beta, name: 'beta' }] }) + // A write planned against alpha must not land in beta, and a whole + // batch stops, not only its first call. + const batch = await Promise.all([ + s.call('write_note', { path: 'inbox/Alpha.md', content: 'planned for alpha' }), + s.call('list_notes'), + s.call('read_note', { path: 'inbox/Alpha.md' }) + ]) + for (const result of batch) { + expect(result.isError).toBe(true) + expect(result.text).toContain('The ZenNotes vault changed') + expect(result.text).toContain(alpha) + expect(result.text).toContain(beta) + expect(result.text).toContain('vault_info') + } + await expect(fsp.access(path.join(beta, 'inbox', 'Alpha.md'))).rejects.toThrow() + expect(await fsp.readFile(path.join(alpha, 'inbox', 'Alpha.md'), 'utf8')).toBe('# Alpha\n') + + const { info } = await s.vaultInfo() + expect(info).toMatchObject({ kind: 'local', vaultRoot: beta }) + expect( + String(info?.notes).startsWith( + `The vault changed: until this call the session worked in the local vault ${alpha}.` + ) + ).toBe(true) + const after = await s.call('list_notes') + expect(after.isError).toBe(false) + expect(after.text).toContain('inbox/Beta.md') + expect(after.text).not.toContain('Alpha.md') + expect(String((await s.vaultInfo()).info?.notes)).not.toContain('The vault changed') + await s.close() + } finally { + await fsp.rm(path.join(alpha, 'inbox', 'Alpha.md'), { force: true }) + await fsp.rm(path.join(beta, 'inbox', 'Beta.md'), { force: true }) + } + }) }) diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 8f227dce..3fed6e0f 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -20,14 +20,24 @@ import { import { resolveInstructions } from './instructions-store.js' import { createBackend, type VaultBackend } from '../cli/backend.js' -import { resolveDefaultTarget, type VaultTarget } from '../cli/vault-target.js' +import { resolveDefaultTarget, sameVault, type VaultTarget } from '../cli/vault-target.js' import { RemoteRequestError } from '../main/remote/connection.js' import type { NoteFolder } from './vault-ops.js' import { addComment, listCommentThreads, replyToComment, resolveComment } from './comment-ops.js' +interface ToolContext { + /** Set on the vault_info call that moved the session to a new vault: the + * vault it left. */ + previous?: VaultTarget | null +} + interface ToolDef { schema: Tool - handler: (args: Record, backend: VaultBackend) => Promise + handler: ( + args: Record, + backend: VaultBackend, + context?: ToolContext + ) => Promise } /* ---------- Comment authorship ---------------------------------------- */ @@ -122,10 +132,13 @@ const TOOLS: ToolDef[] = [ 'Return where the currently configured ZenNotes vault lives (a folder on this machine, or a self-hosted server the desktop app is connected to) and its top-level layout. Call this once at the start of a session to confirm you are pointing at the right vault, and to learn whether the user runs in `inbox` or `root` primary mode (the answer changes how every other tool behaves).', inputSchema: { type: 'object', properties: {} } }, - handler: async (_args, backend) => { + handler: async (_args, backend, context) => { const [description, folders] = await Promise.all([backend.describe(), backend.listFolders()]) const primaryNotesLocation = description.primaryNotesLocation const isRootMode = primaryNotesLocation === 'root' + const switchNote = context?.previous + ? `The vault changed: until this call the session worked in ${describeTarget(context.previous)}. Every tool runs here now, and paths or content you got from the previous vault do not apply to this one. ` + : '' const pathNotes = 'IMPORTANT: Always use the `path` returned by other tools verbatim. Never prepend `inbox/` to a path you got back from list_notes / create_note / read_note. ' + (isRootMode @@ -146,6 +159,7 @@ const TOOLS: ToolDef[] = [ subfolders: folders, authConfigured: description.authConfigured, notes: + switchNote + 'This vault lives on a self-hosted ZenNotes server, the workspace the desktop app currently has open. Every tool works on it through the server API; paths are vault-relative POSIX paths exactly as the server reports them. ' + pathNotes + (description.authConfigured @@ -164,7 +178,7 @@ const TOOLS: ToolDef[] = [ inboxAbsolutePath: isRootMode ? vault : `${vault}/inbox`, topFolders: ['inbox', 'quick', 'archive', 'trash'], subfolders: folders, - notes: pathNotes + notes: switchNote + pathNotes } } }, @@ -966,12 +980,96 @@ export function describeToolError(err: unknown): string { return message } +/** A vault named in a sentence. The app often names a saved server after its + * host already ("Server Vault (10.0.0.5:7878)"), so the name is quoted and + * the URL follows "at" rather than a second parenthesis. */ +function describeTarget(target: VaultTarget): string { + if (target.kind === 'local') return `the local vault ${target.root}` + return target.name + ? `the server "${target.name}" at ${target.baseUrl}` + : `the server at ${target.baseUrl}` +} + +function sameTarget(a: VaultTarget, b: VaultTarget): boolean { + if (a.kind === 'local' || b.kind === 'local') { + return a.kind === 'local' && b.kind === 'local' && a.root === b.root + } + return a.baseUrl === b.baseUrl && a.name === b.name && a.authToken === b.authToken +} + +/** The vault changed after the session last served a call. */ +export class VaultSwitchedError extends Error { + constructor( + readonly from: VaultTarget, + readonly to: VaultTarget + ) { + super( + `The ZenNotes vault changed since this session's last call: it was ${describeTarget(from)} ` + + `and is now ${describeTarget(to)}. Nothing ran. Call vault_info to confirm the new vault and ` + + 'tell the user, then re-read anything you meant to change before you retry.' + ) + } +} + +/** + * The vault the tools run against, resolved again before every call so the + * server follows the desktop app the way a fresh `zn` does. Pinning the first + * vault for the life of the process left an agent working on a server after + * the app had moved back to a local vault, until the client restarted the + * server. + * + * A switch still never redirects work silently. An agent that read notes in + * one vault and writes after the user switched would change a note at the + * same path in the other one, so once the vault changes, every call except + * vault_info fails with VaultSwitchedError until vault_info confirms the new + * vault. That holds for a whole batch of parallel calls, not only the first. + * The Go `zn mcp` (ZenNotes/tui, internal/mcp/session.go) behaves the same. + */ +export class VaultSession { + private target: VaultTarget | null = null + private backend: VaultBackend | null = null + private queue: Promise = Promise.resolve() + + constructor( + private readonly resolve: () => Promise, + private readonly open: (target: VaultTarget) => VaultBackend = createBackend + ) {} + + /** The backend for one call. `confirm` marks vault_info, the only call that + * moves the session to a new vault; `previous` is the vault it moved away + * from, null when the vault did not change. Calls resolve one at a time so + * a batch sees one consistent session. */ + backendFor(confirm: boolean): Promise<{ backend: VaultBackend; previous: VaultTarget | null }> { + const next = this.queue.then(() => this.step(confirm)) + this.queue = next.catch(() => undefined) + return next + } + + private async step( + confirm: boolean + ): Promise<{ backend: VaultBackend; previous: VaultTarget | null }> { + const target = await this.resolve() + let previous: VaultTarget | null = null + if (this.target && this.backend && !(await sameVault(this.target, target))) { + if (!confirm) throw new VaultSwitchedError(this.target, target) + previous = this.target + } + // A new token or profile name for the same vault reopens quietly. + if (!this.target || !this.backend || !sameTarget(this.target, target)) { + this.backend = this.open(target) + this.target = target + } + return { backend: this.backend, previous } + } +} + export interface McpServerOptions { /** - * Which vault the tools run against. `zn mcp` passes the target its - * `--vault` / `--server` / `--token` flags name (#831); the legacy stdio - * entry has no flags and follows the environment, then the workspace the - * desktop app has open (#688), which is also the default here. + * Which vault the tools run against, asked again before every tool call + * (see VaultSession). `zn mcp` passes the target its `--vault` / + * `--server` / `--token` flags name (#831); the legacy stdio entry has no + * flags and follows the environment, then the workspace the desktop app + * has open (#688), which is also the default here. */ resolveTarget?: () => Promise /** Defaults to stdio, the only transport the clients speak. Tests bind an @@ -981,28 +1079,18 @@ export interface McpServerOptions { export async function runMcpServer(options: McpServerOptions = {}): Promise { const resolveTarget = options.resolveTarget ?? (() => resolveDefaultTarget()) - - // Resolve the vault once and keep it for the session. When nothing resolves - // yet we still boot so the client surface stays consistent; every tool call - // then reports the error, and the next call tries again rather than - // repeating a stale failure. - let backendPromise: Promise | null = null - const getBackend = (): Promise => { - if (!backendPromise) { - backendPromise = resolveTarget().then(createBackend) - backendPromise.catch(() => { - backendPromise = null - }) - } - return backendPromise - } + // When nothing resolves yet we still boot so the client surface stays + // consistent; every tool call then reports the error, and the next call + // tries again rather than repeating a stale failure. + const vaults = new VaultSession(resolveTarget) // Try at startup and say so on stderr when it fails: a terminal shows it at // once and MCP clients keep it in their server logs, whereas waiting for the // first tool call hid a `--vault` typo in a client config until the // assistant tripped over it (#831). stdout is the protocol channel and - // stays clean. - await getBackend().catch((err: unknown) => { + // stays clean. The session starts at the first call it serves, so a vault + // the app left before that call never counts as a switch. + await resolveTarget().catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) process.stderr.write( `[zennotes-mcp] ${message} The MCP server is running anyway; every tool call returns this error until a vault resolves.\n` @@ -1036,8 +1124,10 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise, backend) + const { backend, previous } = await vaults.backendFor(name === 'vault_info') + const result = await tool.handler((args ?? {}) as Record, backend, { + previous + }) const payload = typeof result === 'string' ? result : JSON.stringify(result, null, 2) return { content: [{ type: 'text', text: payload }] } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 5d55e876..a6eb6eed 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -442,7 +442,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The CLI is the bridge to launchers', body: - 'The `zn` command-line tool can list, read, search, capture, edit, archive, trash, inspect tasks, work with databases (`zn base list / rows / get / add / set / create` — add rows with record pages, set fields with the grid’s exact semantics, locally or against a self-hosted server; renaming a record page with `zn rename` keeps its row pointing at it), and start the MCP server without the app running. With no `--vault` or `--server` flag, `zn` works on the vault the app has open, a connected server included (give it the token as `ZENNOTES_REMOTE_TOKEN` or `--token`). Raycast uses it for search, then uses `zennotes://open` and `zennotes://open-window` links to bring the selected note back into ZenNotes. On macOS, Settings → CLI can install the bundled Raycast extension locally so users do not need to wait for the Raycast Store version.' + 'The `zn` command-line tool can list, read, search, capture, edit, archive, trash, inspect tasks, work with databases (`zn base list / rows / get / add / set / create` — add rows with record pages, set fields with the grid’s exact semantics, locally or against a self-hosted server; renaming a record page with `zn rename` keeps its row pointing at it), and start the MCP server without the app running. With no `--vault` or `--server` flag, `zn` works on the vault the app has open, a connected server included (it sends the token `zn connect` saved for that server, or `ZENNOTES_REMOTE_TOKEN` / `--token`). Raycast uses it for search, then uses `zennotes://open` and `zennotes://open-window` links to bring the selected note back into ZenNotes. On macOS, Settings → CLI can install the bundled Raycast extension locally so users do not need to wait for the Raycast Store version.' }, { title: 'Excalidraw drawings are first-class files', @@ -1273,6 +1273,6 @@ export const HELP_CLI: HelpCard[] = [ { title: 'MCP for AI agents', body: - '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. Once `zn` is installed, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. A server that requires a token needs it in the MCP client\'s environment as `ZENNOTES_REMOTE_TOKEN` (the app keeps its own copy in the OS secret store, which `zn` cannot read). To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token ` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' + '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. Once `zn` is installed, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. Switch vaults in the app and the server follows: the assistant\'s next tool call is refused with a note naming the old and the new vault, and once `vault_info` confirms the switch it carries on in the new one, so nothing planned in one vault lands in the other. A server that requires a token needs one the MCP server can read, because the app keeps its own copy in the OS secret store: with `zn` installed, run `zn connect --no-default` once in a terminal (it saves the token for `zn` and `zn mcp` without changing which vault they follow), or set `ZENNOTES_REMOTE_TOKEN` in the MCP client\'s environment. To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token ` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' } ] From 4d56f22d7f61db44b3b6fd12dbe9f044abe1d49f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 08:49:22 -0500 Subject: [PATCH 07/20] Fix(split): the editor and the preview stay on the same content, whichever one you scroll (#859) Split mode kept its panes in step with two different mappings. The preview followed the editor by source line, but the editor followed the preview by raw scroll ratio, and in a note full of KaTeX and callouts the rendered preview is about twice as tall as its source, so the two disagreed by whole subsections. Worse, the editor's lag came back: every time CodeMirror re-measured the live-preview widgets the sync had just scrolled into view, its scroll and geometry updates snapped the preview to the editor's lagging line, and a one-shot "ignore the next event" flag caught only the first of them. Driving the app over CDP with a lecture-style note: smooth scrolling the preview yanked it backwards 21 times (up to 1569px) and the reader never got past a fifth of the note; after reading ahead in the preview, one click on an editor line threw the preview back 1211px (@ShowhyT's "click/selection jump"). At the top, the preview started 16px down, which pinned the H1 against the pane's top edge. Both directions now map through one list of anchors (lib/split-scroll-sync): the document start, every top-level block where it begins in each pane (blocks folded into one editor widget share a position; the first stands for all), and both panes' ends. Between two anchors a position maps by the fraction of the way it is from one to the next, so a formula that renders ten times taller than its source stretches only its own stretch, and preview-to-editor is the exact inverse of editor-to-preview. The top of one pane is the top of the other and the end is the end. The pane the reader drives now leads: wheel, touch and keys claim it before its scroll events arrive, and while it leads the other pane's scroll events are echoes, however many CodeMirror fires. An editor re-measure settles the editor onto the preview while the preview leads, and leaves the preview alone when the preview was the pane last driven, so clicking or typing in the editor no longer moves what the reader was looking at. Only the article's own blocks are anchors, so an embed stamped with another note's lines cannot misplace one. scrollTopForScrollRatio goes with the ratio sync. Not done: anchors stay at top-level blocks, so inside one tall callout the mapping is linear. The report also mentions the note title drawn over the H1, which never reproduced here and is not in the video; the top-edge pin above is the part that was. Same harness after the fix: no backward jumps in either direction, the click moves nothing, both panes start at 0 and end together. Tests cover the mapping (round trip, folded blocks, both ends, no anchors) and the echo window. --- .../app-core/src/components/EditorPane.tsx | 189 +++++++++++------- .../src/lib/preview-outline-jump.test.ts | 8 - .../app-core/src/lib/preview-outline-jump.ts | 15 -- .../src/lib/split-scroll-sync.test.ts | 79 ++++++++ .../app-core/src/lib/split-scroll-sync.ts | 98 +++++++++ 5 files changed, 298 insertions(+), 91 deletions(-) create mode 100644 packages/app-core/src/lib/split-scroll-sync.test.ts create mode 100644 packages/app-core/src/lib/split-scroll-sync.ts diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 2ed3b5cf..c3c2f7c2 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -222,10 +222,18 @@ import { previewShowsSourceLine, previewVisibleSourceLines, scrollTopForElementRelativeTop, - scrollTopForScrollRatio, shouldSyncPreviewFromEditorViewport, type PreviewEditRequest } from '../lib/preview-outline-jump' +import { + mapSplitScrollTop, + SPLIT_SCROLL_LEAD_MS, + splitScrollAnchors, + splitScrollEchoes, + type SplitScrollAnchor, + type SplitScrollLead, + type SplitScrollPane +} from '../lib/split-scroll-sync' import { ArchiveIcon, ArrowLeftIcon, @@ -624,6 +632,32 @@ function landEditorOnLine(view: EditorView, line: number, topMargin: number): vo }) } +// Where each top-level block of the note starts, as a scroll position in the +// editor and in the split preview. Only the article's own children count: a +// nested element stamped with some other note's lines (an embed) would put an +// anchor in the wrong place. +function splitScrollAnchorsFor(view: EditorView, previewEl: HTMLElement): SplitScrollAnchor[] { + const editorEl = view.scrollDOM + const doc = view.state.doc + // The document starts below the scroller's top padding. + const editorOffset = view.documentTop - editorEl.getBoundingClientRect().top + editorEl.scrollTop + const previewOffset = previewEl.scrollTop - previewEl.getBoundingClientRect().top + const blocks: SplitScrollAnchor[] = [] + for (const el of previewEl.querySelectorAll('[data-preview-content] > [data-source-line]')) { + const line = Number(el.dataset.sourceLine) + if (!Number.isInteger(line) || line < 1 || line > doc.lines) continue + blocks.push({ + editor: view.lineBlockAt(doc.line(line).from).top + editorOffset, + preview: el.getBoundingClientRect().top + previewOffset + }) + } + return splitScrollAnchors( + blocks, + editorEl.scrollHeight - editorEl.clientHeight, + previewEl.scrollHeight - previewEl.clientHeight + ) +} + const EMPTY_COMMENTS: NoteComment[] = [] const taskJumpHighlightEffect = StateEffect.define() const taskJumpHighlightDecoration = Decoration.line({ class: 'cm-task-jump-highlight' }) @@ -1099,8 +1133,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // The outgoing note's history is set aside first and handed back when that // note returns, see lib/note-undo-history. (#793) const historyCompartmentRef = useRef(null) - const ignoreEditorScrollRef = useRef(false) const ignorePreviewScrollRef = useRef(false) + // Split mode: which pane the reader is driving and until when, and which + // one they drove last. The follower's scroll events are echoes while the + // other pane leads, and an editor re-measure only moves the preview when + // the editor was the pane last driven. (#859) + const splitScrollLeadRef = useRef(null) + const splitScrollDriverRef = useRef('editor') const pendingOutlineJumpLineRef = useRef(null) const pendingPreviewOutlineJumpLineRef = useRef(null) const outlinePreviewJumpFrameRef = useRef(null) @@ -1110,6 +1149,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const hasContentRef = useRef(false) const editorViewportSyncFrameRef = useRef(null) const syncPreviewToEditorScrollRef = useRef<() => boolean>(() => false) + const syncEditorToPreviewScrollRef = useRef<() => void>(() => {}) const activeOutlineLineRef = useRef(null) const activeOutlineFrameRef = useRef(null) const selectionActionFrameRef = useRef(null) @@ -1438,59 +1478,43 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return true }, [content?.body, lockOutlinePreviewSync, mode]) + // The pane the reader drives leads for a moment; `byReader` also makes it + // the pane they drove last. Our own sync takes the lead for the pane it + // copies from, so the follower's echo is recognized as one. + const leadSplitScroll = useCallback((pane: SplitScrollPane, byReader: boolean): void => { + splitScrollLeadRef.current = { pane, until: performance.now() + SPLIT_SCROLL_LEAD_MS } + if (byReader) splitScrollDriverRef.current = pane + }, []) + + // Both directions map through the same anchors (see split-scroll-sync), so + // the same content sits at the top of both panes however differently a + // block renders, and a round trip lands where it started. With no anchored + // block the mapping is the plain scroll ratio. const syncPreviewToEditorScroll = useCallback((): boolean => { const view = viewRef.current const editorEl = view?.scrollDOM const previewEl = previewScrollRef.current if (!view || !editorEl || !previewEl) return false - - // Line-based sync: map the source line at the top of the editor viewport to - // the rendered preview block stamped with that line (data-source-line), so - // the same content sits at the top of both panes even when their heights - // differ. Falls back to a scroll ratio when no line data is available. - const ratioTop = (): number => - scrollTopForScrollRatio( - editorEl.scrollTop, - editorEl.scrollHeight, - editorEl.clientHeight, - previewEl.scrollHeight, - previewEl.clientHeight - ) - - const blocks = previewEl.querySelectorAll('[data-source-line]') - let nextTop: number - if (blocks.length === 0) { - nextTop = ratioTop() - } else { - const topLine = view.state.doc.lineAt(view.lineBlockAtHeight(editorEl.scrollTop).from).number - let anchor: HTMLElement | null = null - let anchorLine = 0 - for (const el of blocks) { - const ln = Number(el.dataset.sourceLine) - if (Number.isFinite(ln) && ln <= topLine) { - anchor = el - anchorLine = ln - } else if (ln > topLine) { - break - } - } - if (!anchor) { - nextTop = 0 - } else { - // Shift the anchor by how far the editor has scrolled past its start - // line, so a mid-block scroll position carries over too. - const anchorEditorTop = view.lineBlockAt(view.state.doc.line(anchorLine).from).top - nextTop = scrollTopForElementRelativeTop(previewEl, anchor, anchorEditorTop - editorEl.scrollTop) - } - } - + const nextTop = mapSplitScrollTop(splitScrollAnchorsFor(view, previewEl), 'editor', editorEl.scrollTop) if (Math.abs(previewEl.scrollTop - nextTop) < 1) return true - ignorePreviewScrollRef.current = true + leadSplitScroll('editor', false) previewEl.scrollTop = nextTop return true - }, []) + }, [leadSplitScroll]) syncPreviewToEditorScrollRef.current = syncPreviewToEditorScroll + const syncEditorToPreviewScroll = useCallback((): void => { + const view = viewRef.current + const editorEl = view?.scrollDOM + const previewEl = previewScrollRef.current + if (!view || !editorEl || !previewEl) return + const nextTop = mapSplitScrollTop(splitScrollAnchorsFor(view, previewEl), 'preview', previewEl.scrollTop) + if (Math.abs(editorEl.scrollTop - nextTop) < 1) return + leadSplitScroll('preview', false) + editorEl.scrollTop = nextTop + }, [leadSplitScroll]) + syncEditorToPreviewScrollRef.current = syncEditorToPreviewScroll + const canSyncPreviewFromEditorViewport = useCallback((): boolean => { return shouldSyncPreviewFromEditorViewport( modeRef.current, @@ -1506,6 +1530,16 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { editorViewportSyncFrameRef.current = requestAnimationFrame(() => { editorViewportSyncFrameRef.current = null if (!canSyncPreviewFromEditorViewport()) return + // The reader is scrolling the preview: the editor measured the lines the + // sync just revealed, so it is the editor that settles onto the preview. + if (splitScrollEchoes(splitScrollLeadRef.current, 'editor', performance.now())) { + syncEditorToPreviewScrollRef.current() + return + } + // The reader last drove the preview. A click in the editor re-measures + // the line it reveals, and that must not drag the preview away from what + // they were reading. (#859) + if (splitScrollDriverRef.current === 'preview') return syncPreviewToEditorScrollRef.current() }) }, [canSyncPreviewFromEditorViewport]) @@ -1590,6 +1624,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return } if (!canSyncPreviewFromEditorViewport()) return + // A re-render while the reader drives the preview keeps their place. + if ( + splitScrollDriverRef.current === 'preview' || + splitScrollEchoes(splitScrollLeadRef.current, 'editor', performance.now()) + ) { + return + } syncPreviewToEditorScroll() }, [ canSyncPreviewFromEditorViewport, @@ -2682,28 +2723,15 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const previewEl = previewScrollRef.current if (!editorEl || !previewEl) return - const syncByRatio = ( - source: HTMLElement, - target: HTMLElement, - targetKind: 'editor' | 'preview' - ): void => { - const nextTop = scrollTopForScrollRatio( - source.scrollTop, - source.scrollHeight, - source.clientHeight, - target.scrollHeight, - target.clientHeight - ) - if (Math.abs(target.scrollTop - nextTop) < 1) return - if (targetKind === 'editor') ignoreEditorScrollRef.current = true - else ignorePreviewScrollRef.current = true - target.scrollTop = nextTop - } + splitScrollLeadRef.current = null + splitScrollDriverRef.current = 'editor' + // A scroll in the pane that does not lead is the echo of our own sync, or + // of the editor settling onto it, and goes no further. Any other scroll is + // the reader's (wheel, scrollbar, keys, the caret scrolled into view): that + // pane leads and the other follows it. const onEditorScroll = (): void => { - if (ignoreEditorScrollRef.current) { - ignoreEditorScrollRef.current = false - return - } + if (splitScrollEchoes(splitScrollLeadRef.current, 'editor', performance.now())) return + leadSplitScroll('editor', true) if (!canSyncPreviewFromEditorViewport()) return syncPreviewToEditorScroll() } @@ -2712,11 +2740,28 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ignorePreviewScrollRef.current = false return } + if (splitScrollEchoes(splitScrollLeadRef.current, 'preview', performance.now())) return + leadSplitScroll('preview', true) if (!canSyncPreviewFromEditorViewport()) return - syncByRatio(previewEl, editorEl, 'editor') + syncEditorToPreviewScroll() + } + // Input that scrolls a pane claims it before the scroll events arrive, so + // an echo landing in the same frame is already known for one. Typing makes + // the editor the pane last driven without taking the lead; a click does + // neither. + const leadEditor = (): void => leadSplitScroll('editor', true) + const leadPreview = (): void => leadSplitScroll('preview', true) + const typedInEditor = (): void => { + splitScrollDriverRef.current = 'editor' } editorEl.addEventListener('scroll', onEditorScroll, { passive: true }) previewEl.addEventListener('scroll', onPreviewScroll, { passive: true }) + editorEl.addEventListener('wheel', leadEditor, { passive: true }) + previewEl.addEventListener('wheel', leadPreview, { passive: true }) + editorEl.addEventListener('touchstart', leadEditor, { passive: true }) + previewEl.addEventListener('touchstart', leadPreview, { passive: true }) + editorEl.addEventListener('keydown', typedInEditor) + previewEl.addEventListener('keydown', leadPreview) const raf = requestAnimationFrame(() => { if (!canSyncPreviewFromEditorViewport()) return syncPreviewToEditorScroll() @@ -2725,8 +2770,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { cancelAnimationFrame(raf) editorEl.removeEventListener('scroll', onEditorScroll) previewEl.removeEventListener('scroll', onPreviewScroll) - ignoreEditorScrollRef.current = false + editorEl.removeEventListener('wheel', leadEditor) + previewEl.removeEventListener('wheel', leadPreview) + editorEl.removeEventListener('touchstart', leadEditor) + previewEl.removeEventListener('touchstart', leadPreview) + editorEl.removeEventListener('keydown', typedInEditor) + previewEl.removeEventListener('keydown', leadPreview) ignorePreviewScrollRef.current = false + splitScrollLeadRef.current = null outlinePreviewSyncLockUntilRef.current = 0 } }, [ @@ -2734,7 +2785,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { content?.path, editorHydration?.path, editorHydration?.ready, + leadSplitScroll, mode, + syncEditorToPreviewScroll, syncPreviewToEditorScroll ]) diff --git a/packages/app-core/src/lib/preview-outline-jump.test.ts b/packages/app-core/src/lib/preview-outline-jump.test.ts index 6d3a5724..9e7cac25 100644 --- a/packages/app-core/src/lib/preview-outline-jump.test.ts +++ b/packages/app-core/src/lib/preview-outline-jump.test.ts @@ -14,7 +14,6 @@ import { previewShowsSourceLine, previewVisibleSourceLines, scrollTopForElementRelativeTop, - scrollTopForScrollRatio, shouldSyncPreviewAfterMarkdownSettles, shouldSyncPreviewFromEditorViewport } from './preview-outline-jump' @@ -78,13 +77,6 @@ describe('preview outline jump helpers', () => { expect(previewScrollTopForHeading(preview, heading, 24)).toBe(700) }) - it('maps continuous scroll by ratio for smooth split-pane sync', () => { - expect(scrollTopForScrollRatio(250, 1000, 500, 2000, 1000)).toBe(500) - expect(scrollTopForScrollRatio(1200, 1000, 500, 2000, 1000)).toBe(1000) - expect(scrollTopForScrollRatio(250, 500, 500, 2000, 1000)).toBe(0) - expect(scrollTopForScrollRatio(250, 1000, 500, 1000, 1000)).toBe(0) - }) - it('extends the outline preview sync lock without shortening an active lock', () => { expect(nextOutlinePreviewSyncLockUntil(100, 450, 0)).toBe(550) expect(nextOutlinePreviewSyncLockUntil(125, 100, 550)).toBe(550) diff --git a/packages/app-core/src/lib/preview-outline-jump.ts b/packages/app-core/src/lib/preview-outline-jump.ts index e0077637..63235237 100644 --- a/packages/app-core/src/lib/preview-outline-jump.ts +++ b/packages/app-core/src/lib/preview-outline-jump.ts @@ -218,21 +218,6 @@ export function scrollTopForElementRelativeTop( return Math.max(0, Math.min(maxTop, nextTop)) } -export function scrollTopForScrollRatio( - sourceScrollTop: number, - sourceScrollHeight: number, - sourceClientHeight: number, - targetScrollHeight: number, - targetClientHeight: number -): number { - const sourceMax = sourceScrollHeight - sourceClientHeight - const targetMax = targetScrollHeight - targetClientHeight - if (targetMax <= 0) return 0 - if (sourceMax <= 0) return 0 - const ratio = Math.max(0, Math.min(1, sourceScrollTop / sourceMax)) - return ratio * targetMax -} - export function nextOutlinePreviewSyncLockUntil( nowMs: number, durationMs: number, diff --git a/packages/app-core/src/lib/split-scroll-sync.test.ts b/packages/app-core/src/lib/split-scroll-sync.test.ts new file mode 100644 index 00000000..8a30a780 --- /dev/null +++ b/packages/app-core/src/lib/split-scroll-sync.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { mapSplitScrollTop, splitScrollAnchors, splitScrollEchoes } from './split-scroll-sync' + +// Blocks as [editorTop, previewTop] pairs, in document order. +function geometry(blocks: Array<[number, number]>, editorMax: number, previewMax: number) { + return splitScrollAnchors( + blocks.map(([editor, preview]) => ({ editor, preview })), + editorMax, + previewMax + ) +} + +describe('mapSplitScrollTop (#859)', () => { + it('falls back to the scroll ratio when no block is anchored', () => { + const g = geometry([], 1000, 3000) + expect(mapSplitScrollTop(g, 'editor', 250)).toBe(750) + expect(mapSplitScrollTop(g, 'preview', 750)).toBe(250) + }) + + it('keeps the top of one pane at the top of the other, padding included', () => { + // The H1 starts 16px down in the preview: the old sync scrolled that + // padding away and pinned the heading against the pane's top edge. + const g = geometry([[0, 16], [120, 300]], 2000, 6000) + expect(mapSplitScrollTop(g, 'editor', 0)).toBe(0) + expect(mapSplitScrollTop(g, 'preview', 0)).toBe(0) + }) + + it('lands a block on its counterpart and interpolates inside it', () => { + // A callout 100px tall in the editor renders 1000px tall in the preview. + const g = geometry([[0, 0], [400, 900], [500, 1900], [800, 2200]], 3000, 9000) + expect(mapSplitScrollTop(g, 'editor', 400)).toBe(900) + expect(mapSplitScrollTop(g, 'editor', 450)).toBe(1400) + expect(mapSplitScrollTop(g, 'preview', 1400)).toBe(450) + expect(mapSplitScrollTop(g, 'preview', 2050)).toBe(650) + }) + + it('returns where it started after a round trip, anywhere in the note', () => { + const g = geometry([[0, 16], [90, 400], [300, 420], [310, 1700], [900, 2000], [1500, 5200]], 2400, 7000) + for (let top = 0; top <= 2400; top += 37) { + const there = mapSplitScrollTop(g, 'editor', top) + expect(mapSplitScrollTop(g, 'preview', there)).toBeCloseTo(top, 6) + } + }) + + it('skips blocks the editor folds into one widget', () => { + // Three blocks inside one rendered callout widget share its top in the + // editor; the segment runs to the first block past it in both panes. + const g = geometry([[100, 200], [100, 500], [100, 800], [300, 1200]], 1000, 4000) + expect(mapSplitScrollTop(g, 'editor', 200)).toBe(700) + expect(mapSplitScrollTop(g, 'preview', 700)).toBe(200) + }) + + it('maps the end of one pane to the end of the other', () => { + // The last blocks begin inside the final screenful, where neither pane's + // top can reach them. + const g = geometry([[0, 0], [700, 2000], [950, 2900], [990, 3100]], 900, 2800) + expect(mapSplitScrollTop(g, 'editor', 900)).toBe(2800) + expect(mapSplitScrollTop(g, 'preview', 2800)).toBe(900) + expect(mapSplitScrollTop(g, 'editor', 800)).toBe(2400) + }) + + it('clamps to the target pane and handles a pane that cannot scroll', () => { + const g = geometry([[0, 0], [100, 400]], 500, 2000) + expect(mapSplitScrollTop(g, 'editor', -50)).toBe(0) + expect(mapSplitScrollTop(g, 'editor', 9999)).toBe(2000) + expect(mapSplitScrollTop(geometry([[0, 0]], 0, 2000), 'editor', 0)).toBe(0) + expect(mapSplitScrollTop(geometry([[0, 0]], 500, 0), 'editor', 300)).toBe(0) + }) +}) + +describe('splitScrollEchoes (#859)', () => { + it('ignores a pane while the other one leads, and only then', () => { + const lead = { pane: 'preview' as const, until: 1000 } + expect(splitScrollEchoes(lead, 'editor', 999)).toBe(true) + expect(splitScrollEchoes(lead, 'preview', 999)).toBe(false) + expect(splitScrollEchoes(lead, 'editor', 1000)).toBe(false) + expect(splitScrollEchoes(null, 'editor', 0)).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/split-scroll-sync.ts b/packages/app-core/src/lib/split-scroll-sync.ts new file mode 100644 index 00000000..ab133240 --- /dev/null +++ b/packages/app-core/src/lib/split-scroll-sync.ts @@ -0,0 +1,98 @@ +/** + * Split mode keeps the editor and the reading view on the same content. Every + * top-level block of the rendered note carries the source line it starts on + * (`data-source-line`), which gives one pair of scroll positions per block: + * where that content begins in the editor and where it begins in the preview. + * Between two such anchors a scroll position maps by the fraction of the way + * it is from one to the next, so a formula or callout that renders ten times + * taller than its source stretches its own stretch of the other pane instead + * of shifting everything after it. The document's start and both panes' ends + * are anchors too: the top of one pane is the top of the other, and the end is + * the end. + * + * Both directions walk the same anchors, so preview-to-editor is the exact + * inverse of editor-to-preview. The preview used to follow the editor by line + * while the editor followed the preview by raw scroll ratio; with KaTeX and + * callouts the two disagreed by whole sections, and every round trip dragged + * the reader somewhere else. (#859) + */ +export type SplitScrollPane = 'editor' | 'preview' + +export interface SplitScrollAnchor { + editor: number + preview: number +} + +/** + * The anchors both directions share: the document start, then each block + * that begins further down in BOTH panes than the last one kept (blocks the + * editor folds into one widget, or that render with no height, share a + * position; the first of them stands for all), then the two panes' ends. + * Blocks that begin inside a pane's last screenful are unreachable as a + * scroll position and end the list there. + */ +export function splitScrollAnchors( + blocks: Iterable, + editorMax: number, + previewMax: number +): SplitScrollAnchor[] { + const anchors: SplitScrollAnchor[] = [{ editor: 0, preview: 0 }] + for (const block of blocks) { + if (block.editor > editorMax || block.preview > previewMax) break + const last = anchors[anchors.length - 1] + if (block.editor > last.editor && block.preview > last.preview) anchors.push(block) + } + const last = anchors[anchors.length - 1] + if (editorMax > last.editor && previewMax > last.preview) { + anchors.push({ editor: editorMax, preview: previewMax }) + } + return anchors +} + +/** The scroll position in the other pane that shows what `top` shows in `from`. */ +export function mapSplitScrollTop( + anchors: readonly SplitScrollAnchor[], + from: SplitScrollPane, + top: number +): number { + const to: SplitScrollPane = from === 'editor' ? 'preview' : 'editor' + const last = anchors.length - 1 + if (last < 1) return 0 + if (top <= anchors[0][from]) return anchors[0][to] + if (top >= anchors[last][from]) return anchors[last][to] + // The segment holding `top`: its start is the last anchor at or above it. + let lo = 0 + let hi = last + while (hi - lo > 1) { + const mid = (lo + hi) >> 1 + if (anchors[mid][from] <= top) lo = mid + else hi = mid + } + const start = anchors[lo] + const end = anchors[hi] + const fraction = (top - start[from]) / (end[from] - start[from]) + return start[to] + fraction * (end[to] - start[to]) +} + +/** How long the pane the user drives keeps the lead after its last scroll. */ +export const SPLIT_SCROLL_LEAD_MS = 250 + +export interface SplitScrollLead { + pane: SplitScrollPane + until: number +} + +/** + * True while the other pane leads: a scroll here is then the echo of our own + * sync (or the editor re-measuring the region the sync revealed), never input + * to send back. A one-shot "ignore the next event" flag missed the second echo + * CodeMirror fires while it measures live-preview widgets, and that echo + * snapped the preview back to where the editor had lagged. (#859) + */ +export function splitScrollEchoes( + lead: SplitScrollLead | null, + pane: SplitScrollPane, + now: number +): boolean { + return lead != null && lead.pane !== pane && now < lead.until +} From 08ec14a9781d0f44f8d67ae6307ae2f51f06c82d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 09:10:08 -0500 Subject: [PATCH 08/20] Chore(terminal): pin ZenNotes CLI 0.4.1 for the bundled zn The desktop's managed zn still pinned ZenNotes CLI 0.4.0, so a 2.57.0 build would have shipped a zn mcp that keeps the first vault it finds and answers 401 on the server the app is connected to, whatever token zn connect saved for it, while this version's manual already describes both fixes (5f8095b2). The pin now names 0.4.1, released today from ZenNotes/tui 86e2149 with the two fixes for that report (35a77be, 86e2149): the source commit, the four macOS and Linux archive URLs and their SHA-256 digests, each read from the release's asset digests and matched against its checksums.txt. Verified through `npm run terminal:stage`, the same download, checksum and binary inspection that `apps/desktop/build/after-pack.js` runs at packaging time, for all four platform pairs; the darwin-arm64 binary answers protocol 1, version 0.4.1. `test:terminal` 12 of 12. The released darwin-arm64 binary also ran the MCP repro against a token-protected server: with only a zn connect token it lists the notes, and it follows a vault switch once vault_info confirms it. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index a3ffbfaf..2b98d750 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.4.0", - "commit": "6481d171916f6d84de05a50a81c93ffef4273ff7", + "version": "0.4.1", + "commit": "86e2149b94fc27587a704fc02afd25e5a42c5cc2", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_darwin_arm64.tar.gz", - "sha256": "1991df8d6893c4bab37ceb0eb34d3aab9026c7d69d265f12212e7ec05af887c3" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_darwin_arm64.tar.gz", + "sha256": "3c83f847268c26279121537c807785dfab0403fa0cae248f149d09a11a2e2eda" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_darwin_amd64.tar.gz", - "sha256": "69861fbdfedca6710b0b44d9a39ab781ba3073bab6567cca5d92cedfed0c041f" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_darwin_amd64.tar.gz", + "sha256": "d541dcc3fbfd15d1f702a71ff4d629d1a1907a21d055c23df27934925e9f4557" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_linux_arm64.tar.gz", - "sha256": "3fda655425d9f41b7961e1a8f119bb9282712c7877a31d19b3ac1548a478a2dd" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_linux_arm64.tar.gz", + "sha256": "55d6999163b66fc0e29a3a35a449f37175c448bb7e6e22072ee1c970769fd739" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_linux_amd64.tar.gz", - "sha256": "03032ff80b14effcecb065533186e72416030b2b69b4c4d3fd67d8cadb73cede" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_linux_amd64.tar.gz", + "sha256": "42e5e64edde08f3145883ffcfeaf197d6290d966074ce0e28f054d254bb806c1" } } } From db8eca79188af4c1a60a06f9b7dc2f954d522055 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 09:27:26 -0500 Subject: [PATCH 09/20] Fix(editor): F3 brings the find bar back, and Mod+F follows its binding (#860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Vim mode off, the in-note find bar opened once. F3 and Mod-G run CodeMirror's findNext and findPrevious, which open the bar only while no query is set: after one search and Escape they moved the caret to the next match of the old query with the bar closed, the selection toolbar popping up over it, and nothing on the keyboard brought the bar back, in any note, until a restart. In the main window F3 was the only key into the bar with Vim off, since Mod+F opens note search there (reported by @Sullti, who named findNext as the cause). The same report found the editor's Mod-f hard-coded to note search: unbinding "Search notes in non-Vim mode" under Settings → Keymap did not free it, in the main editor or the pinned reference pane. F3, Mod-G and their Shift variants now open a closed bar, the last query prefilled and selected as Mod+F does, and step through the matches only while it is open. The change sits in vimAwareSearchKeymap, so every editor surface has it. Both editors' Mod-f now come from the configured `global.searchNotesNonVim` binding, so moving or unbinding it hands Mod+F to the find bar (what #376 asks for, by way of Settings). The manual's Mod+F row said the bar opens with Vim off, which was never true in the main window; it now says where Mod+F goes and how to change that, and a new row covers F3 and Mod+G. Not reproduced: the report's floating-window Ctrl+F, which reopened the bar on every press here, with Cmd+F on macOS and with Ctrl+F under Windows key handling (navigator.platform emulated as Win32). The fix covers any path that runs findNext. Verified over CDP with Vim mode off, both stores isolated: the build before this change passes 5 of 13 checks (F3 jumps from match to match with the bar closed in the main and a floating window, and Mod+F never reaches the bar with note search unbound); this one passes 15 of 15, with F3 and Shift+F3 still stepping between matches while the bar is open and Mod+F still opening note search by default. Five new tests run the bindings on a real EditorView; all five fail on the stock keymap. --- .../app-core/src/components/EditorPane.tsx | 19 +++--- .../src/components/PinnedReferencePane.tsx | 18 +++--- .../src/lib/cm-vim-default-keymap.test.ts | 64 +++++++++++++++++++ .../app-core/src/lib/cm-vim-default-keymap.ts | 38 +++++++++-- packages/app-core/src/lib/help.ts | 3 +- 5 files changed, 118 insertions(+), 24 deletions(-) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index c3c2f7c2..1344ec93 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -376,15 +376,16 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens // edge and misland on wrapped lines under fractional display scaling // (#591, the same resolution #575 removed from `$`). ...displayRowBoundaryKeymap, - { - key: 'Mod-f', - run: () => { - const state = useStore.getState() - if (state.vimMode) return false - state.setSearchOpen(true) - return true - } - }, + // Note search's non-Vim shortcut has to win over the find bar's Mod-f in + // here, but it is the user's binding: unbound or moved under Settings → + // Keymap, Mod+F falls through to the find bar instead of still opening + // note search. (#860) + ...keyBindingsFor(getKeymapBinding(overrides, 'global.searchNotesNonVim'), () => { + const state = useStore.getState() + if (state.vimMode) return false + state.setSearchOpen(true) + return true + }), // Move the current line (or selection) up/down — reorders the markdown so // it persists in the file. Listed before defaultKeymap so the configured // binding wins; works in Vim normal/insert and non-Vim alike. diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index ca62d8f9..06a883cf 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -29,6 +29,8 @@ import { import { vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' +import { getKeymapBinding } from '../lib/keymaps' +import { keyBindingsFor } from '../lib/vim-half-page-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { noteMarkdown } from '../lib/cm-markdown-language' import { customCodeFenceHighlightExtension } from '../lib/cm-custom-code-languages' @@ -266,15 +268,13 @@ export function PinnedReferencePane(): JSX.Element | null { completionNavKeymap, completionKeymapExtension, keymap.of([ - { - key: 'Mod-f', - run: () => { - const state = useStore.getState() - if (state.vimMode) return false - state.setSearchOpen(true) - return true - } - }, + // The user's note-search binding, as in the main editor (#860). + ...keyBindingsFor(getKeymapBinding(s0.keymapOverrides, 'global.searchNotesNonVim'), () => { + const state = useStore.getState() + if (state.vimMode) return false + state.setSearchOpen(true) + return true + }), indentWithTab, ...vimAwareDefaultKeymap(s0.vimMode), ...historyKeymap, diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts index 73f57b02..0ec1b09a 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts @@ -4,6 +4,15 @@ import { EditorSelection, EditorState } from '@codemirror/state' import { EditorView, keymap, type KeyBinding } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { + SearchQuery, + closeSearchPanel, + getSearchQuery, + openSearchPanel, + search, + searchPanelOpen, + setSearchQuery +} from '@codemirror/search' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from './cm-vim-default-keymap' // Regression guard for the macOS Vim `Ctrl-d` bug: defaultKeymap's emacs-style @@ -238,3 +247,58 @@ describe('vimAwareSearchKeymap', () => { }) }) +// #860: F3 and Mod-G ran findNext, which opens the find bar only while no +// query is set. After one search they jumped to the old query's next match +// with the bar closed, and the bar never came back to the keyboard. +describe('find next and previous reopen a closed find bar (#860)', () => { + const doc = 'alpha one\nbeta\nalpha two\nalpha three\n' + const binding = (key: string): KeyBinding => { + const found = vimAwareSearchKeymap(false, false).find((b) => b.key === key) + if (!found) throw new Error(`no ${key} binding`) + return found + } + let view: EditorView | null = null + afterEach(() => { + view?.destroy() + view = null + }) + // One search has run and the bar was closed again, the caret at the start. + const searchedThenClosed = (): EditorView => { + view = new EditorView({ state: EditorState.create({ doc, extensions: [search()] }), parent: document.body }) + openSearchPanel(view) + view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: 'alpha' })) }) + closeSearchPanel(view) + view.dispatch({ selection: EditorSelection.cursor(0) }) + return view + } + const selected = (v: EditorView): [number, number] => [v.state.selection.main.from, v.state.selection.main.to] + + for (const key of ['F3', 'Mod-g']) { + it(`${key} opens the closed bar with the last query and moves nothing`, () => { + const v = searchedThenClosed() + expect(searchPanelOpen(v.state)).toBe(false) + expect(binding(key).run?.(v)).toBe(true) + expect(searchPanelOpen(v.state)).toBe(true) + expect(getSearchQuery(v.state).search).toBe('alpha') + expect(selected(v)).toEqual([0, 0]) + }) + + it(`${key} steps through the matches once the bar is open, and Shift steps back`, () => { + const v = searchedThenClosed() + binding(key).run?.(v) + binding(key).run?.(v) + expect(selected(v)).toEqual([0, 5]) + binding(key).run?.(v) + expect(selected(v)).toEqual([15, 20]) + binding(key).shift?.(v) + expect(selected(v)).toEqual([0, 5]) + }) + } + + it('Shift on a closed bar opens it too, rather than stepping back out of sight', () => { + const v = searchedThenClosed() + expect(binding('F3').shift?.(v)).toBe(true) + expect(searchPanelOpen(v.state)).toBe(true) + expect(selected(v)).toEqual([0, 0]) + }) +}) diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.ts b/packages/app-core/src/lib/cm-vim-default-keymap.ts index 40bd1e9a..9745cd9f 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -1,8 +1,14 @@ import { defaultKeymap } from '@codemirror/commands' import { markdownKeymap } from '@codemirror/lang-markdown' import { Prec, type Extension } from '@codemirror/state' -import { keymap, type EditorView, type KeyBinding } from '@codemirror/view' -import { searchKeymap } from '@codemirror/search' +import { keymap, type Command, type EditorView, type KeyBinding } from '@codemirror/view' +import { + findNext, + findPrevious, + openSearchPanel, + searchKeymap, + searchPanelOpen +} from '@codemirror/search' import { getCM } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' import { insertNewlineContinueFrontmatterList } from './cm-frontmatter' @@ -164,6 +170,27 @@ export function vimAwareDefaultKeymap(vimMode: boolean): readonly KeyBinding[] { return vimMode ? vimModeKeymap : defaultKeymap } +/** + * F3 and Mod-G run CodeMirror's `findNext`/`findPrevious`, which open the find + * bar only while no query is set. Once one search had run and the bar was + * closed, they jumped to the next match of that old query out of sight (the + * selection toolbar popping up over it), and the bar never came back to the + * keyboard, in every note, until a restart. In the main window, where Mod+F + * opens note search with Vim off, F3 was the only key into the bar. (#860) + * With the bar closed they now open it, the last query prefilled and selected + * as Mod+F does; with it open they step through the matches as before. + */ +const reopenFirst = + (step: Command): Command => + (view) => + searchPanelOpen(view.state) ? step(view) : openSearchPanel(view) + +const reopeningSearchKeymap: readonly KeyBinding[] = searchKeymap.map((binding) => + binding.run === findNext + ? { ...binding, run: reopenFirst(findNext), shift: reopenFirst(findPrevious) } + : binding +) + /** * CodeMirror's `searchKeymap`, made Vim-aware where Mod is Ctrl. * @@ -173,11 +200,12 @@ export function vimAwareDefaultKeymap(vimMode: boolean): readonly KeyBinding[] { * Vim: the one motion chord the editor still took from Vim (#510). With Vim * mode on, Vim users search with `/`, so the binding is dropped there and * Ctrl+F pages like Ctrl+B. macOS keeps it: Cmd+F never collided with Vim. - * The rest of the keymap (find next, replace, select matches) stays as is. + * The rest of the keymap (replace, select matches) stays as is; find next and + * previous reopen a closed bar (above). */ export function vimAwareSearchKeymap(vimMode: boolean, mac: boolean = isMacPlatform()): readonly KeyBinding[] { - if (!vimMode || mac) return searchKeymap - return searchKeymap.filter((binding) => binding.key !== 'Mod-f') + if (!vimMode || mac) return reopeningSearchKeymap + return reopeningSearchKeymap.filter((binding) => binding.key !== 'Mod-f') } /** diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index a6eb6eed..c4b53cd8 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -501,7 +501,8 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Ctrl+D (in Search notes)', action: 'Move the highlighted note to Trash', detail: 'Trash a note straight from the search results, with the usual confirmation; the palette stays open, so a clean-up pass is search, Ctrl+D, search, Ctrl+D.' }, { keys: 'Shift+Enter (in Search notes)', action: 'Create a note named after your search', detail: 'The last row of the results offers to create the note you typed: Shift+Enter (or Enter on that row) opens a New note form with three fields. Name starts as your search text, selected so you can retype it. Folder starts empty (your Inbox) or as the path you typed, like projects/roadmap; landing in it lists every folder, typing narrows the list, Enter picks the highlighted one and ArrowUp keeps a folder that does not exist yet. Tags starts with any #tag words from your search; type to pick an existing tag or add a new one, Space or comma commits, Backspace on an empty field removes the last one. The line under the fields tells you what will happen. A note with the same name in that folder blocks Create until you change the name, and Shift+↵ opens the existing note instead; the same name elsewhere only warns. Enter in Name, or Ctrl/Cmd+Enter anywhere, creates the note and opens it; Escape goes back to the results.' }, { keys: 'Mod+F', action: 'Search notes (non-Vim mode)', detail: 'Open the note search palette directly when Vim mode is off.' }, - { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'With Vim mode on, Linux and Windows keep Ctrl+F as Vim\u2019s page-forward (search with / instead); on macOS and with Vim off the bar opens as usual. In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it.' }, + { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it. With Vim mode off, Mod+F in the main window opens note search instead (the row above); unbind or move Search notes in non-Vim mode under Settings \u2192 Keymap to give Mod+F to the bar, or use F3. With Vim mode on, Linux and Windows keep Ctrl+F as Vim\u2019s page-forward (search with / instead); macOS keeps Cmd+F for the bar. A floating window has no note search, so there Mod+F opens the bar with Vim mode off too.' }, + { keys: 'F3 / Mod+G (in the editor)', action: 'Find the next match in the note', detail: 'With the find bar closed, open it with your last search filled in and selected, ready to retype or to press F3 again; with it open, move to the next match. Shift+F3 or Shift+Mod+G moves to the previous one. The same with Vim mode on or off, in every window.' }, { keys: 'Shift+Mod+P', action: 'Open commands', detail: 'Open the command palette.' }, { keys: 'Mod+N', action: 'New note in current folder', detail: 'Create a note in the active note\u2019s folder (or the browsed folder when no note is open) and focus its title. On Linux and Windows this wins over Vim\u2019s Ctrl+N (cursor down). Rebindable under Settings \u2192 Keymaps.' }, { keys: 'Shift+Mod+N', action: 'New Quick Note', detail: 'Create a quick capture note in the main window and focus its title.' }, From 67a65b93d820e350838efc54dbd9a659e14ea526 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 10:08:03 -0500 Subject: [PATCH 10/20] Fix(atlas): notes added after the map was drawn land beside their links, not on them (#861) Atlas lays the vault out once, caches the positions per vault, and from then on only places newcomers, so the map never shuffles. A newcomer (a new note, or one renamed or moved) went to the centroid of the notes it links to, give or take 11 units a side, with no look at what was already there. For the most common newcomer, a note that links to one note, that centroid is the note itself: the dot landed on top of it, every later note linking there piled onto the same spot, and the cache kept the pile for good. The first map of a vault never had this, only notes written after Atlas was first opened, hence the "sometimes" in @vinivinoo's report, whose screenshot shows a pile on one hub and pairs on two smaller notes. A newcomer now takes the nearest spot around the same anchor (its links' centroid, or its region slot while none of them is on the map) that keeps 24 units between dots, edge to edge. On a map too crowded for that near its links, it takes the nearest spot where it touches no dot and keeps 12 from what it links to. New notes that link only to each other are placed breadth-first from the map, so a chain grows off the note it hangs from. Maps that earlier versions already piled up repair themselves on the first open: a cached note within 12 units of a note it links to moves to the nearest free spot, and of the two the note with more links (then the older one) stays. Nothing else moves, and the result settles to itself, so reopening the map moves nothing. The dot radius now lives in lib/atlas.ts (atlasNodeRadius), shared by the layout and the view. The design doc's placement rule says the same, and its banner, which still said nothing had shipped, now says what has. What it deliberately does not do: separate dots that the first layout of a very large vault already leaves overlapping (about 390 pairs in the map of a synthetic 5000-note vault). They predate this and come from the relaxation, not from placement, and repairing them would move about 7% of such a map at once. Dragging single notes by hand, the report's other half, is left out as well: in Atlas a drag moves the camera by design. Verified over CDP on a vault shaped like the report's (29 notes in 7 folders, then 8 notes linking to one hub and two leaves), both stores isolated. The build before this change piles them up: 20 overlapping pairs in the map and 11 in the sky, each newcomer 0.2 to 14 units from its link. This one places them 37 to 59 units from their links with at least 24 between dots, and opening the map 2.56 cached repairs it: 0 overlaps, exactly the 8 stacked notes moved, none of the other 29. The first map is byte-identical to the one 2.56 draws, and reopening moves nothing. Offline against the old layout, full layouts of 45 and 300 notes are unchanged, 1000 to 5000 notes move one to three notes, and the time is the same. Five new tests; the three placement tests fail on the old layout. --- docs/ideas/atlas.md | 27 +- .../app-core/src/components/AtlasView.tsx | 5 +- packages/app-core/src/lib/atlas.test.ts | 144 ++++++++- packages/app-core/src/lib/atlas.ts | 288 ++++++++++++++++-- 4 files changed, 420 insertions(+), 44 deletions(-) diff --git a/docs/ideas/atlas.md b/docs/ideas/atlas.md index 6d54fe3a..51c63b66 100644 --- a/docs/ideas/atlas.md +++ b/docs/ideas/atlas.md @@ -2,11 +2,18 @@ A map of the vault: every note, every connection, in one navigable place. -> **Status (August 2026).** Design document, nothing shipped. Written as an -> answer to the recurring request for "a graph view like Obsidian's", from a -> position of not wanting Obsidian's graph. An interactive prototype of the -> feel (fake vault, real interactions) sits next to this doc as -> `atlas-prototype.html`; open it in a browser and press `?`. +> **Status (September 2026).** Atlas v1 shipped in 2.30.0 (`Space g`): the +> map and the sky behind `v`, lenses 1 to 4, hint jump, filter, and regions +> taken from top-level folders, with positions cached per vault by the app +> rather than in `.zennotes/atlas.json`. Since 2.57.0 a note added after the +> map was drawn takes the nearest free spot beside what it links to (#861). +> The rest of this document (clustering and named regions, trace, replay, +> suggestions, the reflow command) is still design. +> +> Written in August 2026 as an answer to the recurring request for "a graph +> view like Obsidian's", from a position of not wanting Obsidian's graph. An +> interactive prototype of the feel (fake vault, real interactions) sits next +> to this doc as `atlas-prototype.html`; open it in a browser and press `?`. ## Problem Statement @@ -134,9 +141,13 @@ honest. - Placement: regions on a golden-angle spiral (big regions claim space first), notes within a region by phyllotaxis, refined by a short, seeded, damped relaxation. Then **frozen**. -- Incremental: a new note is placed at the weighted centroid of its neighbors - with deterministic jitter; an unconnected note parks at its folder-mates' - region edge until it earns links. Existing positions never shift. +- Incremental: a new note takes the nearest free spot around the notes it + links to (around its region's slot while none of them is on the map yet), + clear of every dot already there. Existing positions never shift, with one + exception: a note sitting on a note it links to moves to the nearest free + spot. That is what versions before 2.57.0 cached for notes written after the + first visit, when a newcomer landed on its neighbors' centroid with only + jitter to separate it (#861). - Persistence: `.zennotes/atlas.json` in the vault. Positions, region names, user renames. Small, derived, safe to delete (deleting = voluntary reflow). In-vault so desktop, web, and any future device share one geography. diff --git a/packages/app-core/src/components/AtlasView.tsx b/packages/app-core/src/components/AtlasView.tsx index 5aa4706b..ba4f0b7d 100644 --- a/packages/app-core/src/components/AtlasView.tsx +++ b/packages/app-core/src/components/AtlasView.tsx @@ -6,6 +6,7 @@ import { useStore, isAtlasViewActive } from '../store' import { applyExtraLinkEdges, atlasHoldsKeyboard, + atlasNodeRadius, atlasRegionDirection, buildAtlasGraph, collectAtlasPositions, @@ -451,7 +452,7 @@ export function AtlasView(): JSX.Element { const d = w.display[i] const em = emphasis(i) const hue = col.regionHues[n.region % col.regionHues.length] - const base = Math.max(1.4, (3.4 + Math.sqrt(n.degree) * 2.1) * p.s) + const base = Math.max(1.4, atlasNodeRadius(n.degree) * p.s) if (em.glow > 0.05) { const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, base * 4.2) const glowHue = st.lens === 2 ? col.accent : hue @@ -596,7 +597,7 @@ export function AtlasView(): JSX.Element { stateRef.current.graph.nodes.forEach((n, i) => { const p = proj[i] if (!p || world.current.display[i].alpha < 0.1) return - const r = Math.max(1.4, (3.4 + Math.sqrt(n.degree) * 2.1) * p.s) + 6 + const r = Math.max(1.4, atlasNodeRadius(n.degree) * p.s) + 6 const d = (p.sx - sx) ** 2 + (p.sy - sy) ** 2 if (d < r * r && p.depth < bd) { bd = p.depth diff --git a/packages/app-core/src/lib/atlas.test.ts b/packages/app-core/src/lib/atlas.test.ts index a0a5794c..534b8a03 100644 --- a/packages/app-core/src/lib/atlas.test.ts +++ b/packages/app-core/src/lib/atlas.test.ts @@ -1,7 +1,17 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' -import { atlasHoldsKeyboard, atlasRegionDirection } from './atlas' +import type { NoteMeta } from '@shared/ipc' +import { + atlasHoldsKeyboard, + atlasNodeRadius, + atlasRegionDirection, + buildAtlasGraph, + collectAtlasPositions, + layoutAtlas, + type AtlasGraph, + type AtlasPositions +} from './atlas' import { SELF_KEYED_SURFACES } from './self-keyed-surfaces' function keyboardEvent( @@ -83,3 +93,135 @@ describe('atlasHoldsKeyboard (#670)', () => { expect(atlasHoldsKeyboard(null, false)).toBe(false) }) }) + +function note(path: string, links: string[] = [], createdAt = 0): NoteMeta { + const title = path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, '') + return { + path, + title, + folder: 'inbox', + tags: [], + createdAt, + updatedAt: createdAt, + wikilinks: links + } as unknown as NoteMeta +} + +// A small vault with a hub, the shape of #861's: folders as regions and a few +// links across them. +function vault(): NoteMeta[] { + const notes = [note('Zettelkasten/Zettelkasten.md')] + const folders = ['Topics', 'Projects', 'Areas', 'Ressources', 'Indexes'] + for (let f = 0; f < folders.length; f++) + for (let i = 0; i < 10; i++) { + const links = i === 0 ? ['Zettelkasten'] : [`${folders[f]} ${i - 1}`] + notes.push(note(`${folders[f]}/${folders[f]} ${i}.md`, links, f * 10 + i)) + } + return notes +} + +function laidOut(notes: NoteMeta[], previous: AtlasPositions | null = null): AtlasGraph { + const graph = buildAtlasGraph(notes) + layoutAtlas(graph, previous) + return graph +} + +// Edge-to-edge room between two notes' dots in the flat map or the sky. +function gap(graph: AtlasGraph, a: string, b: string, dims: 2 | 3): number { + const na = graph.nodes.find((n) => n.path === a)! + const nb = graph.nodes.find((n) => n.path === b)! + const d = + dims === 2 ? Math.hypot(na.x2 - nb.x2, na.y2 - nb.y2) : Math.hypot(na.x - nb.x, na.y - nb.y, na.z - nb.z) + return d - atlasNodeRadius(na.degree) - atlasNodeRadius(nb.degree) +} + +function tightest(graph: AtlasGraph, paths: string[], dims: 2 | 3): number { + let least = Infinity + for (const a of paths) + for (const b of graph.nodes) + if (b.path !== a) least = Math.min(least, gap(graph, a, b.path, dims)) + return least +} + +describe('layoutAtlas keeps notes apart (#861)', () => { + const later = [ + note('Projects/Slip Box Setup.md', ['Zettelkasten'], 1000), + note('Areas/Knowledge Work.md', ['Zettelkasten'], 1001), + note('Ressources/Luhmann Archive.md', ['Zettelkasten'], 1002), + note('Indexes/Method Index.md', ['Zettelkasten'], 1003), + note('Topics/Zettelkasten Method.md', ['Zettelkasten'], 1004), + note('Zettelkasten/Index Cards.md', ['Zettelkasten'], 1005), + note('Projects/Pasta al Limone.md', ['Topics 4'], 1006), + note('Projects/Apfelkuchen.md', ['Pasta al Limone'], 1007) + ] + const laterPaths = later.map((n) => n.path) + + it('places notes written after the map was drawn beside what they link to, never on a note', () => { + const first = collectAtlasPositions(laidOut(vault())) + const graph = laidOut([...vault(), ...later], first) + for (const dims of [2, 3] as const) { + expect(tightest(graph, laterPaths, dims)).toBeGreaterThan(0) + for (const n of later) { + const target = graph.nodes.find((m) => m.title === n.wikilinks[0])! + const room = gap(graph, n.path, target.path, dims) + // Clear of its link, and still beside it. + expect(room).toBeGreaterThan(0) + expect(room).toBeLessThan(150) + } + } + // Nothing that was already on the map moved. + for (const [path, at] of Object.entries(first)) { + expect(collectAtlasPositions(graph)[path]).toEqual(at) + } + }) + + it('finds room for many notes written at once around one hub', () => { + const base = vault() + const crowd = Array.from({ length: 30 }, (_, i) => + note(`Zettelkasten/Card ${i}.md`, ['Zettelkasten'], 2000 + i) + ) + const graph = laidOut([...base, ...crowd], collectAtlasPositions(laidOut(base))) + for (const dims of [2, 3] as const) { + expect(tightest(graph, crowd.map((n) => n.path), dims)).toBeGreaterThan(0) + } + }) + + it('moves a note the old placement stacked on its link, and nothing else', () => { + const first = laidOut(vault()) + const hub = first.nodes.find((n) => n.path === 'Zettelkasten/Zettelkasten.md')! + const clumped: AtlasPositions = collectAtlasPositions(first) + // What 2.56 cached for these notes: the hub's position, give or take 11. + later.slice(0, 6).forEach((n, i) => { + const dx = ((i * 7) % 11) - 5 + const dy = ((i * 5) % 9) - 4 + clumped[n.path] = { x: hub.x + dx, y: hub.y - dy, z: hub.z + dy, x2: hub.x2 + dx, y2: hub.y2 + dy } + }) + const graph = laidOut([...vault(), ...later.slice(0, 6)], clumped) + for (const dims of [2, 3] as const) { + for (const path of laterPaths.slice(0, 6)) { + expect(gap(graph, path, hub.path, dims)).toBeGreaterThan(0) + } + expect(tightest(graph, laterPaths.slice(0, 6), dims)).toBeGreaterThan(0) + } + const now = collectAtlasPositions(graph) + for (const n of vault()) expect(now[n.path]).toEqual(clumped[n.path]) + }) + + it('settles to itself: opening the map again moves nothing', () => { + const fresh = laidOut(vault()) + expect(collectAtlasPositions(laidOut(vault(), collectAtlasPositions(fresh)))).toEqual( + collectAtlasPositions(fresh) + ) + const grown = laidOut([...vault(), ...later], collectAtlasPositions(fresh)) + expect(collectAtlasPositions(laidOut([...vault(), ...later], collectAtlasPositions(grown)))).toEqual( + collectAtlasPositions(grown) + ) + }) + + it('is deterministic: the same notes and cache give the same map', () => { + const cache = collectAtlasPositions(laidOut(vault())) + expect(collectAtlasPositions(laidOut([...vault(), ...later], cache))).toEqual( + collectAtlasPositions(laidOut([...vault(), ...later], cache)) + ) + }) +}) diff --git a/packages/app-core/src/lib/atlas.ts b/packages/app-core/src/lib/atlas.ts index 6c87f1b0..6dfecaf2 100644 --- a/packages/app-core/src/lib/atlas.ts +++ b/packages/app-core/src/lib/atlas.ts @@ -3,9 +3,11 @@ // // Two hard rules from the design (docs/ideas/atlas.md): // - The layout is DETERMINISTIC and FROZEN. Same notes + links = same map. -// - Notes already on the map never move when new notes arrive; newcomers are -// placed at the centroid of their linked neighbors (or their region) so the -// geography stays a place the user knows. +// - Notes already on the map never move when new notes arrive; a newcomer +// takes the nearest free spot beside what it links to (or in its region), +// so the geography stays a place the user knows. The one exception is a +// note sitting on a note it links to, which is no place anyone could know +// (settleAtlasSpace, below). import type { NoteMeta } from '@shared/ipc' import { resolveWikilinkTarget } from './wikilinks' @@ -60,6 +62,11 @@ function mulberry32(a: number): () => number { } } +/** A note's dot radius in world units; the view scales it with the zoom. */ +export function atlasNodeRadius(degree: number): number { + return 3.4 + Math.sqrt(degree) * 2.1 +} + /** The region a note belongs to: its top-level folder (clustering can come later). */ export function atlasRegionKey(path: string): string { const slash = path.indexOf('/') @@ -242,10 +249,197 @@ function relax( } } +// Spacing for notes placed after the map was drawn, edge to edge between +// dots, in world units. A newcomer takes the nearest spot PLACE_GAP clear of +// every other dot when one lies within PLACE_NEAR of the notes it links to; +// on a map too crowded for that it takes the nearest spot that is MIN_GAP +// clear, and LINK_GAP clear of what it links to. Relaxation leaves linked +// notes about 90 apart, so PLACE_GAP is snug but readable. +const PLACE_GAP = 24 +const PLACE_NEAR = 120 +const MIN_GAP = 4 +// Newcomers used to land on the centroid of what they link to, give or take +// 11 units a side, with no look at what was already there: a note written +// after the map was first drawn sat on its link, and the cache froze it there +// (#861). A cached note this close to a note it links to (edge to edge) is one +// of those, since relaxation pushes linked notes much further apart. +const LINK_GAP = 12 +const CELL = 96 +const PROBES = 200000 + +/** + * The k-th probe around an anchor, and its distance from it: a sunflower in + * 2D, a filled ball in 3D. Probes widen evenly from the anchor itself, so the + * first one that fits is about the nearest spot that does. + */ +function probeOffset(k: number, dims: 2 | 3): [number, number, number, number] { + if (dims === 2) { + const r = 5 * Math.sqrt(k) + return [Math.cos(k * GOLD) * r, Math.sin(k * GOLD) * r, 0, r] + } + const r = 6 * Math.cbrt(k) + const z = 1 - 2 * ((k * 0.7548776662466927) % 1) + const a = 2 * Math.PI * ((k * 0.5698402909980532) % 1) + const s = Math.sqrt(Math.max(0, 1 - z * z)) + return [Math.cos(a) * s * r, Math.sin(a) * s * r, z * r, r] +} + +/** + * Settle one layout space: every note gets a position, and no note sits on + * a note it links to. + * + * `placed` marks the notes that already have a position here (cached, or just + * relaxed). They keep it, except a note within LINK_GAP of a note it links + * to: of the two, the heavier one (more links, then older, then path order) + * stays and the other moves to the nearest spot that fits, from where it was. + * Notes without a position (newcomers) then take the nearest spot that fits + * around the notes they link to, or around their region slot while none of + * those is on the map. Every placement meets the rule the first pass checks, + * so a settled space settles to itself and reopening the map moves nothing. + */ +function settleAtlasSpace( + nodes: AtlasNode[], + adjacency: ReadonlyMap, + dims: 2 | 3, + placed: boolean[], + get: (n: AtlasNode) => [number, number, number], + set: (n: AtlasNode, x: number, y: number, z: number) => void, + regionSlot: (i: number) => [number, number, number] +): void { + const count = nodes.length + const radius = nodes.map((n) => atlasNodeRadius(n.degree)) + let maxRadius = 0 + for (const r of radius) maxRadius = Math.max(maxRadius, r) + const linked = new Set() + adjacency.forEach((list, a) => list.forEach((b) => linked.add(a * count + b))) + const px = new Float64Array(count) + const py = new Float64Array(count) + const pz = new Float64Array(count) + nodes.forEach((n, i) => { + if (!placed[i]) return + const [x, y, z] = get(n) + px[i] = x + py[i] = y + pz[i] = dims === 3 ? z : 0 + }) + const grid = new Map() + const add = (i: number): void => { + const key = + Math.floor(px[i] / CELL) + + ',' + + Math.floor(py[i] / CELL) + + ',' + + (dims === 3 ? Math.floor(pz[i] / CELL) : 0) + const cell = grid.get(key) + if (cell) cell.push(i) + else grid.set(key, [i]) + } + // How note i would sit at (x, y, z) among the notes in the grid: 0 when it + // would cover or crowd one (under MIN_GAP, or under LINK_GAP from a note it + // links to), 1 when it fits but snugly, 2 when it is PLACE_GAP clear. + const fit = (i: number, x: number, y: number, z: number, strict: boolean): 0 | 1 | 2 => { + const reach = Math.ceil((radius[i] + maxRadius + PLACE_GAP) / CELL) + const gx = Math.floor(x / CELL) + const gy = Math.floor(y / CELL) + const gz = dims === 3 ? Math.floor(z / CELL) : 0 + let roomy = true + for (let ax = gx - reach; ax <= gx + reach; ax++) + for (let ay = gy - reach; ay <= gy + reach; ay++) + for (let az = dims === 3 ? gz - reach : 0; az <= (dims === 3 ? gz + reach : 0); az++) { + const cell = grid.get(ax + ',' + ay + ',' + az) + if (!cell) continue + for (const j of cell) { + const dx = px[j] - x + const dy = py[j] - y + const dz = pz[j] - z + const gap = Math.sqrt(dx * dx + dy * dy + dz * dz) - radius[i] - radius[j] + if (gap < LINK_GAP && linked.has(i * count + j)) return 0 + if (!strict) continue + if (gap < MIN_GAP) return 0 + if (gap < PLACE_GAP) roomy = false + } + } + return roomy ? 2 : 1 + } + + const heavier = (a: number, b: number): number => + nodes[b].degree - nodes[a].degree || + nodes[a].createdAt - nodes[b].createdAt || + (nodes[a].path < nodes[b].path ? -1 : nodes[a].path > nodes[b].path ? 1 : 0) + const moved: number[] = [] + for (const i of nodes.map((_, i) => i).filter((i) => placed[i]).sort(heavier)) { + if (fit(i, px[i], py[i], pz[i], false) === 0) { + placed[i] = false + moved.push(i) + } else add(i) + } + + const placeNear = (i: number, ax: number, ay: number, az: number): void => { + let snug: [number, number, number] | null = null + let at: [number, number, number] = [ax, ay, az] + for (let k = 0; k < PROBES; k++) { + const [ox, oy, oz, reach] = probeOffset(k, dims) + if (snug && reach > PLACE_NEAR) { + at = snug + break + } + at = [ax + ox, ay + oy, dims === 3 ? az + oz : 0] + const f = fit(i, at[0], at[1], at[2], true) + if (f === 2) break + if (f === 1 && !snug) snug = at + } + ;[px[i], py[i], pz[i]] = at + set(nodes[i], at[0], at[1], at[2]) + placed[i] = true + add(i) + } + for (const i of moved) placeNear(i, px[i], py[i], pz[i]) + + // Newcomers, breadth-first from the map, so a chain of new notes grows off + // the note it hangs from instead of parking in its region. + const queue: number[] = [] + const queued = new Uint8Array(count) + const offer = (i: number): void => { + if (placed[i] || queued[i]) return + if (!(adjacency.get(i) ?? []).some((m) => placed[m])) return + queued[i] = 1 + queue.push(i) + } + nodes.forEach((_, i) => offer(i)) + let head = 0 + let next = 0 + for (;;) { + let i: number + if (head < queue.length) i = queue[head++] + else { + while (next < count && placed[next]) next++ + if (next === count) break + i = next + } + const around = (adjacency.get(i) ?? []).filter((m) => placed[m]) + if (around.length > 0) { + let sx = 0 + let sy = 0 + let sz = 0 + for (const m of around) { + sx += px[m] + sy += py[m] + sz += pz[m] + } + placeNear(i, sx / around.length, sy / around.length, sz / around.length) + } else { + const [x, y, z] = regionSlot(i) + placeNear(i, x, y, dims === 3 ? z : 0) + } + for (const m of adjacency.get(i) ?? []) offer(m) + } +} + /** * Lay out the graph in both dimensions. Nodes whose path appears in `previous` * keep those positions verbatim; only newcomers are computed. When most of the * vault is new (or nothing is cached) the full deterministic layout runs. + * Either way the result is settled (settleAtlasSpace). */ export function layoutAtlas(graph: AtlasGraph, previous?: AtlasPositions | null): void { const { nodes, edges, regions } = graph @@ -278,51 +472,47 @@ export function layoutAtlas(graph: AtlasGraph, previous?: AtlasPositions | null) } const fresh = nodes.length - known.size const fullLayout = known.size === 0 || fresh / Math.max(1, nodes.length) > 0.4 - const perRegionIndex = new Map() const adjacency = new Map() edges.forEach(([a, b]) => { ;(adjacency.get(a) ?? adjacency.set(a, []).get(a)!).push(b) ;(adjacency.get(b) ?? adjacency.set(b, []).get(b)!).push(a) }) - nodes.forEach((n, i) => { + // Each note's slot in its region: a flattened sphere (3D) and a + // phyllotaxis disc (2D) around the region's center. + const perRegionIndex = new Map() + const slotIndex = nodes.map((n) => { const j = perRegionIndex.get(n.region) ?? 0 perRegionIndex.set(n.region, j + 1) - if (!fullLayout && known.has(n.path)) return + return j + }) + const slot = (i: number): { x: number; y: number; z: number; x2: number; y2: number } => { + const n = nodes[i] const reg = regions[n.region] - const jitter = (): number => (rng() - 0.5) * 22 - const placedNeighbors = fullLayout - ? [] - : (adjacency.get(i) ?? []).filter((m) => known.has(nodes[m].path)) - if (!fullLayout && placedNeighbors.length > 0) { - // A newcomer lands beside what it links to; nothing else moves. - const sum = placedNeighbors.reduce( - (s, m) => { - const nb = nodes[m] - return [s[0] + nb.x, s[1] + nb.y, s[2] + nb.z, s[3] + nb.x2, s[4] + nb.y2] - }, - [0, 0, 0, 0, 0] - ) - const c = placedNeighbors.length - n.x = sum[0] / c + jitter() - n.y = sum[1] / c + jitter() - n.z = sum[2] / c + jitter() - n.x2 = sum[3] / c + jitter() - n.y2 = sum[4] / c + jitter() - return - } + const j = slotIndex[i] const count = Math.max(1, reg.count) const yy = 1 - (2 * (j + 0.5)) / count const rr = Math.sqrt(Math.max(0, 1 - yy * yy)) const th = j * GOLD + n.region * 1.7 const rad = reg.r * 0.85 - n.x = reg.cx + Math.cos(th) * rr * rad + jitter() - n.y = reg.cy + yy * rad * 0.85 + jitter() - n.z = reg.cz + Math.sin(th) * rr * rad + jitter() const rr2 = reg.r * 0.9 * Math.sqrt((j + 0.6) / count) - n.x2 = reg.cx2 + Math.cos(th) * rr2 + jitter() - n.y2 = reg.cy2 + Math.sin(th) * rr2 + jitter() - }) + return { + x: reg.cx + Math.cos(th) * rr * rad, + y: reg.cy + yy * rad * 0.85, + z: reg.cz + Math.sin(th) * rr * rad, + x2: reg.cx2 + Math.cos(th) * rr2, + y2: reg.cy2 + Math.sin(th) * rr2 + } + } if (fullLayout) { + const jitter = (): number => (rng() - 0.5) * 22 + nodes.forEach((n, i) => { + const s = slot(i) + n.x = s.x + jitter() + n.y = s.y + jitter() + n.z = s.z + jitter() + n.x2 = s.x2 + jitter() + n.y2 = s.y2 + jitter() + }) const iterations = nodes.length > 1500 ? 50 : 120 relax( nodes, @@ -350,6 +540,38 @@ export function layoutAtlas(graph: AtlasGraph, previous?: AtlasPositions | null) (n) => [regions[n.region].cx2, regions[n.region].cy2, 0] ) } + const onMap = (): boolean[] => nodes.map((n) => fullLayout || known.has(n.path)) + settleAtlasSpace( + nodes, + adjacency, + 3, + onMap(), + (n) => [n.x, n.y, n.z], + (n, x, y, z) => { + n.x = x + n.y = y + n.z = z + }, + (i) => { + const s = slot(i) + return [s.x, s.y, s.z] + } + ) + settleAtlasSpace( + nodes, + adjacency, + 2, + onMap(), + (n) => [n.x2, n.y2, 0], + (n, x, y) => { + n.x2 = x + n.y2 = y + }, + (i) => { + const s = slot(i) + return [s.x2, s.y2, 0] + } + ) // Label anchors follow the notes, wherever they ended up. regions.forEach((reg, ri) => { const rn = nodes.filter((n) => n.region === ri) From 21ac81d1c1303323982630dc312c7e3671c30d4e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 11:45:01 -0500 Subject: [PATCH 11/20] Fix(home): a note opened or created from Home takes the keyboard (#863) Home's Recent and Favorites rows opened a note with no hand-off to the editor. The click swaps the Home view out, button and all, so DOM focus fell to : the note showed, but with Vim mode off typing went nowhere, and with it on VimNav, which routes keys to the sidebar whenever no editor has focus, read the letters as sidebar and leader commands. That is what @phoscoder met on Omarchy (#863): typing moved the sidebar cursor up to the note's folder, the Plans row highlighted in the report's screenshot, and turning Vim mode off only made typing do nothing. The sidebar tree, the note list and the palettes have handed the keyboard over since #599; Home never did. Both rows now open the note and then call focusEditorNormalMode, the same hand-off. Home's New note buttons create the note with focusTitle, like every other New note button, so the new note's title field takes the keyboard instead of nothing. Verified over CDP on a vault shaped like the report's (notes at the vault root, a Plans folder), both stores isolated. Before, a note opened from Recent left focus on : with Vim mode off the first keystrokes were lost, and with it on "h" moved the sidebar cursor up to Plans and a space and "i" opened the Insert template picker. After, the keys reach the editor in both modes, and New note leaves the cursor in the title field. Four tests render HomeView; all four fail on the old view. --- .../app-core/src/components/HomeView.test.ts | 102 ++++++++++++++++++ packages/app-core/src/components/HomeView.tsx | 21 +++- 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 packages/app-core/src/components/HomeView.test.ts diff --git a/packages/app-core/src/components/HomeView.test.ts b/packages/app-core/src/components/HomeView.test.ts new file mode 100644 index 00000000..ccc4bbdc --- /dev/null +++ b/packages/app-core/src/components/HomeView.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { HomeView } from './HomeView' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) + +function note(title: string, updatedAt: number): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + } +} + +describe('HomeView: opening a note hands the keyboard to the editor (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let selectNote: ReturnType + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + selectNote = vi.fn(async () => {}) + useStore.setState({ + notes: [note('Support', 2), note('Plans', 1)], + folders: [], + vaultTasks: [], + tasksLoading: true, + refreshTasks: vi.fn(async () => {}), + selectNote, + vaultSettings: { ...originalState.vaultSettings, favorites: ['inbox/Plans.md'] } + }) + act(() => root.render(createElement(HomeView, { sidebarOpen: true, onShowSidebar: () => {} }))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const click = async (selector: string): Promise => { + const row = host.querySelector(selector) + expect(row).not.toBeNull() + await act(async () => { + row!.click() + }) + } + + it('from a recent note', async () => { + await click('[data-home-section="recent"] [data-home-note-path="inbox/Support.md"]') + expect(selectNote).toHaveBeenCalledWith('inbox/Support.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('from a favorite note', async () => { + await click('[data-home-favorite="note"][data-home-note-path="inbox/Plans.md"]') + expect(selectNote).toHaveBeenCalledWith('inbox/Plans.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('names a new note first: New note focuses its title field, like every other New note button', async () => { + const createAndOpen = vi.fn(async () => {}) + act(() => useStore.setState({ createAndOpen })) + const button = [...host.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'New note') + expect(button).toBeDefined() + await act(async () => { + button!.click() + }) + expect(createAndOpen).toHaveBeenCalledWith('inbox', '', { focusTitle: true }) + }) + + it('only once the note has opened', async () => { + let opened: () => void = () => {} + selectNote.mockImplementation(() => new Promise((resolve) => (opened = resolve))) + await click('[data-home-section="recent"] [data-home-note-path="inbox/Support.md"]') + expect(focusEditorNormalMode).not.toHaveBeenCalled() + await act(async () => opened()) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/components/HomeView.tsx b/packages/app-core/src/components/HomeView.tsx index ba117d0f..c2e33792 100644 --- a/packages/app-core/src/components/HomeView.tsx +++ b/packages/app-core/src/components/HomeView.tsx @@ -3,6 +3,7 @@ import type { NoteMeta } from '@shared/ipc' import { filterTasksForDisplay, type VaultTask } from '@shared/tasks' import { useStore } from '../store' import { computeTasksRender } from '../lib/tasks-filter' +import { focusEditorNormalMode } from '../lib/editor-focus' import { InlineMarkdown } from '../lib/inline-markdown' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { @@ -91,6 +92,18 @@ export function HomeView({ const containerRef = useRef(null) + // Opening a note from here swaps this view out, button and all, so DOM + // focus fell to : the note showed, but with Vim mode off typing went + // nowhere, and with it on the keys drove the sidebar and the leader + // instead of the note (#863). Hand the keyboard to the editor the way the + // sidebar and the palettes do (#599). + const openNote = useCallback( + (path: string): void => { + void selectNote(path).then(() => focusEditorNormalMode()) + }, + [selectNote] + ) + // Quick-create actions. Daily/weekly only appear when enabled in settings // (they default off), matching the command palette's gating. const actions = useMemo void }>>(() => { @@ -98,7 +111,7 @@ export function HomeView({ { label: 'New note', icon: , - run: () => void createAndOpen('inbox', '') + run: () => void createAndOpen('inbox', '', { focusTitle: true }) }, { label: 'Database', @@ -306,7 +319,7 @@ export function HomeView({ type="button" data-home-item data-home-note-path={note.path} - onClick={() => void selectNote(note.path)} + onClick={() => openNote(note.path)} className="group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-paper-200/60 focus:bg-paper-200/70 focus:outline-none" > void createAndOpen('inbox', '') + run: () => void createAndOpen('inbox', '', { focusTitle: true }) }, { label: 'New from template', @@ -397,7 +410,7 @@ export function HomeView({ data-home-favorite={item.kind} {...(item.kind === 'note' ? { 'data-home-note-path': item.path } : {})} onClick={() => - item.kind === 'note' ? void selectNote(item.path) : openFavoriteFolder(item) + item.kind === 'note' ? openNote(item.path) : openFavoriteFolder(item) } className="group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-paper-200/60 focus:bg-paper-200/70 focus:outline-none" > From 5a7a40df5396b6252ef9340bfca391004c7af8fb Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 11:45:01 -0500 Subject: [PATCH 12/20] Fix(notes): a note replaced from outside stays open, unsaved text and all (#863) An open note whose file another program deleted and wrote back, the way git, some sync clients and some editors replace files, closed under the reader. chokidar merges an unlink and an add into a change only inside 100 ms, so a slower replace reached the store as an unlink, and closeUnlinkedNote dropped the tab and the buffer with it, text not yet saved included; with one note open, the reader landed on Home while the note showed up again in Recent as edited "just now". A note-list refresh taken while the file was gone could likewise prune a background tab with unsaved edits. An unlink for an open note now waits 1.5 s and asks the disk again. The tab stays when the file is back (its add re-reads the text as before) and closes only when it is still gone, and it never closes over unsaved edits: the pending save writes the note back, the rule a stale listing already follows for the active tab. The refresh keeps any dirty tab, in the background as in front. The app's own deletes, trashes and moves close their tabs themselves and are unaffected. Verified over CDP: a note whose file was deleted and rewritten 300 ms later went to Home and lost the words typed just before; now it stays open with them. A saved note deleted for good is still open 0.6 s later and on Home by 3 s. Four new store tests fail on the old store; the three #713 rename tests now model the deleted file on disk and pass on both. --- packages/app-core/src/store.test.ts | 131 +++++++++++++++++++++++++++- packages/app-core/src/store.ts | 42 ++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 803d61b5..e8a5c0c6 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -114,6 +114,26 @@ afterEach(() => { lastLoadedStore = null }) +type LoadedStore = Awaited>['useStore'] + +/** Run `work` on a fake clock and let `ms` of it pass: the unlink of an open + * note waits for its file to come back before the tab closes (#863). */ +async function onFakeClock(ms: number, work: () => Promise): Promise { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const done = work() + await vi.advanceTimersByTimeAsync(ms) + await done + } finally { + vi.useRealTimers() + } +} + +const applyUnlink = (useStore: LoadedStore, path: string): Promise => + onFakeClock(2000, () => useStore.getState().applyChange({ kind: 'unlink', path, folder: 'inbox' })) + +const missing = (path: string): Error => Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + async function flushAsyncWork(): Promise { await new Promise((resolve) => window.setTimeout(resolve, 0)) } @@ -2510,7 +2530,7 @@ describe('renaming the open note while the watcher reports the move (#713)', () // inotify: the move is an unlink of the old path and an add of the new // one, both delivered before the host has answered the rename. - await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + await applyUnlink(useStore, OLD) await useStore.getState().applyChange({ kind: 'add', path: NEW, folder: 'inbox' }) await useStore.getState().refreshNotes() expect(useStore.getState().selectedPath).toBe(OLD) @@ -2534,7 +2554,8 @@ describe('renaming the open note while the watcher reports the move (#713)', () useStore.setState({ notes: [oldNote] }) await useStore.getState().selectNote(OLD) - await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + vi.mocked(window.zen.readNote).mockRejectedValue(missing(OLD)) + await applyUnlink(useStore, OLD) expect(JSON.stringify(useStore.getState().paneLayout)).not.toContain(OLD) expect(useStore.getState().noteContents[OLD]).toBeUndefined() @@ -2552,11 +2573,115 @@ describe('renaming the open note while the watcher reports the move (#713)', () await useStore.getState().renameActive('Taken') expect(useStore.getState().selectedPath).toBe(OLD) - await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + vi.mocked(window.zen.readNote).mockRejectedValue(missing(OLD)) + await applyUnlink(useStore, OLD) expect(JSON.stringify(useStore.getState().paneLayout)).not.toContain(OLD) }) }) +describe('an open note replaced from outside the app (#863)', () => { + const PLAN = 'inbox/Plan.md' + const OTHER = 'inbox/Other.md' + + // The vault as files: another program deletes and rewrites them, and the + // app's own saves land here too. + function installDisk(files: Record): Map { + const disk = new Map(Object.entries(files)) + installZen({ + listNotes: vi.fn().mockImplementation(async () => + [...disk].map(([path, body]) => makeNote(body, path)) + ), + readNote: vi.fn().mockImplementation(async (path: string) => { + const body = disk.get(path) + if (body === undefined) throw missing(path) + return makeNote(body, path) + }), + writeNote: vi.fn().mockImplementation(async (path: string, body: string) => { + disk.set(path, body) + }) + }) + return disk + } + + async function openNotes(disk: Map, ...paths: string[]): Promise { + const { useStore } = await loadStore() + useStore.setState({ notes: [...disk].map(([path, body]) => makeNote(body, path)) }) + for (const path of paths) await useStore.getState().selectNote(path) + return useStore + } + + const tabs = (useStore: LoadedStore): string => JSON.stringify(useStore.getState().paneLayout) + + it('keeps a note open when its file is written back, and takes the new text', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst' }) + const useStore = await openNotes(disk, PLAN) + + // git checkout, some sync clients: delete, then write the file back later + // than chokidar's 100 ms, so the watcher reports an unlink and an add. + disk.delete(PLAN) + await onFakeClock(2000, async () => { + const unlinked = useStore.getState().applyChange({ kind: 'unlink', path: PLAN, folder: 'inbox' }) + await vi.advanceTimersByTimeAsync(400) + disk.set(PLAN, '# Plan\n\nrewritten') + await useStore.getState().applyChange({ kind: 'add', path: PLAN, folder: 'inbox' }) + await unlinked + }) + + expect(useStore.getState().selectedPath).toBe(PLAN) + expect(useStore.getState().noteContents[PLAN]?.body).toBe('# Plan\n\nrewritten') + }) + + it('never closes over unsaved edits: the pending save writes the note back', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst' }) + const useStore = await openNotes(disk, PLAN) + + await onFakeClock(2000, async () => { + useStore.getState().updateNoteBody(PLAN, '# Plan\n\nfirst, and typed') + disk.delete(PLAN) + await useStore.getState().applyChange({ kind: 'unlink', path: PLAN, folder: 'inbox' }) + }) + + expect(useStore.getState().selectedPath).toBe(PLAN) + expect(useStore.getState().noteContents[PLAN]?.body).toBe('# Plan\n\nfirst, and typed') + expect(disk.get(PLAN)).toBe('# Plan\n\nfirst, and typed') + }) + + it('closes a clean note whose file stays gone, once the wait is over', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst' }) + const useStore = await openNotes(disk, PLAN) + + disk.delete(PLAN) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const unlinked = useStore.getState().applyChange({ kind: 'unlink', path: PLAN, folder: 'inbox' }) + await vi.advanceTimersByTimeAsync(1000) + expect(tabs(useStore)).toContain(PLAN) + await vi.advanceTimersByTimeAsync(1000) + await unlinked + } finally { + vi.useRealTimers() + } + expect(tabs(useStore)).not.toContain(PLAN) + expect(useStore.getState().noteContents[PLAN]).toBeUndefined() + }) + + it('keeps a background tab with unsaved edits through a listing that missed its file', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst', [OTHER]: '# Other\n' }) + const useStore = await openNotes(disk, PLAN, OTHER) + expect(useStore.getState().selectedPath).toBe(OTHER) + + await onFakeClock(0, async () => { + useStore.getState().updateNoteBody(PLAN, '# Plan\n\nfirst, and typed') + disk.delete(PLAN) + await useStore.getState().refreshNotes() + }) + + expect(tabs(useStore)).toContain(PLAN) + expect(useStore.getState().noteContents[PLAN]?.body).toBe('# Plan\n\nfirst, and typed') + expect(useStore.getState().noteDirty[PLAN]).toBe(true) + }) +}) + describe('custom templates on the change feed (#723)', () => { it('re-lists templates on a templates-scope event without touching the note tree', async () => { const listTemplates = vi diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 24fd0cc6..1d453a68 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3916,6 +3916,16 @@ export function noteDiskRevision(path: string): number { */ const renamesInFlight = new Set() +/** + * How long an open note's file may be gone before its tab closes. A writer + * that replaces a file by deleting it and writing it back (git, some sync + * clients, editors that save that way) reaches the watcher as an unlink and a + * later add, and chokidar merges the pair only inside 100 ms. Closing on the + * unlink dropped the reader on Home and threw away the text they had not + * saved yet along with the tab (#863). + */ +const UNLINK_SETTLE_MS = 1500 + // --- CSV database debounced persistence + echo suppression --- const DATABASE_SAVE_DEBOUNCE_MS = 400 const databaseSaveTimers = new Map>() @@ -7394,8 +7404,10 @@ export const useStore = create((set, get) => { existingPaths.has(path) || isWorkspaceVirtualTabPath(path) || renamesInFlight.has(path) || - (path === s.selectedPath && - (s.noteContents[path] !== undefined || s.noteDirty[path] === true)) + // Unsaved edits outrank a listing taken while their file was + // briefly gone, in a background tab as in the active one (#863). + s.noteDirty[path] === true || + (path === s.selectedPath && s.noteContents[path] !== undefined) const prunedLayout = rewritePathsInTree(s.paneLayout, (path) => keep(path) ? path : null ) @@ -7585,6 +7597,30 @@ export const useStore = create((set, get) => { } }) } + // An unlink from outside the app (this app's own deletes and moves close + // their tabs themselves) may be the first half of a replace, so the tab + // waits for the file to come back before closing. It never closes over + // unsaved edits: the note's pending save writes the file back instead, + // the same way a stale listing never outranks a dirty buffer (#863). + const settleUnlinkedNote = async (notePath: string): Promise => { + const root = get().vault?.root + await new Promise((resolve) => setTimeout(resolve, UNLINK_SETTLE_MS)) + const stillOpen = (): boolean => { + const s = get() + return ( + s.vault?.root === root && + findLeavesContaining(s.paneLayout, notePath).length > 0 && + !s.noteDirty[notePath] + ) + } + if (!stillOpen()) return + const back = await window.zen.readNote(notePath).then( + () => true, + () => false + ) + if (back || !stillOpen()) return + closeUnlinkedNote(notePath) + } if (ev.scope === 'resync') { // The change feed was interrupted and events were lost; re-pull every // surface the feed keeps fresh instead of trusting the resumed stream. @@ -7773,7 +7809,7 @@ export const useStore = create((set, get) => { if (!open) return if (ev.kind === 'unlink') { - closeUnlinkedNote(ev.path) + await settleUnlinkedNote(ev.path) return } From 6f18169d4679086a2f1432052841a14c98318f61 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 11:45:01 -0500 Subject: [PATCH 13/20] Fix(notes): a note opened from Connections, Assets, the Atlas or Cloud settings takes the keyboard The #863 audit of every place that opens a note by mouse. Four still opened it with no hand-off to the editor, so focus stayed on the clicked row or fell to : typing went nowhere with Vim mode off, and to the sidebar and the leader with it on. - Connections panel: clicking a link or a backlink row, and creating the note a missing link names. Enter on a row already handed over through VimNav. - Assets view: the Used button's note, and the menu of notes when several use the asset. Opening the asset itself is unchanged. - Atlas: Enter on the focused note, and a second click on a node. - Cloud settings: Open and Open copy on a file that needs attention, which close Settings first. Each now calls focusEditorNormalMode once the note has opened. Left alone on purpose: Tags, Quick Notes, Archive and Trash already hand the keyboard over with a one-shot focus that works (checked in the app); hover previews, Excalidraw menus, the Obsidian drawing converter and Preview's asset and diagram links open no text editor; and a note link followed in Preview shares its code with the editor's own link following, where a reader is not asking to type. Verified over CDP with Vim mode off, opening a note by mouse from each surface: before, the Connections panel, the Assets view and the Atlas left focus on ; after, every surface lands in the editor. Cloud settings is covered by its test only (it needs a sync conflict). Eight new component tests, all but the asset-row guard failing on the old components, and the Cloud settings Open copy test now expects the hand-off. --- .../src/components/AssetsView.test.ts | 94 +++++++++++++++ .../app-core/src/components/AssetsView.tsx | 11 +- .../app-core/src/components/AtlasView.test.ts | 87 ++++++++++++++ .../app-core/src/components/AtlasView.tsx | 11 +- .../src/components/CloudSettings.test.ts | 8 ++ .../app-core/src/components/CloudSettings.tsx | 5 +- .../src/components/ConnectionsPanel.test.ts | 108 ++++++++++++++++++ .../src/components/ConnectionsPanel.tsx | 18 ++- 8 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 packages/app-core/src/components/AssetsView.test.ts create mode 100644 packages/app-core/src/components/AtlasView.test.ts create mode 100644 packages/app-core/src/components/ConnectionsPanel.test.ts diff --git a/packages/app-core/src/components/AssetsView.test.ts b/packages/app-core/src/components/AssetsView.test.ts new file mode 100644 index 00000000..056c7976 --- /dev/null +++ b/packages/app-core/src/components/AssetsView.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AssetMeta, NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { AssetsView } from './AssetsView' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) + +function note(title: string, assetEmbeds: string[]): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds, + excerpt: '' + } +} + +function asset(name: string): AssetMeta { + return { path: `assets/${name}`, name, kind: 'image', siblingOrder: 0, size: 1, updatedAt: 0 } +} + +describe('AssetsView: a note opened through the asset it uses takes the keyboard (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let openNoteInTab: ReturnType + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + openNoteInTab = vi.fn(async () => {}) + useStore.setState({ + vault: { ...(originalState.vault ?? {}), root: '/vault', name: 'Vault' } as NonNullable, + assetFiles: [asset('solo.png'), asset('shared.png')], + notes: [note('Alpha', ['solo.png', 'shared.png']), note('Beta', ['shared.png'])], + openNoteInTab + }) + act(() => root.render(createElement(AssetsView))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const usedButton = (assetPath: string): HTMLButtonElement => { + const row = host.querySelector(`[title="${assetPath}"]`) + const button = row && [...row.querySelectorAll('button')].find((b) => !b.disabled) + expect(button).toBeTruthy() + return button as HTMLButtonElement + } + + it('from the Used count, when one note uses the asset', async () => { + await act(async () => usedButton('assets/solo.png').click()) + expect(openNoteInTab).toHaveBeenCalledWith('inbox/Alpha.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('from the menu of notes, when several use it', async () => { + await act(async () => usedButton('assets/shared.png').click()) + const item = [...document.querySelectorAll('[data-ctx-menu] button, [data-ctx-menu] [role="menuitem"]')].find( + (el) => el.textContent?.includes('Beta') + ) + expect(item).toBeTruthy() + await act(async () => item!.click()) + expect(openNoteInTab).toHaveBeenCalledWith('inbox/Beta.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('but opening the asset itself leaves the keyboard alone', async () => { + const row = host.querySelector('[title="assets/solo.png"]') + await act(async () => row!.click()) + expect(openNoteInTab).toHaveBeenCalledWith(expect.stringContaining('solo.png')) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/AssetsView.tsx b/packages/app-core/src/components/AssetsView.tsx index 406e0020..3a4e2798 100644 --- a/packages/app-core/src/components/AssetsView.tsx +++ b/packages/app-core/src/components/AssetsView.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import type { AssetMeta } from '@shared/ipc' import { useStore, type AssetSortColumn, type AssetSortOrder } from '../store' import { assetTabPath } from '../lib/asset-tabs' +import { focusEditorNormalMode } from '../lib/editor-focus' import { confirmMoveToTrash } from '../lib/confirm-trash' import { promptApp } from '../lib/prompt-requests' import { naturalCompare } from '../lib/natural-sort' @@ -126,6 +127,12 @@ export function AssetsView(): JSX.Element { const setAssetSortOrder = useStore((s) => s.setAssetSortOrder) const sort = useMemo(() => parseAssetSortOrder(assetSortOrder), [assetSortOrder]) + // A note found through the asset it uses opens for editing: hand it the + // keyboard, since the click left focus on this view's button (#863). + const openUsingNote = (path: string): void => { + void openNoteInTab(path).then(() => focusEditorNormalMode()) + } + // assetPath → note paths that embed it (resolved via relative-path + the // unique-basename fallback, matching how embeds render). (#185) const usage = useMemo(() => { @@ -293,7 +300,7 @@ export function AssetsView(): JSX.Element { onClick={(e) => { e.stopPropagation() const notePaths = usage.get(asset.path) ?? [] - if (notePaths.length === 1) void openNoteInTab(notePaths[0]!) + if (notePaths.length === 1) openUsingNote(notePaths[0]!) else if (notePaths.length > 1) setUsageMenu({ x: e.clientX, y: e.clientY, notes: notePaths }) }} @@ -356,7 +363,7 @@ export function AssetsView(): JSX.Element { y={usageMenu.y} items={usageMenu.notes.map((notePath) => ({ label: noteLabel(notePath), - onSelect: () => void openNoteInTab(notePath) + onSelect: () => openUsingNote(notePath) }))} onClose={() => setUsageMenu(null)} /> diff --git a/packages/app-core/src/components/AtlasView.test.ts b/packages/app-core/src/components/AtlasView.test.ts new file mode 100644 index 00000000..22e1ef3a --- /dev/null +++ b/packages/app-core/src/components/AtlasView.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { ATLAS_TAB_PATH } from '@shared/atlas-view' +import { useStore } from '../store' +import { makeLeaf } from '../lib/pane-layout' +import { AtlasView } from './AtlasView' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) + +function note(title: string): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + } +} + +const press = (key: string, code: string): void => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true })) +} + +// The map is a canvas jsdom cannot draw, so a note is picked the keyboard's +// way (a region jump focuses one, Enter opens it); the second click on a node +// goes through the same opener and is checked in the built app. +describe('AtlasView: opening a note from the map hands the keyboard to the editor (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let selectNote: ReturnType + + beforeEach(() => { + originalState = useStore.getState() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + selectNote = vi.fn(async () => {}) + ;(window as unknown as { zen: unknown }).zen = { readNote: vi.fn(async () => ({ body: '' })) } + const leaf = makeLeaf([ATLAS_TAB_PATH], ATLAS_TAB_PATH) + useStore.setState({ + vault: { ...(originalState.vault ?? {}), root: '/vault', name: 'Vault' } as NonNullable, + notes: [note('Alpha')], + vimMode: true, + paneLayout: leaf, + activePaneId: leaf.id, + selectNote + }) + act(() => root.render(createElement(AtlasView))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + vi.restoreAllMocks() + }) + + it('on Enter, once the note has opened', async () => { + let opened: () => void = () => {} + selectNote.mockImplementation(() => new Promise((resolve) => (opened = resolve))) + await act(async () => { + press(']', 'BracketRight') + press('Enter', 'Enter') + }) + expect(selectNote).toHaveBeenCalledWith('inbox/Alpha.md') + expect(focusEditorNormalMode).not.toHaveBeenCalled() + await act(async () => opened()) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/components/AtlasView.tsx b/packages/app-core/src/components/AtlasView.tsx index ba4f0b7d..015e2d14 100644 --- a/packages/app-core/src/components/AtlasView.tsx +++ b/packages/app-core/src/components/AtlasView.tsx @@ -15,6 +15,7 @@ import { type AtlasPositions } from '../lib/atlas' import { isAppOverlayOpen } from '../lib/overlay-open' +import { focusEditorNormalMode } from '../lib/editor-focus' import { extractMarkdownLinkHrefs } from '../lib/wikilinks' import { resolveInternalNoteHref } from '../lib/internal-links' @@ -670,9 +671,15 @@ export function AtlasView(): JSX.Element { setStatus(reg.label) touch() } + // Opening a note leaves the map, and the map held the keyboard: without a + // hand-off typing went nowhere with Vim mode off and to the sidebar with it + // on (#863). + function openNote(path: string): void { + void selectNote(path).then(() => focusEditorNormalMode()) + } function openFocused(): void { const path = world.current.focusPath - if (path) void selectNote(path) + if (path) openNote(path) } // Keyboard: capture phase so it beats VimNav; single letters are Vim-only @@ -893,7 +900,7 @@ export function AtlasView(): JSX.Element { const i = hitTest(e.clientX - rect.left, e.clientY - rect.top) if (i >= 0) { const path = stateRef.current.graph.nodes[i].path - if (world.current.focusPath === path) void selectNote(path) + if (world.current.focusPath === path) openNote(path) else world.current.focusPath = path } else world.current.focusPath = null } diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index fa5a7f12..5e04abc1 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -49,6 +49,7 @@ const mocks = vi.hoisted(() => ({ requestCloudAutoSync: vi.fn(), syncCloudVaultWithStatus: vi.fn(), confirmApp: vi.fn(async () => true), + focusEditorNormalMode: vi.fn(), })); vi.mock("@zennotes/bridge-contract/bridge", () => ({ @@ -59,6 +60,10 @@ vi.mock("../lib/confirm-requests", () => ({ confirmApp: mocks.confirmApp, })); +vi.mock("../lib/editor-focus", () => ({ + focusEditorNormalMode: mocks.focusEditorNormalMode, +})); + vi.mock("../lib/cloud-auto-sync", async (importOriginal) => ({ ...(await importOriginal()), requestCloudAutoSync: mocks.requestCloudAutoSync, @@ -617,9 +622,12 @@ describe("CloudSettings", () => { const openCopy = [...list!.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "Open copy", ); + mocks.focusEditorNormalMode.mockClear(); await act(async () => openCopy!.click()); expect(setSettingsOpen).toHaveBeenCalledWith(false); expect(openNoteInTab).toHaveBeenCalledWith("inbox/Plan (cloud conflict).md"); + // Settings held the keyboard; the note opened behind it takes it (#863). + expect(mocks.focusEditorNormalMode).toHaveBeenCalledTimes(1); } finally { useStore.setState(previous); } diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index ed85aa4a..8827cab6 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -36,6 +36,7 @@ import { notifyPublishedNoteChanged } from "../lib/published-note-events"; import { requestPublishNote } from "../lib/publish-note-requests"; import { Button } from "./ui/Button"; import { useStore } from "../store"; +import { focusEditorNormalMode } from "../lib/editor-focus"; import { CloudPendingConflictResolver } from "./CloudPendingConflictResolver"; type CloudAction = @@ -2020,7 +2021,9 @@ function CloudSyncSummary({ const openPath = (path: string): void => { const store = useStore.getState(); store.setSettingsOpen(false); - void store.openNoteInTab(path); + // Settings held the keyboard; the note opened behind it takes it now, + // or typing went nowhere once the modal closed (#863). + void store.openNoteInTab(path).then(() => focusEditorNormalMode()); }; return (
vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) +const promptApp = vi.hoisted(() => vi.fn()) +vi.mock('../lib/prompt-requests', () => ({ promptApp })) + +function meta(title: string): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + } +} + +const bodies: Record = { + 'inbox/Alpha.md': '# Alpha\n\nSee [[Beta]], and [[Gamma]] which does not exist yet.\n', + 'inbox/Beta.md': '# Beta\n\nBack to [[Alpha]].\n' +} + +describe('ConnectionsPanel: following a connection hands the keyboard to the editor (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let selectNote: ReturnType + let createAndOpen: ReturnType + + beforeEach(async () => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + promptApp.mockReset() + selectNote = vi.fn(async () => {}) + createAndOpen = vi.fn(async () => {}) + ;(window as unknown as { zen: unknown }).zen = { + readNote: vi.fn(async (path: string) => ({ + ...meta(path.replace(/^inbox\/|\.md$/g, '')), + body: bodies[path] ?? '' + })) + } + useStore.setState({ notes: [meta('Alpha'), meta('Beta')], assetFiles: [], selectNote, createAndOpen }) + const note: NoteContent = { ...meta('Alpha'), body: bodies['inbox/Alpha.md'] } + await act(async () => root.render(createElement(ConnectionsPanel, { note }))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const rowsFor = (path: string): HTMLButtonElement[] => [ + ...host.querySelectorAll(`[data-connections-type="note"][data-connections-path="${path}"]`) + ] + + it('from a link out of the note', async () => { + await act(async () => rowsFor('inbox/Beta.md')[0]!.click()) + expect(selectNote).toHaveBeenCalledWith('inbox/Beta.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('from a backlink', async () => { + // Beta shows twice: linked from Alpha, and linking back to it. + const rows = rowsFor('inbox/Beta.md') + expect(rows).toHaveLength(2) + await act(async () => rows[1]!.click()) + expect(selectNote).toHaveBeenCalledWith('inbox/Beta.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('only once the note has opened', async () => { + let opened: () => void = () => {} + selectNote.mockImplementation(() => new Promise((resolve) => (opened = resolve))) + await act(async () => rowsFor('inbox/Beta.md')[0]!.click()) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + await act(async () => opened()) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('after creating the note a missing link names', async () => { + promptApp.mockResolvedValue('inbox/Gamma.md') + const missing = host.querySelector('[data-connections-type="missing"]') + expect(missing).not.toBeNull() + await act(async () => missing!.click()) + expect(createAndOpen).toHaveBeenCalledWith('inbox', '', { title: 'Gamma' }) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx index f046ea85..6e51c931 100644 --- a/packages/app-core/src/components/ConnectionsPanel.tsx +++ b/packages/app-core/src/components/ConnectionsPanel.tsx @@ -14,6 +14,7 @@ import { classifyOutgoingWikilinks, type AttachmentLink } from '../lib/connectio import { assetTabPath } from '../lib/asset-tabs' import { LazyNoteHoverPreview as NoteHoverPreview } from './LazyNoteHoverPreview' import { promptApp } from '../lib/prompt-requests' +import { focusEditorNormalMode } from '../lib/editor-focus' import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' @@ -137,6 +138,15 @@ export function ConnectionsPanel({ [connectionPreview, notes] ) + // Following a connection opens that note in the editor beside the panel, + // but a click left DOM focus on the row, or on once the panel + // re-rendered for the new note, so typing never reached it: nowhere with + // Vim mode off, the sidebar with it on. Enter on a row already hands the + // keyboard over through VimNav; a click does the same now (#863). + const openConnection = (path: string): void => { + void selectNote(path).then(() => focusEditorNormalMode()) + } + const handleCreateMissingLink = async (item: MissingLinkItem): Promise => { const value = await promptApp({ title: `Create note for "${item.target}"?`, @@ -164,9 +174,11 @@ export function ConnectionsPanel({ ) if (existing) { await selectNote(existing.path) + focusEditorNormalMode() return } await createAndOpen(parsed.folder, parsed.subpath, { title: parsed.title }) + focusEditorNormalMode() } useEffect(() => { @@ -298,7 +310,7 @@ export function ConnectionsPanel({ key={item.path} note={item} summary={item.excerpt || 'No excerpt available yet.'} - onOpen={() => void selectNote(item.path)} + onOpen={() => openConnection(item.path)} onHover={(rect) => { cancelScheduledClose() setPreviewFromRect(item, rect) @@ -353,7 +365,7 @@ export function ConnectionsPanel({ .filter(Boolean) .join(' · ') || 'No excerpt available yet.' } - onOpen={() => void selectNote(item.path)} + onOpen={() => openConnection(item.path)} onHover={(rect) => { cancelScheduledClose() setPreviewFromRect(item, rect) @@ -378,7 +390,7 @@ export function ConnectionsPanel({ note={item.note} summary={item.snippet} tone="mention" - onOpen={() => void selectNote(item.note.path)} + onOpen={() => openConnection(item.note.path)} onHover={(rect) => { cancelScheduledClose() setPreviewFromRect(item.note, rect) From 4326847239276e5476d4c6e3063e6b76be2f5ad8 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 13:48:31 -0500 Subject: [PATCH 14/20] Feat(editor): list items and tasks fold like headings, in Edit and Preview (#848) A long to-do note with a few indented detail lines under every task had no way to show just the task lines: headings folded, list items did not, not visibly. @Sullti asked in discussion #848 for the heading treatment: an arrow on hover for any item with indented children, in Edit and Preview, the fold keys, Fold All and Vim's fold commands on list items too, and the Tasks view left alone. A bullet, numbered item or task with a child block below its first line (a nested list, another paragraph, a code block) now gets an arrow beside its marker, shown on hover or on the caret's line and kept showing while folded. The range is the one @codemirror/lang-markdown already gives a ListItem, from the end of the item's first line to the end of the item, so the arrow, the fold keys and CodeMirror's own foldCode fold the same span and each reopens what another closed. An item that only wraps onto a second line has nothing under it and gets no arrow. In Preview the same items get an arrow left of their bullet, number or checkbox, and the fold state lives in the DOM, as Preview's heading folds do. The fold keys were heading-only: Ctrl+Alt+F (Cmd+Option+F) did nothing on an item, while Vim's zc already folded one through the syntax fold. Both now run foldAtCursor: the heading's section, else the item on the line, else the syntax fold, else the item the cursor sits under, with the caret moved to it. Fold All (zM, :foldall) folded only the outermost ranges, so a heading swallowed its tasks; it now folds every heading and every list item, nested, and zo on the heading shows one line per task. Command ids stay; their titles, the keymap catalog, the manual and the demo tour's cheat sheet name list items. Folding only hides lines: nothing is written to the note, and the Tasks view lists the same tasks. Driving the built app turned up two layout traps, and the CSS says why. A list line's negative text-indent is inherited, and it pushed the arrow's glyph out by the marker's width (off screen on a task). In Preview an em or ch inside the arrow's own 12 px font shrank every offset, so the offsets use lengths registered with @property and resolved on the item. The Preview arrow is the item's last child, since as the first it broke the rule that seats a loose task's paragraph beside its checkbox, and it is added after the task pass, which would otherwise take it into the task body. Tests: 7 in cm-heading-fold.test.ts, 8 in preview-list-fold.test.ts. --- packages/app-core/src/components/Editor.tsx | 36 +-- .../app-core/src/components/EditorPane.tsx | 8 +- packages/app-core/src/components/HelpView.tsx | 5 +- packages/app-core/src/components/Preview.tsx | 4 + .../app-core/src/lib/cm-heading-fold.test.ts | 139 ++++++++++- packages/app-core/src/lib/cm-heading-fold.ts | 117 ++++++--- packages/app-core/src/lib/cm-list-fold.ts | 226 ++++++++++++++++++ packages/app-core/src/lib/commands.ts | 35 +-- packages/app-core/src/lib/help.ts | 18 +- packages/app-core/src/lib/keymaps.ts | 24 +- .../src/lib/preview-list-fold.test.ts | 133 +++++++++++ .../app-core/src/lib/preview-list-fold.ts | 109 +++++++++ packages/app-core/src/styles/index.css | 116 +++++++++ packages/shared-domain/src/demo-tour-data.ts | 2 +- packages/shared-domain/src/keymaps-catalog.ts | 12 +- 15 files changed, 881 insertions(+), 103 deletions(-) create mode 100644 packages/app-core/src/lib/cm-list-fold.ts create mode 100644 packages/app-core/src/lib/preview-list-fold.test.ts create mode 100644 packages/app-core/src/lib/preview-list-fold.ts diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index e586cad6..9bbbc375 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -27,7 +27,8 @@ import { } from "../lib/cm-harper"; import { harperEditorConfig } from "../lib/harper-runtime"; import { moveLineDown, moveLineUp } from "@codemirror/commands"; -import { foldAll, unfoldAll, foldCode, unfoldCode } from "@codemirror/language"; +import { unfoldAll } from "@codemirror/language"; +import { foldAllOutline, foldAtCursor, unfoldAtCursor } from "../lib/cm-heading-fold"; import { isTagsViewActive, isTasksViewActive, useStore } from "../store"; import { buildCommands, type Command } from "../lib/commands"; import { rankItems } from "../lib/fuzzy-score"; @@ -1284,27 +1285,26 @@ function registerVimNoteCommands(): void { void useStore.getState().openTrashView(); }); - // Heading fold helpers — wrap CodeMirror's commands so they work on - // whichever pane currently owns the editor. `:fold` / `:unfold` act - // 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 => { + // Fold helpers: run on whichever pane currently owns the editor. + // `:fold` / `:unfold` act on the heading or list item at the cursor + // (#848); `:foldall` / `:unfoldall` cover the whole note, headings and + // lists nested. We map vim's `zc` / `zo` / `zM` / `zR` keys explicitly so + // the advertised fold chords work regardless of what CM-Vim ships by + // default. The action ids keep their heading names; config refers to them. + const runFold = (cmd: (view: EditorView) => boolean): void => { const view = useStore.getState().editorViewRef; if (!view) return; - cmd(view as unknown as Parameters[0]); + cmd(view); view.focus(); }; - Vim.defineAction("foldHeadingAtCursor", () => runFold(foldCode as never)); - Vim.defineAction("unfoldHeadingAtCursor", () => runFold(unfoldCode as never)); - Vim.defineAction("foldAllHeadings", () => runFold(foldAll as never)); - Vim.defineAction("unfoldAllHeadings", () => runFold(unfoldAll as never)); - Vim.defineEx("fold", "fold", () => runFold(foldCode as never)); - Vim.defineEx("unfold", "unfold", () => runFold(unfoldCode as never)); - Vim.defineEx("foldall", "foldall", () => runFold(foldAll as never)); - Vim.defineEx("unfoldall", "unfoldall", () => runFold(unfoldAll as never)); + Vim.defineAction("foldHeadingAtCursor", () => runFold(foldAtCursor)); + Vim.defineAction("unfoldHeadingAtCursor", () => runFold(unfoldAtCursor)); + Vim.defineAction("foldAllHeadings", () => runFold(foldAllOutline)); + Vim.defineAction("unfoldAllHeadings", () => runFold(unfoldAll)); + Vim.defineEx("fold", "fold", () => runFold(foldAtCursor)); + Vim.defineEx("unfold", "unfold", () => runFold(unfoldAtCursor)); + Vim.defineEx("foldall", "foldall", () => runFold(foldAllOutline)); + Vim.defineEx("unfoldall", "unfoldall", () => runFold(unfoldAll)); } /** Flatten the pane tree to a list of leaves, independent of the store's diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 1344ec93..220576f6 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -94,9 +94,9 @@ import { defaultHighlightStyle } from '@codemirror/language' import { - foldHeadingAtCursor, + foldAtCursor, headingFolding, - unfoldHeadingAtCursor + unfoldAtCursor } from '../lib/cm-heading-fold' import { tags as t } from '@lezer/highlight' import { autocompletion } from '@codemirror/autocomplete' @@ -401,8 +401,8 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens // reaching for the arrow keys. Mode-agnostic like the line moves. (#490) ...keyBindingsFor(getKeymapBinding(overrides, 'editor.hopMarkerForward'), markerHop.forward), ...keyBindingsFor(getKeymapBinding(overrides, 'editor.hopMarkerBackward'), markerHop.backward), - ...keyBindingsFor(getKeymapBinding(overrides, 'editor.foldHeading'), foldHeadingAtCursor), - ...keyBindingsFor(getKeymapBinding(overrides, 'editor.unfoldHeading'), unfoldHeadingAtCursor), + ...keyBindingsFor(getKeymapBinding(overrides, 'editor.foldHeading'), foldAtCursor), + ...keyBindingsFor(getKeymapBinding(overrides, 'editor.unfoldHeading'), unfoldAtCursor), // Inline-format shortcuts (bold/italic/code/strike/highlight/math/link). In // Vim mode VimNav owns these (its window handler also resolves the Ctrl+I // jumplist collision on Linux); in non-Vim mode that handler is disabled, so diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index c36751a0..cc7a37a8 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -105,9 +105,12 @@ function resolveShortcutKeys( return leader === UNBOUND_LABEL ? leader : `${leader}, then pause` } if (action === 'Toggle outline panel') return shortcut(overrides, 'global.toggleOutlinePanel') - if (action === 'Fold / unfold heading') { + if (action === 'Fold / unfold heading or list item') { return alternatives([shortcut(overrides, 'vim.foldCurrent'), shortcut(overrides, 'vim.unfoldCurrent')]) } + if (action === 'Fold / unfold, Vim on or off') { + return alternatives([shortcut(overrides, 'editor.foldHeading'), shortcut(overrides, 'editor.unfoldHeading')]) + } if (action === 'Fold / unfold all') { return alternatives([shortcut(overrides, 'vim.foldAll'), shortcut(overrides, 'vim.unfoldAll')]) } diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index b40c607e..888a92b1 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -38,6 +38,7 @@ import { isExcalidrawPath, isObsidianExcalidrawPath } from "@shared/excalidraw"; import { resolveExcalidrawEmbedPath } from "../lib/excalidraw-preview"; import { LazyExcalidrawPreview } from "./LazyExcalidrawPreview"; import { enhancePreviewHeadingFolds } from "../lib/preview-heading-fold"; +import { enhancePreviewListFolds } from "../lib/preview-list-fold"; import { wrapTaskItemOwnText } from "../lib/preview-task-body"; import { previewEditRequestForTarget, @@ -849,6 +850,9 @@ export const Preview = memo(function Preview({ // alone, never on a sub-task or a block such as a code block. (#512, #849) wrapTaskItemOwnText(li, input); }); + // After the task pass: wrapTaskItemOwnText gathers every inline node of a + // task item into its body span, and would take a fold arrow in with them. + enhancePreviewListFolds(stage); const applyRenderedDom = async (): Promise => { try { diff --git a/packages/app-core/src/lib/cm-heading-fold.test.ts b/packages/app-core/src/lib/cm-heading-fold.test.ts index 0f983c75..54b5f42f 100644 --- a/packages/app-core/src/lib/cm-heading-fold.test.ts +++ b/packages/app-core/src/lib/cm-heading-fold.test.ts @@ -2,14 +2,10 @@ import { describe, it, expect } from 'vitest' import { EditorState } from '@codemirror/state' -import { foldService } from '@codemirror/language' +import { foldedRanges, foldService, unfoldEffect } from '@codemirror/language' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorView } from '@codemirror/view' -import { - foldHeadingAtCursor, - headingFolding, - unfoldHeadingAtCursor -} from './cm-heading-fold' +import { foldAllOutline, foldAtCursor, headingFolding, unfoldAtCursor } from './cm-heading-fold' /** Run the heading fold service over a given line; returns its fold range. */ function foldRangeAtLine(doc: string, lineNumber: number): { from: number; to: number } | null { @@ -98,16 +94,143 @@ describe('heading folding', () => { }) }) - expect(foldHeadingAtCursor(view)).toBe(true) + expect(foldAtCursor(view)).toBe(true) expect( Array.from(parent.querySelectorAll('.cm-heading-line-folded')).some((line) => line.textContent?.includes('Fold me') ) ).toBe(true) - expect(unfoldHeadingAtCursor(view)).toBe(true) + expect(unfoldAtCursor(view)).toBe(true) expect(parent.querySelector('.cm-heading-line-folded')).toBeNull() view.destroy() parent.remove() }) }) + +describe('list item folding (#848)', () => { + const doc = [ + '# Todo', // 1 + '', // 2 + '- [ ] **Task title** #tag', // 3 + ' - detail line', // 4 + ' - another detail line', // 5 + '- [ ] **Next task** #tag', // 6 + '- plain bullet', // 7 + ' 1. numbered child', // 8 + '1. First step', // 9 + ' - step detail', // 10 + '2. Second step', // 11 + '', // 12 + '```', // 13 + 'code', // 14 + '```', // 15 + '' + ].join('\n') + + function mount(text: string, line: number): { view: EditorView; done: () => void } { + const parent = document.createElement('div') + document.body.append(parent) + const state = EditorState.create({ + doc: text, + extensions: [markdown({ base: markdownLanguage }), headingFolding()] + }) + const view = new EditorView({ + parent, + state: state.update({ selection: { anchor: state.doc.line(line).from } }).state + }) + return { + view, + done: () => { + view.destroy() + parent.remove() + } + } + } + + /** Folded ranges as [first line, last hidden line]. */ + const folds = (view: EditorView): Array<[number, number]> => { + const out: Array<[number, number]> = [] + foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => { + out.push([view.state.doc.lineAt(from).number, view.state.doc.lineAt(to).number]) + }) + return out.sort((a, b) => a[0] - b[0]) + } + + it('puts an arrow beside every item with children and none beside the rest', () => { + const { view, done } = mount(doc, 1) + const arrows = [...view.dom.querySelectorAll('.cm-list-fold-arrow')] + const lines = arrows.map((arrow) => view.state.doc.lineAt(view.posAtDOM(arrow)).number) + expect(lines).toEqual([3, 7, 9]) + done() + }) + + it('gives a hard-wrapped item with nothing under it no arrow', () => { + const { view, done } = mount('- a long item\n that wraps onto a second line\n- next\n', 1) + expect(view.dom.querySelector('.cm-list-fold-arrow')).toBeNull() + done() + }) + + it('folds from the arrow and opens again from it', () => { + const { view, done } = mount(doc, 1) + const arrow = () => view.dom.querySelector('.cm-list-fold-arrow')! + arrow().click() + expect(folds(view)).toEqual([[3, 5]]) + expect(arrow().classList.contains('is-folded')).toBe(true) + expect(arrow().getAttribute('aria-expanded')).toBe('false') + arrow().click() + expect(folds(view)).toEqual([]) + done() + }) + + it('folds the list item on the cursor line, tasks, bullets and numbers alike', () => { + for (const [line, expected] of [ + [3, [3, 5]], + [7, [7, 8]], + [9, [9, 10]] + ] as const) { + const { view, done } = mount(doc, line) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([expected]) + expect(unfoldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([]) + done() + } + }) + + it('from a child line, folds the item it belongs to and moves the caret to its marker', () => { + const { view, done } = mount(doc, 4) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[3, 5]]) + expect(view.state.selection.main.head).toBe(view.state.doc.line(3).from) + done() + }) + + it('still folds a code block from its fence line', () => { + const { view, done } = mount(doc, 13) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[13, 15]]) + done() + }) + + it('folds every heading and list item, nested, so opening the heading shows the items still folded', () => { + const { view, done } = mount(doc, 4) + expect(foldAllOutline(view)).toBe(true) + expect(folds(view)).toEqual([ + [1, 16], + [3, 5], + [7, 8], + [9, 10] + ]) + // The caret moved out of the hidden text, onto the heading's line. + expect(view.state.selection.main.head).toBe(0) + const heading = { from: view.state.doc.line(1).to, to: view.state.doc.line(16).to } + view.dispatch({ effects: unfoldEffect.of(heading) }) + expect(folds(view)).toEqual([ + [3, 5], + [7, 8], + [9, 10] + ]) + done() + }) +}) diff --git a/packages/app-core/src/lib/cm-heading-fold.ts b/packages/app-core/src/lib/cm-heading-fold.ts index 24d6d03e..1fbf32ef 100644 --- a/packages/app-core/src/lib/cm-heading-fold.ts +++ b/packages/app-core/src/lib/cm-heading-fold.ts @@ -1,19 +1,21 @@ /** - * Obsidian-style heading folding for CodeMirror 6 markdown editors. + * Obsidian-style heading and list folding for CodeMirror 6 markdown editors. * * A fold on a heading hides everything from the end of the heading * line up to (but not including) the next heading of equal-or-higher - * level — or the end of the document when none follows. + * level, or the end of the document when none follows. A list item + * with indented children folds the same way (lib/cm-list-fold, #848). * - * The exported extension bundles three pieces: + * The exported extension bundles four pieces: * - `foldService`: the semantic range calculator so CodeMirror's * fold commands (our vim `zc` / `zo` mappings, the `:fold` * ex command) know what to collapse. * - A `ViewPlugin` that adds an inline ▾ arrow to each heading's * line start and a line-decoration class marking the cursor line. - * - No fold gutter — the full-document gutter would show chevrons - * next to every foldable range (lists, code blocks, frontmatter) - * which clutters the minimalist editor surface. + * - The list item arrows, one beside each foldable item's marker. + * - No fold gutter: the full-document gutter would show chevrons + * next to every foldable range (paragraphs, code blocks, + * frontmatter), which clutters the minimalist editor surface. * * CSS rules in styles/index.css hide the arrow by default and only * reveal it when the heading line is hovered or holds the caret, @@ -21,8 +23,11 @@ */ import { codeFolding, + ensureSyntaxTree, + foldable, foldService, foldEffect, + unfoldCode, unfoldEffect, foldedRanges, syntaxTree @@ -37,6 +42,14 @@ import { ViewUpdate, WidgetType } from '@codemirror/view' +import { + allListItemFoldRanges, + enclosingListItem, + foldedExactly, + listItemAtLine, + listItemFoldArrows, + type FoldRange +} from './cm-list-fold' const HEADING_RE = /^(#{1,6})\s+/ @@ -154,36 +167,79 @@ function exactFoldAtRange( state: EditorState, range: { from: number; to: number } ): { from: number; to: number } | null { - let existing: { from: number; to: number } | null = null - foldedRanges(state).between(range.from, range.to, (from, to) => { - if (from === range.from && to === range.to) { - existing = { from, to } - return false - } - return undefined - }) - return existing + return foldedExactly(state, range) } -function headingRangeAtCursor(view: EditorView): { from: number; to: number } | null { - const line = view.state.doc.lineAt(view.state.selection.main.head) - const level = headingLevelAt(view.state, line.number) - return level === null ? null : rangeForHeading(view.state, line.number, level) +/** + * Fold what the cursor is on: the heading or list item its line starts, + * else anything CodeMirror folds from that line (a code block, a table), + * else the list item the line sits in, the way Vim's zc closes the fold + * around the cursor; the caret then moves to that item's marker (#848). + * Mod+Alt+F, zc, :fold and the palette all run this. + */ +export function foldAtCursor(view: EditorView): boolean { + const { state } = view + const head = state.selection.main.head + const line = state.doc.lineAt(head) + const level = headingLevelAt(state, line.number) + let range: FoldRange | null = + level !== null + ? rangeForHeading(state, line.number, level) + : (listItemAtLine(state, line.number)?.range ?? null) + let caret: number | null = null + if (!range) range = foldable(state, line.from, line.to) + if (!range) { + const parent = enclosingListItem(state, head) + if (parent) { + range = parent.range + caret = parent.node.from + } + } + if (!range || exactFoldAtRange(state, range)) return false + view.dispatch({ + effects: foldEffect.of(range), + ...(caret !== null ? { selection: { anchor: caret } } : {}) + }) + return true } -export function foldHeadingAtCursor(view: EditorView): boolean { - const range = headingRangeAtCursor(view) - if (!range || exactFoldAtRange(view.state, range)) return false - view.dispatch({ effects: foldEffect.of(range) }) - return true +/** Unfold the fold that starts on the cursor's line, a heading's or a list + * item's alike. */ +export function unfoldAtCursor(view: EditorView): boolean { + return unfoldCode(view) } -export function unfoldHeadingAtCursor(view: EditorView): boolean { - const range = headingRangeAtCursor(view) - if (!range) return false - const existing = exactFoldAtRange(view.state, range) - if (!existing) return false - view.dispatch({ effects: unfoldEffect.of(existing) }) +/** + * Fold every heading section and every list item with children, nested, + * the way Vim's zM closes every fold: opening a heading then shows its + * lists still folded, one line per item. CodeMirror's foldAll folds only + * the outermost ranges, so a heading swallowed the tasks under it and + * there was no way to see just the task lines (#848). + */ +export function foldAllOutline(view: EditorView): boolean { + const { state } = view + const tree = ensureSyntaxTree(state, state.doc.length, 500) ?? syntaxTree(state) + const ranges: FoldRange[] = [] + for (let n = 1; n <= state.doc.lines; n++) { + const level = headingLevelAt(state, n) + if (level === null) continue + const range = rangeForHeading(state, n, level) + if (range) ranges.push(range) + } + ranges.push(...allListItemFoldRanges(state, tree)) + const fresh = ranges.filter((range) => !exactFoldAtRange(state, range)) + if (fresh.length === 0) return false + // Keep the caret in sight: on the line that opens the outermost fold + // around it, as zM leaves it in Vim. + const head = state.selection.main.head + let outer: FoldRange | null = null + for (const range of ranges) { + if (head > range.from && head <= range.to && (!outer || range.from < outer.from)) outer = range + } + view.dispatch({ + effects: fresh.map((range) => foldEffect.of(range)), + ...(outer ? { selection: { anchor: state.doc.lineAt(outer.from).from } } : {}) + }) return true } @@ -404,6 +460,7 @@ export function headingFolding(options: HeadingFoldingOptions = {}): Extension { codeFolding(), service, headingArrowPlugin(showLevelLabels), + listItemFoldArrows(), // The level chips sit LEFT of the arrow slot, wider than .cm-content's // default 32px padding. The centered column's auto margin cannot host // them: it is zero whenever the pane is narrower than the column cap, diff --git a/packages/app-core/src/lib/cm-list-fold.ts b/packages/app-core/src/lib/cm-list-fold.ts new file mode 100644 index 00000000..255f8c1b --- /dev/null +++ b/packages/app-core/src/lib/cm-list-fold.ts @@ -0,0 +1,226 @@ +/** + * List item folding (#848): a bullet, numbered or task item with indented + * children folds like a heading, from a disclosure arrow beside its marker. + * + * The range is the one @codemirror/lang-markdown already gives a ListItem + * node (the end of the item's first line to the end of the item), so the + * arrow, the fold commands and Vim's `zc` all fold the same span and any of + * them reopens what another closed. Only an item with a child block below + * its first line counts: a nested list, another paragraph, a code block. A + * hard-wrapped item with nothing under it gets no arrow. + * + * CSS in styles/index.css hides the arrow until its line is hovered or holds + * the caret, and keeps it visible while the item is folded. + */ +import { foldEffect, foldedRanges, syntaxTree, unfoldEffect } from '@codemirror/language' +import type { EditorState, Extension } from '@codemirror/state' +import type { SyntaxNode, Tree } from '@lezer/common' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType +} from '@codemirror/view' + +export interface FoldRange { + from: number + to: number +} + +/** A ListItem's fold range when it has a child block below its first line. */ +export function listItemFoldRange(state: EditorState, node: SyntaxNode): FoldRange | null { + if (node.name !== 'ListItem') return null + const firstLineEnd = state.doc.lineAt(node.from).to + let hasChild = false + for (let child = node.firstChild; child; child = child.nextSibling) { + if (child.from > firstLineEnd && child.name !== 'ListMark') { + hasChild = true + break + } + } + if (!hasChild || node.to <= firstLineEnd) return null + return { from: firstLineEnd, to: node.to } +} + +/** The outermost foldable list item whose marker sits on `lineNumber`. */ +export function listItemAtLine( + state: EditorState, + lineNumber: number +): { node: SyntaxNode; range: FoldRange } | null { + if (lineNumber < 1 || lineNumber > state.doc.lines) return null + const line = state.doc.line(lineNumber) + let found: { node: SyntaxNode; range: FoldRange } | null = null + syntaxTree(state).iterate({ + from: line.from, + to: line.to, + enter: (ref) => { + if (found) return false + if (ref.name !== 'ListItem' || ref.from < line.from || ref.from > line.to) return undefined + const range = listItemFoldRange(state, ref.node) + if (range) { + found = { node: ref.node, range } + return false + } + return undefined + } + }) + return found +} + +/** The innermost foldable list item that contains `pos` below its first line. */ +export function enclosingListItem( + state: EditorState, + pos: number +): { node: SyntaxNode; range: FoldRange } | null { + for ( + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); + node; + node = node.parent + ) { + if (node.name !== 'ListItem') continue + const range = listItemFoldRange(state, node) + if (range && pos > range.from && pos <= range.to) return { node, range } + } + return null +} + +/** Every foldable list item in the document, nested ones included. Pass a + * fully parsed tree (ensureSyntaxTree) to reach past the viewport. */ +export function allListItemFoldRanges( + state: EditorState, + tree: Tree = syntaxTree(state) +): FoldRange[] { + const ranges: FoldRange[] = [] + tree.iterate({ + enter: (ref) => { + if (ref.name !== 'ListItem') return + const range = listItemFoldRange(state, ref.node) + if (range) ranges.push(range) + } + }) + return ranges +} + +export function foldedExactly(state: EditorState, range: FoldRange): FoldRange | null { + let existing: FoldRange | null = null + foldedRanges(state).between(range.from, range.to, (from, to) => { + if (from === range.from && to === range.to) { + existing = { from, to } + return false + } + return undefined + }) + return existing +} + +function toggleListItemFold(view: EditorView, markerPos: number): void { + const { state } = view + const item = listItemAtLine(state, state.doc.lineAt(markerPos).number) + if (!item) return + const existing = foldedExactly(state, item.range) + view.dispatch({ effects: existing ? unfoldEffect.of(existing) : foldEffect.of(item.range) }) +} + +class ListFoldArrow extends WidgetType { + constructor( + private readonly markerPos: number, + private readonly folded: boolean + ) { + super() + } + + eq(other: ListFoldArrow): boolean { + return other.markerPos === this.markerPos && other.folded === this.folded + } + + toDOM(view: EditorView): HTMLElement { + // A zero-width anchor at the marker, so the arrow sits just left of the + // bullet, number or checkbox at any depth without shifting the text. + const anchor = document.createElement('span') + anchor.className = 'cm-list-fold-anchor' + const arrow = document.createElement('span') + arrow.className = `cm-list-fold-arrow ${this.folded ? 'is-folded' : 'is-open'}` + arrow.setAttribute('role', 'button') + arrow.setAttribute('aria-label', this.folded ? 'Expand list item' : 'Collapse list item') + arrow.setAttribute('aria-expanded', String(!this.folded)) + arrow.textContent = this.folded ? '▸' : '▾' + // Same contract as the heading arrow: eat the press so CodeMirror does + // not place the caret, and toggle on click. + const swallow = (event: Event): void => { + event.preventDefault() + event.stopPropagation() + } + arrow.addEventListener('mousedown', swallow) + arrow.addEventListener('pointerdown', swallow) + arrow.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + toggleListItemFold(view, this.markerPos) + }) + anchor.appendChild(arrow) + return anchor + } + + ignoreEvent(): boolean { + return true + } +} + +function buildListArrows(view: EditorView): DecorationSet { + const { state } = view + const widgets: { pos: number; deco: Decoration }[] = [] + const seenLines = new Set() + for (const { from, to } of view.visibleRanges) { + syntaxTree(state).iterate({ + from, + to, + enter: (ref) => { + if (ref.name !== 'ListItem' || ref.from < from || ref.from > to) return + const range = listItemFoldRange(state, ref.node) + if (!range) return + // One arrow per line: the outermost item wins a line two share. + const lineNumber = state.doc.lineAt(ref.from).number + if (seenLines.has(lineNumber)) return + seenLines.add(lineNumber) + widgets.push({ + pos: ref.from, + deco: Decoration.widget({ + side: -1, + widget: new ListFoldArrow(ref.from, foldedExactly(state, range) !== null) + }) + }) + } + }) + } + widgets.sort((a, b) => a.pos - b.pos) + return Decoration.set(widgets.map((w) => w.deco.range(w.pos))) +} + +/** The arrows. Fold state itself comes from `codeFolding()` in headingFolding. */ +export function listItemFoldArrows(): Extension { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = buildListArrows(view) + } + + update(update: ViewUpdate): void { + if ( + update.docChanged || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) || + update.transactions.some((tr) => + tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)) + ) + ) { + this.decorations = buildListArrows(update.view) + } + } + }, + { decorations: (plugin) => plugin.decorations } + ) +} diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 947d18e2..fedaf571 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -51,13 +51,20 @@ const APP_REPOSITORY_URL = 'https://github.com/ZenNotes/zennotes' const APP_RELEASES_URL = 'https://github.com/ZenNotes/zennotes/releases/latest' const APP_ISSUES_URL = 'https://github.com/ZenNotes/zennotes/issues' -type FoldCommand = 'foldCode' | 'unfoldCode' | 'foldAll' | 'unfoldAll' +type FoldCommand = 'foldAtCursor' | 'unfoldAtCursor' | 'foldAllOutline' | 'unfoldAll' async function runFoldCommand(command: FoldCommand): Promise { const view = useStore.getState().editorViewRef if (!view) return - const foldModule = await import('@codemirror/language') - foldModule[command](view) + if (command === 'unfoldAll') { + const language = await import('@codemirror/language') + language.unfoldAll(view) + } else { + // Headings and list items alike (#848); loaded on demand like the + // editor itself. + const folding = await import('./cm-heading-fold') + folding[command](view) + } view.focus() } @@ -848,37 +855,37 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'fold.heading', - title: 'Fold Heading at Cursor', + title: 'Fold Heading or List Item at Cursor', category: 'Editor', shortcut: shortcut('vim.foldCurrent'), - keywords: 'collapse fold heading section', + keywords: 'collapse fold heading section list item task bullet children', when: () => !!getState().editorViewRef && !!getState().activeNote, - run: () => runFoldCommand('foldCode') + run: () => runFoldCommand('foldAtCursor') }, { id: 'fold.unfold-heading', - title: 'Unfold Heading at Cursor', + title: 'Unfold Heading or List Item at Cursor', category: 'Editor', shortcut: shortcut('vim.unfoldCurrent'), - keywords: 'expand unfold heading section', + keywords: 'expand unfold heading section list item task bullet children', when: () => !!getState().editorViewRef && !!getState().activeNote, - run: () => runFoldCommand('unfoldCode') + run: () => runFoldCommand('unfoldAtCursor') }, { id: 'fold.all', - title: 'Fold All Headings', + title: 'Fold All Headings and Lists', category: 'Editor', shortcut: shortcut('vim.foldAll'), - keywords: 'collapse fold all every', + keywords: 'collapse fold all every heading list item task outline', when: () => !!getState().editorViewRef && !!getState().activeNote, - run: () => runFoldCommand('foldAll') + run: () => runFoldCommand('foldAllOutline') }, { id: 'fold.unfold-all', - title: 'Unfold All Headings', + title: 'Unfold All Headings and Lists', category: 'Editor', shortcut: shortcut('vim.unfoldAll'), - keywords: 'expand unfold all every reset', + keywords: 'expand unfold all every reset heading list item task outline', when: () => !!getState().editorViewRef && !!getState().activeNote, run: () => runFoldCommand('unfoldAll') }, diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index c4b53cd8..2c059f00 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -360,9 +360,9 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ 'When you type `/` at the start of a line or after whitespace, ZenNotes opens an inline insert menu for common markdown structures such as headings, bulleted or numbered lists, to-do items, callouts, code blocks, dividers, tables, math blocks, links, images, and even creating a new note page.' }, { - title: 'Label and fold headings', + title: 'Label and fold headings and lists', body: - 'Settings → Editor → Writing can show H1 through H6 labels before Markdown headings. Every heading also has a disclosure arrow that folds its section. Click the arrow, use Ctrl+Alt+F and Ctrl+Alt+U (Cmd+Option+F and Cmd+Option+U on macOS), or use zc and zo in Vim mode.' + 'Settings → Editor → Writing can show H1 through H6 labels before Markdown headings. Every heading also has a disclosure arrow that folds its section, and so does every list item with indented lines under it (a bullet, a numbered item or a task): the arrow beside its marker folds those lines, in Edit and in Preview, and the item line stays in view. Click the arrow, use Ctrl+Alt+F and Ctrl+Alt+U (Cmd+Option+F and Cmd+Option+U on macOS), or use zc and zo in Vim mode; on one of the lines under a list item, they fold the item itself. Fold All (zM) folds every heading and every list item, nested, so opening a heading shows its tasks one line each. Folding only hides lines: the note, and the Tasks view, are unchanged.' }, { title: 'Create text replacements', @@ -574,9 +574,9 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space l d', action: 'Convert table to database', detail: 'Turn the Markdown table under the cursor (or the rendered table whose cell you are in) into a `.base` database and leave a `[[link]]` to it in the note. Also “Convert Table to Database…” in the command palette, `:table_to_database`, and the bottom of the table cell menu.' }, { keys: 'Space, then pause', action: 'Show leader hints', detail: 'If enabled in Settings, open a which-key style guide for the next available leader actions. Sticky mode keeps it open until `Space` or `Esc`.' }, { keys: 'Mod+3', action: 'Toggle outline panel', detail: 'Show or hide the persistent outline in the active pane. Once focused (Ctrl+W l or Alt+L from the editor), j / k — or the arrows — walk the headings, gg / G jump to the first and last, Enter jumps the editor to the heading under the cursor, and Esc hands focus back.' }, - { keys: 'zc / zo', action: 'Fold / unfold heading', detail: 'Collapse or expand the section below the heading at the cursor.' }, - { keys: 'Ctrl+Alt+F / U', action: 'Fold / unfold heading', detail: 'Collapse or expand the heading section at the cursor with Vim mode on or off. On macOS, use Cmd+Option+F / U.' }, - { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section in the note.' }, + { keys: 'zc / zo', action: 'Fold / unfold heading or list item', detail: 'Collapse or expand the section below the heading at the cursor, or the lines under the list item at the cursor. On one of those lines, zc folds the item it belongs to.' }, + { keys: 'Ctrl+Alt+F / U', action: 'Fold / unfold, Vim on or off', detail: 'The same as zc / zo, with Vim mode on or off. On macOS, use Cmd+Option+F / U.' }, + { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section and every list item in the note. zM folds them nested, so opening a heading shows its list items still folded.' }, { keys: ']s / [s', action: 'Next / previous Harper suggestion', detail: 'With Grammar and spelling with Harper on, jump the cursor to the next or previous underlined problem, the way Vim walks misspellings.' }, { keys: 'z=', action: 'Harper suggestions', detail: 'Open the fixes for the problem under the cursor (or the first one on its line). A digit or Enter applies one, j/k move, Esc closes.' }, { keys: 'zg', action: 'Add word to Harper dictionary', detail: 'Teach this vault\'s dictionary the word under the cursor. Stored in vault.json, so it travels and syncs with the vault.' }, @@ -1069,13 +1069,13 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ }, { command: ':fold / :unfold', - summary: 'Toggle the heading at the cursor', - detail: 'Collapse or expand the section beneath the heading at the current line. This is the ex-line path to the editor fold and unfold motions.' + summary: 'Toggle the heading or list item at the cursor', + detail: 'Collapse or expand the section beneath the heading at the current line, or the lines under the list item there. This is the ex-line path to the editor fold and unfold motions.' }, { command: ':foldall / :unfoldall', - summary: 'Fold every heading', - detail: 'Collapse or expand every heading section at once. This is the ex-line path to the editor-wide fold motions.' + summary: 'Fold every heading and list item', + detail: 'Collapse or expand every heading section and every list item with lines under it at once, nested. This is the ex-line path to the editor-wide fold motions.' } ] diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 444504cc..d265db52 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -942,8 +942,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Fold heading at cursor", - description: "Collapse the heading section at the cursor.", + title: "Fold heading or list item at cursor", + description: "Collapse the heading section or the list item at the cursor; on a line under a list item, fold that item.", defaultBinding: "z c", vimOnly: true, maxTokens: 2, @@ -953,8 +953,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Unfold heading at cursor", - description: "Expand the heading section at the cursor.", + title: "Unfold heading or list item at cursor", + description: "Expand the heading section or the list item folded at the cursor.", defaultBinding: "z o", vimOnly: true, maxTokens: 2, @@ -964,8 +964,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Fold all headings", - description: "Collapse every heading section in the note.", + title: "Fold all headings and lists", + description: "Collapse every heading section and every list item with lines under it, nested.", defaultBinding: "z M", vimOnly: true, maxTokens: 2, @@ -975,8 +975,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Unfold all headings", - description: "Expand every heading section in the note.", + title: "Unfold all headings and lists", + description: "Expand every fold in the note.", defaultBinding: "z R", vimOnly: true, maxTokens: 2, @@ -1280,8 +1280,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "shortcut", scope: "vim-editor", group: "view-actions", - title: "Fold heading", - description: "Fold the heading section containing the cursor.", + title: "Fold heading or list item", + description: "Fold the heading section or the list item at the cursor; on a line under a list item, fold that item.", defaultBinding: "Mod+Alt+F", }, { @@ -1289,8 +1289,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "shortcut", scope: "vim-editor", group: "view-actions", - title: "Unfold heading", - description: "Unfold the heading section containing the cursor.", + title: "Unfold heading or list item", + description: "Unfold the heading section or the list item folded at the cursor.", defaultBinding: "Mod+Alt+U", }, { diff --git a/packages/app-core/src/lib/preview-list-fold.test.ts b/packages/app-core/src/lib/preview-list-fold.test.ts new file mode 100644 index 00000000..a95338f8 --- /dev/null +++ b/packages/app-core/src/lib/preview-list-fold.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { renderMarkdown } from './markdown' +import { enhancePreviewListFolds } from './preview-list-fold' +import { wrapTaskItemOwnText } from './preview-task-body' + +const md = [ + '- [ ] **Task title** #tag', + ' - detail line', + ' - another detail line', + '- [ ] **Next task** #tag', + '- plain bullet', + ' 1. numbered child', + '1. First step', + ' - step detail', + '2. Second step', + '', + '- loose item', + '', + ' second paragraph', + '', + '- after' +].join('\n') + +function mount(source = md): HTMLElement { + const root = document.createElement('div') + root.innerHTML = renderMarkdown(source) + document.body.append(root) + enhancePreviewListFolds(root) + return root +} + +/** An item's own first line: no arrow, nothing nested. */ +const labelOf = (li: HTMLElement): string => { + const copy = li.cloneNode(true) as HTMLElement + copy.querySelectorAll('.prose-list-fold-arrow, ul, ol').forEach((el) => el.remove()) + return (copy.textContent ?? '').trim().split('\n')[0].trim() +} + +const itemStartingWith = (root: HTMLElement, text: string): HTMLElement => + [...root.querySelectorAll('li')].find((li) => labelOf(li).startsWith(text))! + +const arrowOf = (li: HTMLElement): HTMLElement | null => li.querySelector(':scope > .prose-list-fold-arrow') + +describe('preview list folding (#848)', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('puts an arrow on every item with content under its first line, and on no other', () => { + const root = mount() + const withArrow = [...root.querySelectorAll('li')].filter((li) => arrowOf(li)).map(labelOf) + expect(withArrow).toEqual(['Task title #tag', 'plain bullet', 'First step', 'loose item']) + }) + + it('keeps the arrow out of a task body wrapped before it, as the preview renders', () => { + const root = document.createElement('div') + root.innerHTML = renderMarkdown(md) + document.body.append(root) + root.querySelectorAll('li.task-list-item').forEach((li) => + wrapTaskItemOwnText(li, li.querySelector(':scope > input')) + ) + enhancePreviewListFolds(root) + const task = itemStartingWith(root, 'Task title') + const arrow = arrowOf(task)! + expect(arrow).not.toBeNull() + expect(task.querySelector('.task-item-body')!.contains(arrow)).toBe(false) + arrow.click() + expect(task.querySelector(':scope > ul')!.style.display).toBe('none') + expect(task.querySelector('.task-item-body')!.style.display).toBe('') + }) + + it('leaves a loose task paragraph first, where its checkbox styling expects it', () => { + const root = mount(['- [ ] loose task', '', ' more about it', ''].join('\n')) + const task = root.querySelector('li.task-list-item')! + expect(arrowOf(task)).not.toBeNull() + const [first, second] = [...task.querySelectorAll(':scope > p')] + expect(task.querySelector(':scope > p:first-child')).toBe(first) + arrowOf(task)!.click() + expect(first.style.display).toBe('') + expect(second.style.display).toBe('none') + }) + + it('tells the CSS how wide a numbered item marker is', () => { + const items = Array.from({ length: 10 }, (_, i) => `${i + 1}. step ${i + 1}\n - detail`).join('\n') + const root = mount(items) + const chars = [...root.querySelectorAll('ol > li')].map((li) => + li.style.getPropertyValue('--z-list-marker-chars') + ) + expect(chars).toEqual(['3', '3', '3', '3', '3', '3', '3', '3', '3', '4']) + }) + + it('gives a bullet nested in a numbered item its own marker width', () => { + const root = mount(['1. step', ' - bullet', ' - detail', ''].join('\n')) + const bullet = itemStartingWith(root, 'bullet') + expect(bullet.style.getPropertyValue('--z-list-marker-chars')).toBe('2') + }) + + it('folds the list nested under a task from the arrow and opens it again', () => { + const root = mount() + const task = itemStartingWith(root, 'Task title') + const nested = task.querySelector(':scope > ul')! + const arrow = arrowOf(task)! + arrow.click() + expect(nested.style.display).toBe('none') + expect(task.getAttribute('data-list-folded')).toBe('true') + expect(arrow.classList.contains('is-folded')).toBe(true) + expect(arrow.getAttribute('aria-expanded')).toBe('false') + arrow.click() + expect(nested.style.display).toBe('') + expect(task.getAttribute('data-list-folded')).toBe('false') + }) + + it('keeps the first paragraph of a loose item and folds the rest', () => { + const root = mount() + const loose = itemStartingWith(root, 'loose item') + const [first, second] = [...loose.querySelectorAll(':scope > p')] + arrowOf(loose)!.click() + expect(first.style.display).toBe('') + expect(second.style.display).toBe('none') + }) + + it('stays out of the preview click handling and adds no second arrow', () => { + const root = mount() + const onClick = vi.fn() + root.addEventListener('click', onClick) + arrowOf(itemStartingWith(root, 'plain bullet'))!.click() + expect(onClick).not.toHaveBeenCalled() + enhancePreviewListFolds(root) + expect(root.querySelectorAll('.prose-list-fold-arrow')).toHaveLength(4) + }) +}) diff --git a/packages/app-core/src/lib/preview-list-fold.ts b/packages/app-core/src/lib/preview-list-fold.ts new file mode 100644 index 00000000..2d1ff8c0 --- /dev/null +++ b/packages/app-core/src/lib/preview-list-fold.ts @@ -0,0 +1,109 @@ +/** + * List item folding for the rendered preview (#848), the counterpart of the + * editor's list arrows: an item with block content under its first line (a + * nested list, another paragraph, a code block) gets a ▾ in the gutter that + * hides that content. + * + * Folded state lives in the DOM (`data-list-folded`), the contract the + * preview heading folds keep: a re-render (an edit, a mode switch) opens + * everything again. + * + * The arrow is the item's LAST child and absolutely placed: as the first it + * would break `li.task-list-item > p:first-child`, which seats a loose + * task's text beside its checkbox. + */ + +const ARROW_CLASS = 'prose-list-fold-arrow' +const FOLDABLE_CLASS = 'prose-list-foldable' +const FOLDED_ATTR = 'data-list-folded' + +const BLOCK_TAGS = new Set([ + 'UL', + 'OL', + 'P', + 'PRE', + 'BLOCKQUOTE', + 'TABLE', + 'DIV', + 'FIGURE', + 'DETAILS', + 'HR', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6' +]) + +function isBlock(node: Element): node is HTMLElement { + return node instanceof HTMLElement && BLOCK_TAGS.has(node.tagName) +} + +/** + * What an item folds: every block after the one holding its first line. A + * tight item keeps its text inline (after the checkbox, for a task), so all + * of its blocks fold; a loose item wraps its text in a first `

`, which + * stays. + */ +export function listItemFoldTargets(li: HTMLElement): HTMLElement[] { + const blocks = Array.from(li.children).filter(isBlock) + if (blocks.length === 0) return [] + let inlineFirst = false + for (const node of Array.from(li.childNodes)) { + if (node === blocks[0]) break + if (node instanceof HTMLElement && node.classList.contains(ARROW_CLASS)) continue + if (node.nodeType === Node.TEXT_NODE && !(node.textContent ?? '').trim()) continue + inlineFirst = true + break + } + return inlineFirst || blocks[0].tagName !== 'P' ? blocks : blocks.slice(1) +} + +function setListItemFolded(li: HTMLElement, folded: boolean): void { + for (const el of listItemFoldTargets(li)) el.style.display = folded ? 'none' : '' + li.setAttribute(FOLDED_ATTR, folded ? 'true' : 'false') + const arrow = li.querySelector(`:scope > .${ARROW_CLASS}`) + if (!arrow) return + arrow.classList.toggle('is-folded', folded) + arrow.textContent = folded ? '▸' : '▾' + arrow.setAttribute('aria-expanded', String(!folded)) + arrow.setAttribute('aria-label', folded ? 'Expand list item' : 'Collapse list item') +} + +/** `• ` for a bullet, `N. ` for a number. */ +function markerChars(li: HTMLElement): number { + const list = li.parentElement + if (!(list instanceof HTMLOListElement)) return 2 + const index = Array.from(list.children).filter((el) => el.tagName === 'LI').indexOf(li) + const number = (list.start || 1) + Math.max(index, 0) + return String(Math.abs(number)).length + 2 +} + +export function enhancePreviewListFolds(root: HTMLElement): void { + root.querySelectorAll('li').forEach((li) => { + if (li.querySelector(`:scope > .${ARROW_CLASS}`)) return // idempotent + if (listItemFoldTargets(li).length === 0) return + li.classList.add(FOLDABLE_CLASS) + li.setAttribute(FOLDED_ATTR, 'false') + // The marker renders outside the item, right-aligned, so `10. ` reaches + // further left than `9. `; the CSS seats the arrow by the marker's width + // in characters. Set on every item: a custom property inherits, and a + // bullet nested in a numbered item must not take its parent's width. + li.style.setProperty('--z-list-marker-chars', String(markerChars(li))) + + const arrow = document.createElement('span') + arrow.className = `${ARROW_CLASS} is-open` + arrow.setAttribute('role', 'button') + arrow.setAttribute('aria-label', 'Collapse list item') + arrow.setAttribute('aria-expanded', 'true') + arrow.setAttribute('contenteditable', 'false') + arrow.textContent = '▾' + arrow.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + setListItemFolded(li, li.getAttribute(FOLDED_ATTR) !== 'true') + }) + li.append(arrow) + }) +} diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index a789a8cc..0b205647 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -4859,6 +4859,61 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-accent)); } +/* List item fold arrow (#848): the widget is a zero-width anchor placed at + * the item's marker, so the arrow sits just left of the bullet, number or + * checkbox at any nesting depth and never shifts the text. The anchor is + * boxed and aligned like .cm-task-checkbox, so the arrow centres on the + * checkbox it sits beside. Revealed like the heading arrow: on line hover, + * on the caret line, and while folded. */ +.cm-list-fold-anchor { + position: relative; + display: inline-block; + width: 0; + height: 1.05em; + vertical-align: text-bottom; +} +/* text-indent: the list line's hanging indent is inherited and would push the + * glyph out of its box by the marker's width (six columns for a task). */ +.cm-list-fold-anchor, +.cm-list-fold-arrow { + text-indent: 0; +} +.cm-list-fold-arrow { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 12px; + line-height: 1; + color: theme("colors.ink.400"); + opacity: 0; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: + opacity 120ms ease, + color 120ms ease, + background-color 120ms ease; +} +.cm-editor .cm-line:hover .cm-list-fold-arrow, +.cm-editor .cm-activeLine .cm-list-fold-arrow, +.cm-list-fold-arrow:hover, +.cm-list-fold-arrow.is-folded { + opacity: 1; +} +.cm-list-fold-arrow:hover { + color: theme("colors.ink.900"); + background: rgb(var(--z-bg-2)); +} +.cm-list-fold-arrow.is-folded { + color: rgb(var(--z-accent)); +} + /* The chip gutter must fit inside .cm-content's own box. The fold arrow * (≤28px + ≤10px gap) was sized to live inside the default 32px padding; * the chip needs another 30px beyond it, which only exists as centered- @@ -4940,6 +4995,67 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-accent)); } +/* --- Preview list item fold arrow (#848) --------------------------- */ +/* Just left of the item's marker, as in the editor. A bullet or number + * renders outside the item, right-aligned to it, as `• ` or `N. `; the + * enhancer counts a number's characters, so `10.` steps its arrow further + * left than `9.`. A task's checkbox sits in the item's own padding instead. + * Shown while the item itself is hovered (not one nested under it) and while + * it is folded. */ +@property --z-list-item-em { + syntax: ""; + inherits: true; + initial-value: 16px; +} +@property --z-list-item-ch { + syntax: ""; + inherits: true; + initial-value: 9.6px; +} +.prose-zen li.prose-list-foldable { + position: relative; + /* Registered lengths resolve here, in the item's font: the arrow's own + * smaller font would shrink every offset below along with its glyph. */ + --z-list-item-em: 1em; + --z-list-item-ch: 1ch; +} +.prose-zen .prose-list-fold-arrow { + position: absolute; + left: calc(var(--z-list-marker-chars, 2) * var(--z-list-item-ch) * -1 - 20px); + top: calc(var(--z-list-item-em) * var(--z-editor-line-height, 1.7) / 2 - 8px); + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 12px; + line-height: 1; + color: theme("colors.ink.400"); + opacity: 0; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: + opacity 120ms ease, + color 120ms ease, + background-color 120ms ease; +} +.prose-zen li.task-list-item > .prose-list-fold-arrow { + left: -20px; +} +.prose-zen li.prose-list-foldable:hover:not(:has(li:hover)) > .prose-list-fold-arrow, +.prose-zen .prose-list-fold-arrow:hover, +.prose-zen .prose-list-fold-arrow.is-folded { + opacity: 1; +} +.prose-zen .prose-list-fold-arrow:hover { + color: theme("colors.ink.900"); + background: rgb(var(--z-bg-2)); +} +.prose-zen .prose-list-fold-arrow.is-folded { + color: rgb(var(--z-accent)); +} + /* ===================================================================== WYSIWYG live-preview rendering — ported from PR #185 (author: songgnqing). Rendered tables, blockquote bars, list bullets, horizontal rules, diff --git a/packages/shared-domain/src/demo-tour-data.ts b/packages/shared-domain/src/demo-tour-data.ts index 039aad83..7ce889c6 100644 --- a/packages/shared-domain/src/demo-tour-data.ts +++ b/packages/shared-domain/src/demo-tour-data.ts @@ -46,7 +46,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/09 — Vim Cheat Sheet.md", - body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" + body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading or list item at the cursor |\n| `zo` | Unfold the heading or list item at the cursor |\n| `zM` | Fold all headings and list items |\n| `zR` | Unfold all headings and list items |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading or list item |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading and list item |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" }, { path: "inbox/demo/10 — Ideas and Tasks.md", diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 4dfe2bab..184da08d 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -121,10 +121,10 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.hintMode", group: "vim", defaultBinding: "h", title: "Leader: hint mode" }, { id: "vim.goToDefinition", group: "vim", defaultBinding: "g d", title: "Follow link at cursor" }, { id: "vim.createNoteFromLink", group: "vim", defaultBinding: "g D", title: "Create note for link at cursor" }, - { id: "vim.foldCurrent", group: "vim", defaultBinding: "z c", title: "Fold heading at cursor" }, - { id: "vim.unfoldCurrent", group: "vim", defaultBinding: "z o", title: "Unfold heading at cursor" }, - { id: "vim.foldAll", group: "vim", defaultBinding: "z M", title: "Fold all headings" }, - { id: "vim.unfoldAll", group: "vim", defaultBinding: "z R", title: "Unfold all headings" }, + { id: "vim.foldCurrent", group: "vim", defaultBinding: "z c", title: "Fold heading or list item at cursor" }, + { id: "vim.unfoldCurrent", group: "vim", defaultBinding: "z o", title: "Unfold heading or list item at cursor" }, + { id: "vim.foldAll", group: "vim", defaultBinding: "z M", title: "Fold all headings and lists" }, + { id: "vim.unfoldAll", group: "vim", defaultBinding: "z R", title: "Unfold all headings and lists" }, { id: "vim.harperNext", group: "vim", defaultBinding: "] s", title: "Next Harper suggestion" }, { id: "vim.harperPrevious", group: "vim", defaultBinding: "[ s", title: "Previous Harper suggestion" }, { id: "vim.harperSuggest", group: "vim", defaultBinding: "z =", title: "Harper suggestions at cursor" }, @@ -153,8 +153,8 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "tasks.savedFilters", group: "view-actions", defaultBinding: "F", title: "Pick a saved Tasks filter" }, { id: "editor.hopMarkerForward", group: "view-actions", defaultBinding: "Alt+]", defaultBindingMac: "Ctrl+.", title: "Hop past next marker" }, { id: "editor.hopMarkerBackward", group: "view-actions", defaultBinding: "Alt+[", defaultBindingMac: "Ctrl+,", title: "Hop before previous marker" }, - { id: "editor.foldHeading", group: "view-actions", defaultBinding: "Alt+Mod+F", title: "Fold heading" }, - { id: "editor.unfoldHeading", group: "view-actions", defaultBinding: "Alt+Mod+U", title: "Unfold heading" }, + { id: "editor.foldHeading", group: "view-actions", defaultBinding: "Alt+Mod+F", title: "Fold heading or list item" }, + { id: "editor.unfoldHeading", group: "view-actions", defaultBinding: "Alt+Mod+U", title: "Unfold heading or list item" }, { id: "editor.moveLineUp", group: "view-actions", defaultBinding: "Alt+ArrowUp", title: "Move line up" }, { id: "editor.moveLineDown", group: "view-actions", defaultBinding: "Alt+ArrowDown", title: "Move line down" }, { id: "editor.toggleCheckbox", group: "view-actions", defaultBinding: "Mod+L", title: "Toggle checkbox" }, From 0ef69fc467841c5ce2ac2dc6995790fc97f4d9c6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 13:50:04 -0500 Subject: [PATCH 15/20] Feat(callouts): > [!type]- starts collapsed and + expanded, as in Obsidian (#853) Obsidian marks a foldable callout with - or + right after its type, and vaults moving over use it for long blocks that should stay closed: screenshots, logs, config excerpts. ZenNotes did not know the marker. Preview's callout pattern wanted whitespace right after [!type], so "> [!example]- Screenshots" fell back to a plain quote showing the whole line as text, and Edit mode titled it "- Screenshots". @Sullti asked in discussion #853 for - collapsed, + expanded, no marker as today, and a click on the title row to toggle it, in Edit and Preview. Preview renders a callout with a marker as a

with its title as the : the whole title row toggles it, Tab and Enter work, the browser's find opens a closed one on a match, and Vim's hint mode (Space h) now labels summaries. A callout with nothing under its title has nothing to fold and stays a plain box. DOMPurify's defaults already keep details, summary and open. A PDF cannot be clicked open, so the export window opens every callout before it waits on images and prints. In Edit mode the marker hides with the type, a chevron follows the title (off the caret's line, where the title is source; a click on the title text still edits it), and a folded callout closes its card on the title line. The fold range is the quote below the title line, so the chevron, the fold keys and Fold All close the same span. The fold keys fold a callout from its title line, and a foldable one from any line inside it; Fold All folds foldable callouts along with headings and list items. A collapsed callout folds each time a note opens. That state is a field rather than a view plugin's constructor: EditorPane reconfigures the live preview compartment on a theme or math renderer change, and a constructor would close again what the reader opened. The field keeps its value across a reconfigure and resets only when a note sync replaces the whole document, which is how the editor swaps notes. Two things turned up on the way. The WYSIWYG block walk visits a quote once per visible range, and a fold splits the viewport into ranges, so the chevron came twice; a quote already decorated is now skipped. And a code block's closing fence inside a quote or callout stayed visible in Edit mode, because the fence test expected the fence at the start of the line. Left as it was: an image embed on a quoted line (> ![[shot.png]]) shows as source in Edit mode, as in any quote; Preview draws it. The manual, the MCP instructions and the demo tour's callout note describe the marker. Tests: 10 in cm-callout-fold.test.ts, 5 in markdown.test.ts, 1 in cm-wysiwyg-blocks.test.ts (the quoted fence, failing on the old pattern). --- apps/desktop/src/mcp/instructions.ts | 4 +- apps/desktop/src/renderer/export-window.tsx | 8 + .../app-core/src/components/EditorPane.tsx | 3 + .../app-core/src/components/HintOverlay.tsx | 2 + .../app-core/src/lib/cm-callout-fold.test.ts | 179 +++++++++++++++ packages/app-core/src/lib/cm-callout-fold.ts | 217 ++++++++++++++++++ packages/app-core/src/lib/cm-heading-fold.ts | 27 ++- .../src/lib/cm-wysiwyg-blocks.test.ts | 7 + .../app-core/src/lib/cm-wysiwyg-blocks.ts | 53 +++-- packages/app-core/src/lib/commands.ts | 8 +- packages/app-core/src/lib/help.ts | 14 +- packages/app-core/src/lib/keymaps.ts | 6 +- packages/app-core/src/lib/markdown.test.ts | 33 +++ packages/app-core/src/lib/markdown.ts | 24 +- packages/app-core/src/styles/index.css | 45 ++++ packages/shared-domain/src/demo-tour-data.ts | 2 +- 16 files changed, 587 insertions(+), 45 deletions(-) create mode 100644 packages/app-core/src/lib/cm-callout-fold.test.ts create mode 100644 packages/app-core/src/lib/cm-callout-fold.ts diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index aab786c5..10a41e8d 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -194,7 +194,9 @@ when the folder is \`Linear Algebra/\`), synonyms, and feeling tags \`due:YYYY-MM-DD\`, \`!high\` / \`!med\` / \`!low\`, \`@waiting\`, and \`#tag\`. -**Callouts**: \`> [!note]\`, \`> [!tip]\`, \`> [!warning]\`. +**Callouts**: \`> [!note]\`, \`> [!tip]\`, \`> [!warning]\`. A \`-\` right after the +type (\`> [!example]- Screenshots\`) makes the callout start collapsed, a \`+\` +starts it expanded; use \`-\` for long blocks a reader opens on demand. ## Tool etiquette diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx index bc053635..529302ac 100644 --- a/apps/desktop/src/renderer/export-window.tsx +++ b/apps/desktop/src/renderer/export-window.tsx @@ -460,6 +460,14 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { markdown={withExportTitle(note.body, note.title).markdown} notePath={note.path} onRendered={() => { + // A PDF cannot be clicked open: print collapsed callouts open, + // before the image wait below, since their images load only + // once they show (#853). + document + .querySelectorAll('[data-preview-content] details.callout') + .forEach((callout) => { + callout.open = true + }) // Images load after the DOM is in place, and the preview defers // the ones below the viewport; print only once they have all // settled (#769). diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 220576f6..65c6d150 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -108,6 +108,7 @@ import { livePreviewPlugin } from '../lib/cm-live-preview' import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair' import { tablePlugin, tableVimEntry } from '../lib/cm-table' import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' +import { calloutFolding } from '../lib/cm-callout-fold' import { hashtagExtension } from '../lib/cm-hashtags' import { taskMetadataExtension } from '../lib/cm-task-metadata' import { liveTemplateTokenExtension } from '../lib/cm-live-template-tokens' @@ -479,6 +480,8 @@ function wysiwygExtensions( // markdown for full keyboard/Vim editing (#232). ...(renderTables ? [tablePlugin, tableVimEntry] : []), wysiwygBlocksPlugin, + // Collapsed callouts (`> [!type]-`) start folded when a note opens (#853). + calloutFolding(), ...hashtagExtension, ...taskMetadataExtension, ...taskRollupExtension, diff --git a/packages/app-core/src/components/HintOverlay.tsx b/packages/app-core/src/components/HintOverlay.tsx index c1bd308a..f4f2d89d 100644 --- a/packages/app-core/src/components/HintOverlay.tsx +++ b/packages/app-core/src/components/HintOverlay.tsx @@ -14,6 +14,8 @@ function getVisibleInteractiveElements(): HTMLElement[] { const selectors = [ 'button:not([disabled])', 'a[href]', + // A
toggle, such as a foldable callout's title in Preview (#853). + 'summary', '[role="button"]', '[role="link"]', 'input:not([disabled])', diff --git a/packages/app-core/src/lib/cm-callout-fold.test.ts b/packages/app-core/src/lib/cm-callout-fold.test.ts new file mode 100644 index 00000000..fdd59b31 --- /dev/null +++ b/packages/app-core/src/lib/cm-callout-fold.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing, foldedRanges } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, describe, expect, it } from 'vitest' +import { calloutFolding } from './cm-callout-fold' +import { foldAllOutline, foldAtCursor, headingFolding } from './cm-heading-fold' +import { wysiwygBlocksPlugin } from './cm-wysiwyg-blocks' +import { noteEditingSync } from './note-lifecycle-lock' + +const DOC = [ + '# Launch', // 1 + '', // 2 + '> [!example]- Screenshots', // 3 + '> first shot', // 4 + '>', // 5 + '> second shot', // 6 + '', // 7 + '> [!tip]+ Open by default', // 8 + '> tip body', // 9 + '', // 10 + '> [!note] Plain', // 11 + '> plain body', // 12 + '', // 13 + 'End.' // 14 +].join('\n') + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +let views: EditorView[] = [] +afterEach(() => { + for (const view of views) view.destroy() + views = [] + document.body.innerHTML = '' +}) + +function mount(doc = DOC, { caretLine = 14, livePreview = new Compartment() } = {}): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const state = EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage }), + headingFolding(), + livePreview.of([wysiwygBlocksPlugin, calloutFolding()]) + ] + }) + const view = new EditorView({ + parent, + state: state.update({ selection: { anchor: state.doc.line(Math.min(caretLine, state.doc.lines)).from } }).state + }) + forceParsing(view, doc.length, 5000) + views.push(view) + return view +} + +/** Folded ranges as [first line, last line] pairs. */ +function folds(view: EditorView): [number, number][] { + const out: [number, number][] = [] + foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => { + out.push([view.state.doc.lineAt(from).number, view.state.doc.lineAt(to).number]) + }) + return out +} + +describe('foldable callouts in the editor (#853)', () => { + it('folds a collapsed callout when the note opens, and leaves + and plain callouts open', async () => { + const view = mount() + await settle() + expect(folds(view)).toEqual([[3, 6]]) + }) + + it('hides the fold marker and puts a chevron after a foldable title only', async () => { + const view = mount() + await settle() + const text = view.dom.textContent ?? '' + expect(text).not.toContain('[!example]-') + expect(text).not.toContain('[!tip]+') + expect(text).toContain('Screenshots') + const chevrons = [...view.dom.querySelectorAll('.cm-callout-fold')] + expect(chevrons.map((c) => c.textContent)).toEqual(['▸', '▾']) + expect(chevrons[0].getAttribute('aria-expanded')).toBe('false') + // Folded, the title line closes the card on its own. + const head = view.dom.querySelector('.cm-callout-question.cm-callout-head')! + expect(head.classList.contains('cm-callout-foot')).toBe(true) + }) + + it('toggles from the chevron', async () => { + const view = mount() + await settle() + const chevron = () => view.dom.querySelectorAll('.cm-callout-fold') + chevron()[0].click() + expect(folds(view)).toEqual([]) + chevron()[1].click() + expect(folds(view)).toEqual([[8, 9]]) + chevron()[1].click() + expect(folds(view)).toEqual([]) + }) + + it('shows no chevron on the line holding the caret, where the title is source', async () => { + const view = mount(DOC, { caretLine: 8 }) + await settle() + expect(view.dom.querySelectorAll('.cm-callout-fold')).toHaveLength(1) + expect(view.dom.textContent).toContain('[!tip]+') + }) + + it('does not close again what the reader opened when live preview is reconfigured', async () => { + const livePreview = new Compartment() + const view = mount(DOC, { livePreview }) + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + expect(folds(view)).toEqual([]) + // A theme switch rebuilds the live-preview extensions. + view.dispatch({ effects: livePreview.reconfigure([wysiwygBlocksPlugin, calloutFolding()]) }) + await settle() + expect(folds(view)).toEqual([]) + }) + + it('folds the next note’s collapsed callouts after a note swap, and not after typing', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + // Typing a marker into a title does not fold it. + const tip = view.state.doc.line(8) + view.dispatch({ changes: { from: tip.from + '> [!tip]'.length, to: tip.from + '> [!tip]+'.length, insert: '-' } }) + await settle() + expect(folds(view)).toEqual([]) + // The editor swaps in another note: a whole-document replace under + // noteEditingSync, as EditorPane does. + const next = '> [!warning]- Later\n> hidden\n\ntext' + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + annotations: noteEditingSync.of(true), + selection: { anchor: next.length } + }) + await settle() + expect(folds(view)).toEqual([[1, 2]]) + }) + + it('folds a callout from its title line with the fold keys, the whole body', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 4 } }) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[3, 6]]) + }) + + it('folds a foldable callout from a line inside it and moves the caret to its title', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + view.dispatch({ selection: { anchor: view.state.doc.line(6).from + 4 } }) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[3, 6]]) + expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(3) + }) + + it('leaves a plain callout alone from a line inside it', async () => { + const view = mount() + await settle() + view.dispatch({ selection: { anchor: view.state.doc.line(12).from + 4 } }) + expect(foldAtCursor(view)).toBe(false) + }) + + it('includes foldable callouts in Fold All', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + expect(foldAllOutline(view)).toBe(true) + expect(folds(view)).toEqual([ + [1, 14], + [3, 6], + [8, 9] + ]) + }) +}) diff --git a/packages/app-core/src/lib/cm-callout-fold.ts b/packages/app-core/src/lib/cm-callout-fold.ts new file mode 100644 index 00000000..253de367 --- /dev/null +++ b/packages/app-core/src/lib/cm-callout-fold.ts @@ -0,0 +1,217 @@ +/** + * Foldable callouts (#853), as in Obsidian: `> [!type]-` starts collapsed and + * `> [!type]+` expanded; a callout with no marker is not foldable by click. + * + * The fold range is the callout's whole blockquote below its title line, so + * the chevron, the fold keys and Fold All close the same span and any of them + * reopens what another closed. Collapsed callouts fold once each time a note + * opens in the editor. That rides a state field which a note swap resets (a + * whole-document replace under `noteEditingSync`) and which outlives the + * live-preview compartment's reconfigures: a theme switch must not close + * again what the reader opened. + */ +import { ensureSyntaxTree, foldEffect, syntaxTree, unfoldEffect } from '@codemirror/language' +import { + type EditorState, + type Extension, + StateEffect, + StateField, + type Transaction +} from '@codemirror/state' +import { type EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view' +import type { SyntaxNode, Tree } from '@lezer/common' +import { type FoldRange, foldedExactly } from './cm-list-fold' +import { noteEditingSync } from './note-lifecycle-lock' + +/** A callout's title line: `> [!type]`, an optional fold marker, the title. */ +export const CALLOUT_HEAD_RE = /^(\s*>\s?)\[!(\w+)\]([-+])?\s?(.*)$/ + +export type CalloutFoldMarker = '-' | '+' + +export interface Callout { + node: SyntaxNode + /** `-` collapsed by default, `+` expanded, null for a plain callout. */ + marker: CalloutFoldMarker | null + /** Everything below the title line; null when there is nothing below. */ + range: FoldRange | null +} + +function calloutOf(state: EditorState, node: SyntaxNode): Callout | null { + if (node.name !== 'Blockquote') return null + const head = state.doc.lineAt(node.from) + const match = head.text.match(CALLOUT_HEAD_RE) + if (!match) return null + return { + node, + marker: (match[3] as CalloutFoldMarker | undefined) ?? null, + range: node.to > head.to ? { from: head.to, to: node.to } : null + } +} + +/** The callout whose title is on `lineNumber`, marker or not. */ +export function calloutAtLine(state: EditorState, lineNumber: number): Callout | null { + if (lineNumber < 1 || lineNumber > state.doc.lines) return null + const line = state.doc.line(lineNumber) + let found: Callout | null = null + syntaxTree(state).iterate({ + from: line.from, + to: line.to, + enter: (ref) => { + if (found) return false + if (ref.name !== 'Blockquote' || ref.from < line.from || ref.from > line.to) return undefined + found = calloutOf(state, ref.node) + return false + } + }) + return found +} + +/** The innermost foldable callout (one with a marker) holding `pos` below its + * title line. */ +export function enclosingFoldableCallout(state: EditorState, pos: number): Callout | null { + for ( + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); + node; + node = node.parent + ) { + const callout = calloutOf(state, node) + if (callout?.marker && callout.range && pos > callout.range.from && pos <= callout.range.to) { + return callout + } + } + return null +} + +/** Fold ranges of every callout with a marker, or only the collapsed ones. + * Pass a fully parsed tree (ensureSyntaxTree) to reach past the viewport. */ +export function foldableCalloutRanges( + state: EditorState, + tree: Tree = syntaxTree(state), + only?: CalloutFoldMarker +): FoldRange[] { + const ranges: FoldRange[] = [] + tree.iterate({ + enter: (ref) => { + if (ref.name !== 'Blockquote') return undefined + const callout = calloutOf(state, ref.node) + if (callout?.marker && callout.range && (!only || callout.marker === only)) { + ranges.push(callout.range) + } + return undefined + } + }) + return ranges +} + +/** Toggle the callout whose title line holds `pos`. */ +export function toggleCalloutFold(view: EditorView, pos: number): boolean { + const { state } = view + const callout = calloutAtLine(state, state.doc.lineAt(pos).number) + if (!callout?.range) return false + const existing = foldedExactly(state, callout.range) + view.dispatch({ effects: existing ? unfoldEffect.of(existing) : foldEffect.of(callout.range) }) + return true +} + +/** + * The chevron after a foldable callout's title: ▾ open, ▸ folded. Clicking it + * toggles the callout; the press is eaten so the caret stays where it was and + * the title line does not turn into source under the pointer. + */ +export class CalloutFoldChevron extends WidgetType { + constructor(private readonly folded: boolean) { + super() + } + + eq(other: CalloutFoldChevron): boolean { + return other.folded === this.folded + } + + toDOM(view: EditorView): HTMLElement { + const chevron = document.createElement('span') + chevron.className = `cm-callout-fold ${this.folded ? 'is-folded' : 'is-open'}` + chevron.setAttribute('role', 'button') + chevron.setAttribute('aria-label', this.folded ? 'Expand callout' : 'Collapse callout') + chevron.setAttribute('aria-expanded', String(!this.folded)) + chevron.textContent = this.folded ? '▸' : '▾' + const swallow = (event: Event): void => { + event.preventDefault() + event.stopPropagation() + } + chevron.addEventListener('mousedown', swallow) + chevron.addEventListener('pointerdown', swallow) + chevron.addEventListener('click', (event) => { + swallow(event) + toggleCalloutFold(view, view.posAtDOM(chevron)) + }) + return chevron + } + + ignoreEvent(): boolean { + return true + } +} + +const markCalloutDefaultsApplied = StateEffect.define() + +function replacesWholeDocument(tr: Transaction): boolean { + let whole = false + tr.changes.iterChangedRanges((fromA, toA) => { + if (fromA === 0 && toA === tr.startState.doc.length) whole = true + }) + return whole +} + +/** Whether this document's collapsed callouts have been folded yet. */ +const calloutDefaultsApplied = StateField.define({ + create: () => false, + update(applied, tr) { + if (tr.effects.some((effect) => effect.is(markCalloutDefaultsApplied))) return true + // Another note swapped in: its collapsed callouts start folded too. + if (applied && tr.docChanged && tr.annotation(noteEditingSync) && replacesWholeDocument(tr)) { + return false + } + return applied + } +}) + +const applyCalloutDefaults = ViewPlugin.fromClass( + class { + private scheduled = false + private destroyed = false + + constructor(view: EditorView) { + this.check(view) + } + + update(update: ViewUpdate): void { + this.check(update.view) + } + + destroy(): void { + this.destroyed = true + } + + private check(view: EditorView): void { + if (this.scheduled || view.state.field(calloutDefaultsApplied, false) !== false) return + this.scheduled = true + // Not from inside a view update: CodeMirror refuses a dispatch there. + queueMicrotask(() => { + this.scheduled = false + const { state } = view + if (this.destroyed || state.field(calloutDefaultsApplied, false) !== false) return + const tree = ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state) + const folds = foldableCalloutRanges(state, tree, '-') + .filter((range) => !foldedExactly(state, range)) + .map((range) => foldEffect.of(range)) + view.dispatch({ effects: [...folds, markCalloutDefaultsApplied.of(null)] }) + }) + } + } +) + +/** Collapsed callouts start folded when a note opens. Live preview only, with + * the chevrons in `cm-wysiwyg-blocks.ts`; folding itself is `codeFolding()`. */ +export function calloutFolding(): Extension { + return [calloutDefaultsApplied, applyCalloutDefaults] +} diff --git a/packages/app-core/src/lib/cm-heading-fold.ts b/packages/app-core/src/lib/cm-heading-fold.ts index 1fbf32ef..7a45420e 100644 --- a/packages/app-core/src/lib/cm-heading-fold.ts +++ b/packages/app-core/src/lib/cm-heading-fold.ts @@ -4,7 +4,8 @@ * A fold on a heading hides everything from the end of the heading * line up to (but not including) the next heading of equal-or-higher * level, or the end of the document when none follows. A list item - * with indented children folds the same way (lib/cm-list-fold, #848). + * with indented children folds the same way (lib/cm-list-fold, #848), and + * so does a callout below its title line (lib/cm-callout-fold, #853). * * The exported extension bundles four pieces: * - `foldService`: the semantic range calculator so CodeMirror's @@ -50,6 +51,7 @@ import { listItemFoldArrows, type FoldRange } from './cm-list-fold' +import { calloutAtLine, enclosingFoldableCallout, foldableCalloutRanges } from './cm-callout-fold' const HEADING_RE = /^(#{1,6})\s+/ @@ -185,11 +187,18 @@ export function foldAtCursor(view: EditorView): boolean { let range: FoldRange | null = level !== null ? rangeForHeading(state, line.number, level) - : (listItemAtLine(state, line.number)?.range ?? null) + : (listItemAtLine(state, line.number)?.range ?? + calloutAtLine(state, line.number)?.range ?? + null) let caret: number | null = null if (!range) range = foldable(state, line.from, line.to) if (!range) { - const parent = enclosingListItem(state, head) + // On a line inside a list item or a foldable callout, fold the innermost + // of the two and bring the caret up to its first line. + const item = enclosingListItem(state, head) + const found = enclosingFoldableCallout(state, head) + const callout = found?.range ? { node: found.node, range: found.range } : null + const parent = item && callout ? (item.range.from >= callout.range.from ? item : callout) : (item ?? callout) if (parent) { range = parent.range caret = parent.node.from @@ -210,11 +219,11 @@ export function unfoldAtCursor(view: EditorView): boolean { } /** - * Fold every heading section and every list item with children, nested, - * the way Vim's zM closes every fold: opening a heading then shows its - * lists still folded, one line per item. CodeMirror's foldAll folds only - * the outermost ranges, so a heading swallowed the tasks under it and - * there was no way to see just the task lines (#848). + * Fold every heading section, every list item with children and every + * foldable callout, nested, the way Vim's zM closes every fold: opening a + * heading then shows its lists still folded, one line per item. CodeMirror's + * foldAll folds only the outermost ranges, so a heading swallowed the tasks + * under it and there was no way to see just the task lines (#848). */ export function foldAllOutline(view: EditorView): boolean { const { state } = view @@ -226,7 +235,7 @@ export function foldAllOutline(view: EditorView): boolean { const range = rangeForHeading(state, n, level) if (range) ranges.push(range) } - ranges.push(...allListItemFoldRanges(state, tree)) + ranges.push(...allListItemFoldRanges(state, tree), ...foldableCalloutRanges(state, tree)) const fresh = ranges.filter((range) => !exactFoldAtRange(state, range)) if (fresh.length === 0) return false // Keep the caret in sight: on the line that opens the outermost fold diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts index a73f9e4b..3687974a 100644 --- a/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts @@ -53,6 +53,13 @@ describe('wysiwygBlocksPlugin', () => { view.destroy() }) + it('hides a code block\'s closing fence inside a callout too', () => { + const view = mount('# T\n\n> [!warning] Log\n> ```\n> fetch: done\n> ```\n\nEnd.') + expect(view.dom.textContent).toContain('fetch: done') + expect(view.dom.textContent).not.toContain('```') + view.destroy() + }) + it('reveals the raw list marker on the active line', () => { const view = mount(DOC) const firstItem = DOC.indexOf('- first') diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts index fdd2bb9c..000f4701 100644 --- a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts @@ -6,7 +6,7 @@ * * WYSIWYG-only: registered via `wysiwygExtensions()`; never loads in Split. */ -import { syntaxTree } from '@codemirror/language' +import { foldEffect, syntaxTree, unfoldEffect } from '@codemirror/language' import { RangeSetBuilder } from '@codemirror/state' import { Decoration, @@ -17,6 +17,8 @@ import { WidgetType } from '@codemirror/view' import { calloutGroupFor } from './callout-types' +import { CALLOUT_HEAD_RE, CalloutFoldChevron } from './cm-callout-fold' +import { foldedExactly } from './cm-list-fold' const quoteLine = Decoration.line({ class: 'cm-wq-quote' }) @@ -57,9 +59,6 @@ const hrRule = Decoration.replace({ widget: new HrWidget() }) * line + its card styling) when the cursor is outside the code block. */ const hideInline = Decoration.replace({}) -/** Header of an Obsidian-style callout: `> [!type] optional title`. */ -const CALLOUT_RE = /^(\s*>\s?)\[!(\w+)\]\s?(.*)$/ - /** Collapse callout type aliases onto one color group, mirroring the Preview * renderer (markdown.ts) via the shared `callout-types` table. */ const calloutGroup = calloutGroupFor @@ -120,20 +119,28 @@ function buildDecorations(view: EditorView): DecorationSet { enter: (node) => { if (node.name === 'Blockquote') { const first = state.doc.lineAt(node.from).number + // A fold splits the viewport into ranges and the tree walk meets a + // quote once per range: its line classes are guarded by + // `quotedLines`, and its title decorations must not come twice. + if (quotedLines.has(first)) return const last = state.doc.lineAt(Math.max(node.from, node.to - 1)).number const firstLine = state.doc.line(first) - const callout = firstLine.text.match(CALLOUT_RE) + const callout = firstLine.text.match(CALLOUT_HEAD_RE) if (callout) { // Obsidian-style callout: a colored card with a typed title. Tag - // each line for the box (head/foot round the top/bottom). + // each line for the box (head/foot round the top/bottom). A folded + // callout is its title line alone, so that line closes the box. const group = calloutGroup(callout[2]) + const marker = callout[3] ?? '' + const range = node.to > firstLine.to ? { from: firstLine.to, to: node.to } : null + const folded = range !== null && foldedExactly(state, range) !== null for (let n = first; n <= last; n++) { if (quotedLines.has(n)) continue quotedLines.add(n) const ln = state.doc.line(n) let cls = `cm-callout cm-callout-${group}` if (n === first) cls += ' cm-callout-head' - if (n === last) cls += ' cm-callout-foot' + if (n === last || (n === first && folded)) cls += ' cm-callout-foot' pending.push({ from: ln.from, to: ln.from, @@ -141,12 +148,22 @@ function buildDecorations(view: EditorView): DecorationSet { line: true }) } - // Header: render the title. Off the line, hide the `[!type]` token — - // the custom title stays (styled), or we show the type name. + // Header: render the title. Off the line, hide the `[!type]` token + // and its fold marker; the custom title stays (styled), or we show + // the type name. A foldable callout (#853) gets its chevron after + // the title. if (!active.has(first)) { const bStart = firstLine.from + callout[1].length - const bEnd = bStart + `[!${callout[2]}]`.length - if (callout[3].trim()) { + const bEnd = bStart + `[!${callout[2]}]${marker}`.length + if (marker && range) { + pending.push({ + from: firstLine.to, + to: firstLine.to, + deco: Decoration.widget({ widget: new CalloutFoldChevron(folded), side: -1 }), + line: false + }) + } + if (callout[4].trim()) { let to = bEnd if (state.doc.sliceString(to, to + 1) === ' ') to += 1 pending.push({ from: bStart, to, deco: hideInline, line: false }) @@ -196,8 +213,9 @@ function buildDecorations(view: EditorView): DecorationSet { pending.push({ from: firstLine.from, to: firstLine.to, deco: hideInline, line: false }) } // Only hide the last line if it's actually a closing fence — an - // unclosed block at EOF ends on a content line we must keep. - const closesWithFence = /^\s*(?:`{3,}|~{3,})\s*$/.test(lastLine.text) + // unclosed block at EOF ends on a content line we must keep. In a + // quote or callout the fence carries the `>` markers too. + const closesWithFence = /^\s*(?:>\s?)*(?:`{3,}|~{3,})\s*$/.test(lastLine.text) if ( closesWithFence && lastLine.number !== firstLine.number && @@ -248,7 +266,14 @@ export const wysiwygBlocksPlugin = ViewPlugin.fromClass( this.decorations = buildDecorations(view) } update(update: ViewUpdate): void { - if (update.docChanged || update.selectionSet || update.viewportChanged) { + if ( + update.docChanged || + update.selectionSet || + update.viewportChanged || + update.transactions.some((tr) => + tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)) + ) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index fedaf571..2ff18ec9 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -858,7 +858,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Fold Heading or List Item at Cursor', category: 'Editor', shortcut: shortcut('vim.foldCurrent'), - keywords: 'collapse fold heading section list item task bullet children', + keywords: 'collapse fold heading section list item task bullet children callout', when: () => !!getState().editorViewRef && !!getState().activeNote, run: () => runFoldCommand('foldAtCursor') }, @@ -867,7 +867,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Unfold Heading or List Item at Cursor', category: 'Editor', shortcut: shortcut('vim.unfoldCurrent'), - keywords: 'expand unfold heading section list item task bullet children', + keywords: 'expand unfold heading section list item task bullet children callout', when: () => !!getState().editorViewRef && !!getState().activeNote, run: () => runFoldCommand('unfoldAtCursor') }, @@ -876,7 +876,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Fold All Headings and Lists', category: 'Editor', shortcut: shortcut('vim.foldAll'), - keywords: 'collapse fold all every heading list item task outline', + keywords: 'collapse fold all every heading list item task outline callout', when: () => !!getState().editorViewRef && !!getState().activeNote, run: () => runFoldCommand('foldAllOutline') }, @@ -885,7 +885,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Unfold All Headings and Lists', category: 'Editor', shortcut: shortcut('vim.unfoldAll'), - keywords: 'expand unfold all every reset heading list item task outline', + keywords: 'expand unfold all every reset heading list item task outline callout', when: () => !!getState().editorViewRef && !!getState().activeNote, run: () => runFoldCommand('unfoldAll') }, diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 2c059f00..5ab4c081 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -377,7 +377,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Callouts highlight the important bits', body: - 'Turn a blockquote into a colored callout (an Obsidian-style admonition) by starting its first line with `> [!type]`, optionally followed by a title: `> [!warning] Heads up`. Typing `[!` inside a blockquote opens an insert menu of the callout types — filter by name (aliases match too, so `warn` finds Warning and `tldr` finds Abstract), move with the arrow keys or the Vim/Emacs completion chords (Ctrl+J / Ctrl+K, Ctrl+N / Ctrl+P), and press Enter, Tab, Ctrl+Y, or click to drop in the syntax. The type sets the color: `note`, `info`, `abstract` / `summary` / `tldr` render blue; `tip` / `hint` / `important` and `success` / `check` / `done` green; `question` / `help` / `faq` and `example` purple; `warning` / `caution` / `attention` yellow; `danger` / `error`, `bug`, and `failure` / `fail` red; and `quote` / `cite` a neutral gray. Types are case-insensitive, and an unrecognized one still renders as a neutral note, so callouts pasted in from Obsidian keep working.' + 'Turn a blockquote into a colored callout (an Obsidian-style admonition) by starting its first line with `> [!type]`, optionally followed by a title: `> [!warning] Heads up`. Typing `[!` inside a blockquote opens an insert menu of the callout types — filter by name (aliases match too, so `warn` finds Warning and `tldr` finds Abstract), move with the arrow keys or the Vim/Emacs completion chords (Ctrl+J / Ctrl+K, Ctrl+N / Ctrl+P), and press Enter, Tab, Ctrl+Y, or click to drop in the syntax. The type sets the color: `note`, `info`, `abstract` / `summary` / `tldr` render blue; `tip` / `hint` / `important` and `success` / `check` / `done` green; `question` / `help` / `faq` and `example` purple; `warning` / `caution` / `attention` yellow; `danger` / `error`, `bug`, and `failure` / `fail` red; and `quote` / `cite` a neutral gray. Types are case-insensitive, and an unrecognized one still renders as a neutral note, so callouts pasted in from Obsidian keep working. Put `-` or `+` right after the type to make a callout foldable, as in Obsidian: `> [!example]- Screenshots` starts collapsed and `> [!example]+ Screenshots` starts expanded. Click the title row in Preview, or the chevron after the title in Edit mode, to open or close it; the fold keys (zc and zo, or Ctrl+Alt+F and Ctrl+Alt+U, Cmd+Option+F and Cmd+Option+U on macOS) work on it from its title line or any line inside it, and Fold All (zM) includes it. A collapsed callout starts closed each time the note opens and prints open in a PDF export; folding never changes the note.' }, { title: '@ inserts dates and links notes', @@ -574,9 +574,9 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space l d', action: 'Convert table to database', detail: 'Turn the Markdown table under the cursor (or the rendered table whose cell you are in) into a `.base` database and leave a `[[link]]` to it in the note. Also “Convert Table to Database…” in the command palette, `:table_to_database`, and the bottom of the table cell menu.' }, { keys: 'Space, then pause', action: 'Show leader hints', detail: 'If enabled in Settings, open a which-key style guide for the next available leader actions. Sticky mode keeps it open until `Space` or `Esc`.' }, { keys: 'Mod+3', action: 'Toggle outline panel', detail: 'Show or hide the persistent outline in the active pane. Once focused (Ctrl+W l or Alt+L from the editor), j / k — or the arrows — walk the headings, gg / G jump to the first and last, Enter jumps the editor to the heading under the cursor, and Esc hands focus back.' }, - { keys: 'zc / zo', action: 'Fold / unfold heading or list item', detail: 'Collapse or expand the section below the heading at the cursor, or the lines under the list item at the cursor. On one of those lines, zc folds the item it belongs to.' }, + { keys: 'zc / zo', action: 'Fold / unfold heading or list item', detail: 'Collapse or expand the section below the heading at the cursor, the lines under the list item at the cursor, or the callout whose title it is on. On a line under a list item or inside a foldable callout, zc folds that.' }, { keys: 'Ctrl+Alt+F / U', action: 'Fold / unfold, Vim on or off', detail: 'The same as zc / zo, with Vim mode on or off. On macOS, use Cmd+Option+F / U.' }, - { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section and every list item in the note. zM folds them nested, so opening a heading shows its list items still folded.' }, + { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section, every list item and every foldable callout in the note. zM folds them nested, so opening a heading shows its list items still folded.' }, { keys: ']s / [s', action: 'Next / previous Harper suggestion', detail: 'With Grammar and spelling with Harper on, jump the cursor to the next or previous underlined problem, the way Vim walks misspellings.' }, { keys: 'z=', action: 'Harper suggestions', detail: 'Open the fixes for the problem under the cursor (or the first one on its line). A digit or Enter applies one, j/k move, Esc closes.' }, { keys: 'zg', action: 'Add word to Harper dictionary', detail: 'Teach this vault\'s dictionary the word under the cursor. Stored in vault.json, so it travels and syncs with the vault.' }, @@ -1069,13 +1069,13 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ }, { command: ':fold / :unfold', - summary: 'Toggle the heading or list item at the cursor', - detail: 'Collapse or expand the section beneath the heading at the current line, or the lines under the list item there. This is the ex-line path to the editor fold and unfold motions.' + summary: 'Toggle the heading, list item or callout at the cursor', + detail: 'Collapse or expand the section beneath the heading at the current line, the lines under the list item there, or the callout whose title it is. This is the ex-line path to the editor fold and unfold motions.' }, { command: ':foldall / :unfoldall', - summary: 'Fold every heading and list item', - detail: 'Collapse or expand every heading section and every list item with lines under it at once, nested. This is the ex-line path to the editor-wide fold motions.' + summary: 'Fold every heading, list item and foldable callout', + detail: 'Collapse or expand every heading section, every list item with lines under it and every foldable callout at once, nested. This is the ex-line path to the editor-wide fold motions.' } ] diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index d265db52..5831fbcc 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -943,7 +943,7 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ scope: "vim-editor", group: "vim", title: "Fold heading or list item at cursor", - description: "Collapse the heading section or the list item at the cursor; on a line under a list item, fold that item.", + description: "Collapse the heading section, the list item or the callout at the cursor; on a line under a list item or inside a foldable callout, fold that.", defaultBinding: "z c", vimOnly: true, maxTokens: 2, @@ -965,7 +965,7 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ scope: "vim-editor", group: "vim", title: "Fold all headings and lists", - description: "Collapse every heading section and every list item with lines under it, nested.", + description: "Collapse every heading section, every list item with lines under it and every foldable callout, nested.", defaultBinding: "z M", vimOnly: true, maxTokens: 2, @@ -1281,7 +1281,7 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ scope: "vim-editor", group: "view-actions", title: "Fold heading or list item", - description: "Fold the heading section or the list item at the cursor; on a line under a list item, fold that item.", + description: "Fold the heading section, the list item or the callout at the cursor; on a line under a list item or inside a foldable callout, fold that.", defaultBinding: "Mod+Alt+F", }, { diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index 629a1a69..1f576ab9 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -550,6 +550,39 @@ describe('callout titles keep their inline markup (#549)', () => { }) }) +describe('foldable callouts (#853)', () => { + it('renders a collapsed callout as a closed
with the title as its ', () => { + const html = renderMarkdown('> [!example]- Screenshots\n> first\n> second') + expect(html).toMatch(/
]*>/) + expect(html).not.toMatch(/]*\sopen/) + expect(html).toContain('Screenshots') + expect(html).toMatch(/]*>first\nsecond<\/p>/) + }) + + it('renders an expanded callout open', () => { + const html = renderMarkdown('> [!tip]+ Open by default\n> body') + expect(html).toMatch(/
Open by default
') + }) + + it('titles an untitled foldable callout with its type', () => { + expect(renderMarkdown('> [!note]-\n> body')).toContain('Note') + }) + + it('draws a plain box for a callout with nothing to fold, or no marker', () => { + const empty = renderMarkdown('> [!note]-') + expect(empty).not.toContain('Note
') + const plain = renderMarkdown('> [!note] Plain\n> body') + expect(plain).not.toContain(' { + expect(renderMarkdown('> [!note]-x not a marker')).not.toContain('callout') + }) +}) + // A display block inside an Obsidian-style callout used to lose the reading // view entirely: the fence normalizer re-scanned the closing fence of the block // BEFORE the callout as an opener, took the callout's `> $$` for content diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index dcdae4ef..8da9de40 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -47,6 +47,7 @@ const ALLOWED_RENDERED_URI_RE = const ALLOWED_RENDERED_DATA_ATTRS = [ 'data-bookmark-url', 'data-callout', + 'data-callout-fold', 'data-embed-src', 'data-embed-url', 'data-embed-height', @@ -592,6 +593,11 @@ function remarkHighlight() { * > body * * → `
` with a `.callout-title` header. + * + * A fold marker after the type (`> [!note]-` collapsed, `> [!note]+` + * expanded) makes it a `
` with the title as its ``, so the + * whole title row toggles it and the browser's find opens it on a match + * (#853). Folding it hides the body only; the note never changes. */ function remarkCallouts() { return (tree: MdRoot): void => { @@ -606,9 +612,10 @@ function remarkCallouts() { // nodes included — a [link](x) or $math$ in the title used to be // orphaned into an uncolored body paragraph, because only this leading // text fragment was consulted for the title. (#549) - const marker = firstText.value.match(/^\[!(\w+)\](?:[ \t]+|(?=\n)|$)/) + const marker = firstText.value.match(/^\[!(\w+)\]([-+])?(?:[ \t]+|(?=\n)|$)/) if (!marker) return const type = marker[1].toLowerCase() + const marker2 = marker[2] as '-' | '+' | undefined // Split the paragraph's inline children into the title line and the // body. Explicit Markdown breaks arrive as `break` nodes, while ordinary @@ -657,22 +664,27 @@ function remarkCallouts() { } else { node.children.shift() } + // A callout with nothing under its title has nothing to fold, and + // renders as a plain one, as the editor draws it with no chevron. + const fold = node.children.length > 0 ? marker2 : undefined - // Turn the blockquote into a styled div. + // Turn the blockquote into a styled div, or a
when it folds. node.data = { ...(node.data || {}), - hName: 'div', + hName: fold ? 'details' : 'div', hProperties: { className: ['callout'], - 'data-callout': type + 'data-callout': type, + ...(fold ? { 'data-callout-fold': fold === '-' ? 'collapsed' : 'expanded', open: fold === '+' } : {}) } } - // Prepend a title paragraph that renders as `
`. + // Prepend a title paragraph that renders as `
` + // (the of a foldable callout). node.children.unshift({ type: 'paragraph', data: { - hName: 'div', + hName: fold ? 'summary' : 'div', hProperties: { className: ['callout-title'] } }, children: hasTitle ? titleChildren : [{ type: 'text', value: fallbackTitle }] diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 0b205647..3e19c754 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -3807,6 +3807,35 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite .prose-zen .callout > p { margin: 0.35em 0 0; } +/* Foldable callouts (#853): `> [!type]-` and `> [!type]+` render as a +
whose is the title, so the whole title row toggles it. + The chevron follows the title, as in the editor. */ +.prose-zen details.callout > summary.callout-title { + display: block; + list-style: none; + cursor: pointer; +} +.prose-zen details.callout > summary.callout-title::-webkit-details-marker { + display: none; +} +.prose-zen details.callout > summary.callout-title::after { + content: "▾"; + display: inline-block; + margin-left: 0.4em; + font-size: 0.8em; + opacity: 0.75; +} +.prose-zen details.callout:not([open]) > summary.callout-title { + margin-bottom: 0; +} +.prose-zen details.callout:not([open]) > summary.callout-title::after { + content: "▸"; +} +.prose-zen details.callout > summary.callout-title:focus-visible { + outline: 2px solid rgb(var(--z-accent) / 0.6); + outline-offset: 2px; + border-radius: 4px; +} /* Callout color groups — the `data-callout` lists mirror the alias→group table in `lib/callout-types.ts` (kept in sync by hand; CSS can't import it). */ .prose-zen .callout[data-callout="note"], @@ -5578,6 +5607,22 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite font-weight: 600; color: rgb(var(--callout-color)); } +/* The fold chevron after a foldable callout's title (#853). */ +.cm-wysiwyg .cm-editor .cm-callout-fold { + display: inline-block; + margin-left: 0.4em; + padding: 0 0.25em; + font-size: 0.8em; + color: rgb(var(--callout-color)); + opacity: 0.75; + cursor: pointer; + user-select: none; + border-radius: 4px; +} +.cm-wysiwyg .cm-editor .cm-callout-fold:hover { + opacity: 1; + background: rgb(var(--callout-color) / 0.14); +} /* A list line inside a callout/blockquote keeps its hanging indent, but it has to sit on TOP of the box's own left padding. Otherwise the list's negative text-indent (see .cm-markdown-list-line) drags the bullet out past the box's diff --git a/packages/shared-domain/src/demo-tour-data.ts b/packages/shared-domain/src/demo-tour-data.ts index 7ce889c6..d2490d69 100644 --- a/packages/shared-domain/src/demo-tour-data.ts +++ b/packages/shared-domain/src/demo-tour-data.ts @@ -34,7 +34,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/06 — Callouts and Footnotes.md", - body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" + body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\nPut `-` right after the type to start a callout collapsed, or `+` to start it expanded, then click its title to open or close it.\n\n> [!example]- Foldable callout\n> Handy for screenshots, logs, and config excerpts that should stay out of the way until you need them.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" }, { path: "inbox/demo/07 — Wiki Links and Tags.md", From 2071ce1510feff136b598ef4437b19f270a766ca Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 14:20:10 -0500 Subject: [PATCH 16/20] Fix(mcp): keep case-sensitive server vaults behind the switch guard The session compared remote targets by lowercasing the entire URL. A reverse proxy could serve different vaults at /Work and /work, yet a write planned against the first would silently reach the second. Compare parsed URLs so the origin is normalized while paths and query values retain their case. A real MCP session against a local HTTP server now proves that no write reaches the new path before vault_info confirms it, and that writes resume after confirmation. Verified the target and MCP suites (47 tests) and the desktop main-process typecheck. --- apps/desktop/src/cli/vault-target.test.ts | 15 ++++++++ apps/desktop/src/cli/vault-target.ts | 4 +- apps/desktop/src/mcp/server.test.ts | 46 ++++++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/cli/vault-target.test.ts b/apps/desktop/src/cli/vault-target.test.ts index a36c3523..be91f67c 100644 --- a/apps/desktop/src/cli/vault-target.test.ts +++ b/apps/desktop/src/cli/vault-target.test.ts @@ -330,4 +330,19 @@ describe('sameVault: a vault switch is a different vault, not new credentials', expect(await sameVault(server('http://notes.example.com:7878'), server('http://notes.example.com:7879'))).toBe(false) expect(await sameVault(local(workVault), server('https://notes.example.com'))).toBe(false) }) + + it('normalizes the origin without changing the case of a reverse-proxy path', async () => { + expect( + await sameVault(server('https://notes.example.com/Work'), server('https://notes.example.com/work')) + ).toBe(false) + expect( + await sameVault(server('HTTPS://Notes.Example.com:443/Work/'), server('https://notes.example.com/Work')) + ).toBe(true) + }) + + it('keeps query values case-sensitive too', async () => { + expect( + await sameVault(server('https://notes.example.com/?vault=Work'), server('https://notes.example.com/?vault=work')) + ).toBe(false) + }) }) diff --git a/apps/desktop/src/cli/vault-target.ts b/apps/desktop/src/cli/vault-target.ts index c38461fc..ed125549 100644 --- a/apps/desktop/src/cli/vault-target.ts +++ b/apps/desktop/src/cli/vault-target.ts @@ -39,10 +39,12 @@ export type VaultTarget = * matter. */ export async function sameVault(a: VaultTarget, b: VaultTarget): Promise { if (a.kind === 'remote' || b.kind === 'remote') { + // URL normalizes the origin while preserving case-sensitive paths and + // query values: a reverse proxy may serve different vaults at /Work and /work. return ( a.kind === 'remote' && b.kind === 'remote' && - normalizeBaseUrl(a.baseUrl).toLowerCase() === normalizeBaseUrl(b.baseUrl).toLowerCase() + new URL(normalizeBaseUrl(a.baseUrl)).href === new URL(normalizeBaseUrl(b.baseUrl)).href ) } if (path.resolve(a.root) === path.resolve(b.root)) return true diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index 8b55f386..3e63d32e 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -405,6 +405,50 @@ describe('runMcpServer follows the target it is given (#831)', () => { await s.close() }) + it('refuses writes between case-sensitive server paths until vault_info confirms the switch', async () => { + const writes: string[] = [] + const fake = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const prefix = url.pathname.startsWith('/Work/') ? '/Work' : '/work' + const route = url.pathname.slice(prefix.length) + if (req.method === 'POST') writes.push(url.pathname) + const bodies: Record = { + '/api/vault': { root: `/srv${prefix}`, name: prefix.slice(1) }, + '/api/vault/settings': { primaryNotesLocation: 'inbox', systemFolderPaths: null }, + '/api/folders': [], + '/api/notes/write': { path: 'inbox/Plan.md', title: 'Plan' } + } + res.writeHead(route in bodies ? 200 : 404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(bodies[route] ?? null)) + }) + await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve)) + const address = fake.address() + if (address == null || typeof address === 'string') throw new Error('no port') + const origin = `http://127.0.0.1:${address.port}` + let target: VaultTarget = { kind: 'remote', baseUrl: `${origin}/Work`, name: '', authToken: null } + let s: Awaited> | undefined + + try { + s = await session({ resolveTarget: async () => target }) + expect((await s.vaultInfo()).info).toMatchObject({ server: `${origin}/Work` }) + + target = { ...target, baseUrl: `${origin}/work` } + const refused = await s.call('write_note', { path: 'inbox/Plan.md', body: 'planned for Work' }) + expect(writes).toEqual([]) + expect(refused.isError).toBe(true) + expect(refused.text).toContain('The ZenNotes vault changed') + + const { info } = await s.vaultInfo() + expect(info).toMatchObject({ server: `${origin}/work` }) + expect(String(info?.notes)).toContain(`until this call the session worked in the server at ${origin}/Work`) + expect((await s.call('write_note', { path: 'inbox/Plan.md', body: 'planned for work' })).isError).toBe(false) + expect(writes).toEqual(['/work/api/notes/write']) + } finally { + await s?.close() + await new Promise((resolve) => fake.close(() => resolve())) + } + }) + it('follows the app to another vault only after vault_info confirms it', async () => { await fsp.writeFile(path.join(alpha, 'inbox', 'Alpha.md'), '# Alpha\n') await fsp.writeFile(path.join(beta, 'inbox', 'Beta.md'), '# Beta\n') @@ -418,7 +462,7 @@ describe('runMcpServer follows the target it is given (#831)', () => { // A write planned against alpha must not land in beta, and a whole // batch stops, not only its first call. const batch = await Promise.all([ - s.call('write_note', { path: 'inbox/Alpha.md', content: 'planned for alpha' }), + s.call('write_note', { path: 'inbox/Alpha.md', body: 'planned for alpha' }), s.call('list_notes'), s.call('read_note', { path: 'inbox/Alpha.md' }) ]) From a23bcd5c98aef210b390cdc587f7750cf6fe922c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 14:22:38 -0500 Subject: [PATCH 17/20] Fix(editor): Fold All keeps code blocks and other syntax folds Collecting only headings, list items and marked callouts made zM and :foldall do nothing in notes containing standalone code blocks. Those ranges still folded individually with zc. Ask the fold services and syntax tree at each non-heading line, then combine those ranges with the nested outline folds. Deduplicate shared ranges so reopening a heading leaves each child folded once. Existing code, table and quote folds participate again. Verified the heading and callout folding suites (25 tests) and the app-core typecheck. The regression cases cover standalone fences, repeated Fold All, and code remaining folded after its heading opens. --- .../app-core/src/lib/cm-callout-fold.test.ts | 5 ++-- .../app-core/src/lib/cm-heading-fold.test.ts | 17 +++++++++-- packages/app-core/src/lib/cm-heading-fold.ts | 29 +++++++++++-------- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/app-core/src/lib/cm-callout-fold.test.ts b/packages/app-core/src/lib/cm-callout-fold.test.ts index fdd59b31..64d4057d 100644 --- a/packages/app-core/src/lib/cm-callout-fold.test.ts +++ b/packages/app-core/src/lib/cm-callout-fold.test.ts @@ -165,7 +165,7 @@ describe('foldable callouts in the editor (#853)', () => { expect(foldAtCursor(view)).toBe(false) }) - it('includes foldable callouts in Fold All', async () => { + it('includes foldable callouts and ordinary blockquote folds in Fold All', async () => { const view = mount() await settle() view.dom.querySelector('.cm-callout-fold')!.click() @@ -173,7 +173,8 @@ describe('foldable callouts in the editor (#853)', () => { expect(folds(view)).toEqual([ [1, 14], [3, 6], - [8, 9] + [8, 9], + [11, 12] ]) }) }) diff --git a/packages/app-core/src/lib/cm-heading-fold.test.ts b/packages/app-core/src/lib/cm-heading-fold.test.ts index 54b5f42f..1427cdfb 100644 --- a/packages/app-core/src/lib/cm-heading-fold.test.ts +++ b/packages/app-core/src/lib/cm-heading-fold.test.ts @@ -213,6 +213,17 @@ describe('list item folding (#848)', () => { done() }) + it('folds standalone code blocks with Fold All, without needing a heading or list', () => { + const text = '```js\nconst answer = 42\n```\n\n~~~text\nlog line\n~~~\n' + const { view, done } = mount(text, 1) + expect(foldAllOutline(view)).toBe(true) + expect(folds(view)).toEqual([[1, 3], [5, 7]]) + expect(foldAllOutline(view)).toBe(false) + expect(folds(view)).toEqual([[1, 3], [5, 7]]) + expect(view.state.doc.toString()).toBe(text) + done() + }) + it('folds every heading and list item, nested, so opening the heading shows the items still folded', () => { const { view, done } = mount(doc, 4) expect(foldAllOutline(view)).toBe(true) @@ -220,7 +231,8 @@ describe('list item folding (#848)', () => { [1, 16], [3, 5], [7, 8], - [9, 10] + [9, 10], + [13, 15] ]) // The caret moved out of the hidden text, onto the heading's line. expect(view.state.selection.main.head).toBe(0) @@ -229,7 +241,8 @@ describe('list item folding (#848)', () => { expect(folds(view)).toEqual([ [3, 5], [7, 8], - [9, 10] + [9, 10], + [13, 15] ]) done() }) diff --git a/packages/app-core/src/lib/cm-heading-fold.ts b/packages/app-core/src/lib/cm-heading-fold.ts index 7a45420e..2d1d5c7f 100644 --- a/packages/app-core/src/lib/cm-heading-fold.ts +++ b/packages/app-core/src/lib/cm-heading-fold.ts @@ -219,30 +219,35 @@ export function unfoldAtCursor(view: EditorView): boolean { } /** - * Fold every heading section, every list item with children and every - * foldable callout, nested, the way Vim's zM closes every fold: opening a - * heading then shows its lists still folded, one line per item. CodeMirror's - * foldAll folds only the outermost ranges, so a heading swallowed the tasks - * under it and there was no way to see just the task lines (#848). + * Fold every heading section, list item, callout and syntax fold, nested, + * the way Vim's zM closes every fold: opening a heading then shows its lists + * still folded, one line per item. CodeMirror's foldAll folds only the + * outermost ranges, so a heading swallowed the tasks under it (#848), but + * its syntax folds (code blocks, tables, quotes) must still participate. */ export function foldAllOutline(view: EditorView): boolean { const { state } = view const tree = ensureSyntaxTree(state, state.doc.length, 500) ?? syntaxTree(state) - const ranges: FoldRange[] = [] + const ranges = new Map() for (let n = 1; n <= state.doc.lines; n++) { + const line = state.doc.line(n) const level = headingLevelAt(state, n) - if (level === null) continue - const range = rangeForHeading(state, n, level) - if (range) ranges.push(range) + const range = + level === null + ? foldable(state, line.from, line.to) + : rangeForHeading(state, n, level) + if (range) ranges.set(`${range.from}:${range.to}`, range) } - ranges.push(...allListItemFoldRanges(state, tree), ...foldableCalloutRanges(state, tree)) - const fresh = ranges.filter((range) => !exactFoldAtRange(state, range)) + for (const range of [...allListItemFoldRanges(state, tree), ...foldableCalloutRanges(state, tree)]) { + ranges.set(`${range.from}:${range.to}`, range) + } + const fresh = [...ranges.values()].filter((range) => !exactFoldAtRange(state, range)) if (fresh.length === 0) return false // Keep the caret in sight: on the line that opens the outermost fold // around it, as zM leaves it in Vim. const head = state.selection.main.head let outer: FoldRange | null = null - for (const range of ranges) { + for (const range of ranges.values()) { if (head > range.from && head <= range.to && (!outer || range.from < outer.from)) outer = range } view.dispatch({ From 2e10d065865cfcecb9446beb5fb1bd32ea267139 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 14:24:51 -0500 Subject: [PATCH 18/20] Fix(editor): pinned references follow search keymap changes immediately The reference editor read note search from its initial preference snapshot. Unbinding or moving the shortcut in Settings updated the main editor but left the reference capturing the old chord until it was unpinned. Keep the reference keymap in its own compartment and reconfigure it when overrides or Vim mode change. The editor instance, selection, document and undo history stay in place while the old shortcut is released and the new one takes effect. Regression tests drive a mounted CodeMirror editor through unbinding, rebinding and resetting, and verify undo survives. The 25 reference and search-keymap tests and the app-core typecheck pass. --- .../components/PinnedReferencePane.test.ts | 124 ++++++++++++++++++ .../src/components/PinnedReferencePane.tsx | 42 ++++-- 2 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 packages/app-core/src/components/PinnedReferencePane.test.ts diff --git a/packages/app-core/src/components/PinnedReferencePane.test.ts b/packages/app-core/src/components/PinnedReferencePane.test.ts new file mode 100644 index 00000000..d71cbc5a --- /dev/null +++ b/packages/app-core/src/components/PinnedReferencePane.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { EditorView } from '@codemirror/view' +import { undo } from '@codemirror/commands' +import { closeSearchPanel, searchPanelOpen } from '@codemirror/search' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteContent } from '@shared/ipc' +import { useStore } from '../store' +import { PinnedReferencePane } from './PinnedReferencePane' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const note: NoteContent = { + path: 'inbox/Reference.md', + title: 'Reference', + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 10, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: 'alpha beta', + body: 'alpha beta' +} + +describe('PinnedReferencePane search bindings (#860)', () => { + let host: HTMLDivElement + let root: Root + let view: EditorView + let originalState: ReturnType + + beforeEach(async () => { + // jsdom has no layout; CodeMirror still measures the caret after a key. + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [] + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect() + }) + originalState = useStore.getState() + useStore.setState({ + vault: { root: '/vault', name: 'Vault' }, + selectedPath: note.path, + pinnedRefPath: note.path, + pinnedRefKind: 'note', + pinnedRefVisible: true, + pinnedRefMode: 'edit', + noteRefs: {}, + noteContents: { [note.path]: note }, + noteDirty: {}, + vimMode: false, + livePreview: false, + keymapOverrides: {}, + searchOpen: false, + updateNoteBody: vi.fn() + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + await act(async () => root.render(createElement(PinnedReferencePane))) + view = EditorView.findFromDOM(host.querySelector('.cm-editor')!)! + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + async function press(key: string, modifiers: KeyboardEventInit = {}): Promise { + await act(async () => { + view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { + key, bubbles: true, cancelable: true, ...modifiers + })) + }) + } + + it('hands an unbound Mod+F to the find bar without replacing the editor or its undo history', async () => { + await press('f', { ctrlKey: true }) + expect(useStore.getState().searchOpen).toBe(true) + act(() => { + useStore.getState().setSearchOpen(false) + view.dispatch({ changes: { from: note.body.length, insert: ' draft' }, selection: { anchor: 16 } }) + }) + const selection = view.state.selection + + await act(async () => useStore.setState({ keymapOverrides: { 'global.searchNotesNonVim': '' } })) + + expect(EditorView.findFromDOM(host.querySelector('.cm-editor')!)).toBe(view) + expect(view.state.doc.toString()).toBe('alpha beta draft') + expect(view.state.selection.eq(selection)).toBe(true) + await press('f', { ctrlKey: true }) + expect(useStore.getState().searchOpen).toBe(false) + expect(searchPanelOpen(view.state)).toBe(true) + act(() => { undo(view) }) + expect(view.state.doc.toString()).toBe(note.body) + }) + + it('uses the new shortcut immediately and releases it when the default is restored', async () => { + await act(async () => useStore.setState({ keymapOverrides: { 'global.searchNotesNonVim': 'F6' } })) + await press('F6') + expect(useStore.getState().searchOpen).toBe(true) + act(() => useStore.getState().setSearchOpen(false)) + + await press('f', { ctrlKey: true }) + expect(searchPanelOpen(view.state)).toBe(true) + expect(useStore.getState().searchOpen).toBe(false) + act(() => { closeSearchPanel(view) }) + + await act(async () => useStore.setState({ keymapOverrides: {} })) + await press('F6') + expect(useStore.getState().searchOpen).toBe(false) + await press('f', { ctrlKey: true }) + expect(useStore.getState().searchOpen).toBe(true) + expect(searchPanelOpen(view.state)).toBe(false) + }) +}) diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 06a883cf..f7d5c6cf 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -29,7 +29,7 @@ import { import { vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' -import { getKeymapBinding } from '../lib/keymaps' +import { getKeymapBinding, type KeymapOverrides } from '../lib/keymaps' import { keyBindingsFor } from '../lib/vim-half-page-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { noteMarkdown } from '../lib/cm-markdown-language' @@ -130,6 +130,22 @@ function lineNumberExtension(mode: LineNumberMode): Extension { ] } +function buildReferenceKeymap(vimMode: boolean, overrides: KeymapOverrides): Extension { + return keymap.of([ + // Note search must precede the find bar only while its binding is set. + ...keyBindingsFor(getKeymapBinding(overrides, 'global.searchNotesNonVim'), () => { + const state = useStore.getState() + if (state.vimMode) return false + state.setSearchOpen(true) + return true + }), + indentWithTab, + ...vimAwareDefaultKeymap(vimMode), + ...historyKeymap, + ...vimAwareSearchKeymap(vimMode) + ]) +} + export function PinnedReferencePane(): JSX.Element | null { const globalRefPath = useStore((s) => s.pinnedRefPath) const globalRefKind = useStore((s) => s.pinnedRefKind) @@ -167,6 +183,7 @@ export function PinnedReferencePane(): JSX.Element | null { const updateNoteBody = useStore((s) => s.updateNoteBody) const persistNote = useStore((s) => s.persistNote) const vimMode = useStore((s) => s.vimMode) + const keymapOverrides = useStore((s) => s.keymapOverrides) const livePreview = useStore((s) => s.livePreview) const showHeadingLevelLabels = useStore((s) => s.showHeadingLevelLabels) const lineNumberMode = useStore((s) => s.lineNumberMode) @@ -180,6 +197,7 @@ export function PinnedReferencePane(): JSX.Element | null { const viewRef = useRef(null) const viewPathRef = useRef(null) const vimCompartmentRef = useRef(null) + const keymapCompartmentRef = useRef(null) const livePreviewCompartmentRef = useRef(null) const lineNumbersCompartmentRef = useRef(null) const headingCompartmentRef = useRef(null) @@ -198,11 +216,13 @@ export function PinnedReferencePane(): JSX.Element | null { } if (viewRef.current) return const vimCompartment = new Compartment() + const keymapCompartment = new Compartment() const livePreviewCompartment = new Compartment() const lineNumbersCompartment = new Compartment() const headingCompartment = new Compartment() const tabSizeCompartment = new Compartment() vimCompartmentRef.current = vimCompartment + keymapCompartmentRef.current = keymapCompartment livePreviewCompartmentRef.current = livePreviewCompartment lineNumbersCompartmentRef.current = lineNumbersCompartment headingCompartmentRef.current = headingCompartment @@ -267,19 +287,7 @@ export function PinnedReferencePane(): JSX.Element | null { }), completionNavKeymap, completionKeymapExtension, - keymap.of([ - // The user's note-search binding, as in the main editor (#860). - ...keyBindingsFor(getKeymapBinding(s0.keymapOverrides, 'global.searchNotesNonVim'), () => { - const state = useStore.getState() - if (state.vimMode) return false - state.setSearchOpen(true) - return true - }), - indentWithTab, - ...vimAwareDefaultKeymap(s0.vimMode), - ...historyKeymap, - ...vimAwareSearchKeymap(s0.vimMode) - ]), + keymapCompartment.of(buildReferenceKeymap(s0.vimMode, s0.keymapOverrides)), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return if (upd.transactions.some((tr: Transaction) => tr.annotation(programmatic))) return @@ -325,6 +333,12 @@ export function PinnedReferencePane(): JSX.Element | null { if (!view || !comp) return view.dispatch({ effects: comp.reconfigure(vimMode ? vim() : []) }) }, [vimMode]) + useEffect(() => { + const view = viewRef.current + const comp = keymapCompartmentRef.current + if (!view || !comp) return + view.dispatch({ effects: comp.reconfigure(buildReferenceKeymap(vimMode, keymapOverrides)) }) + }, [vimMode, keymapOverrides]) useEffect(() => { const view = viewRef.current const comp = livePreviewCompartmentRef.current From 1a062deaead327bd900296babe62874cd51b381b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 14:33:19 -0500 Subject: [PATCH 19/20] Fix(split): keep both pane ends reachable at boundary anchors A content block exactly at one pane's scroll limit was accepted as an anchor, then stopped the paired terminal anchor from being added. Scrolling that pane to the bottom could leave hundreds of pixels unread in the other. Reserve either scroll limit for the shared terminal anchor. Regression cases cover a block at the editor limit, the preview limit and both limits, checking reachability, near-end movement and round trips. The full uncached test suite, workspace typecheck and desktop build pass. Isolated CDP checks confirm code folding, live reference keymap updates and final content reachable from either split pane. --- .../app-core/src/lib/split-scroll-sync.test.ts | 16 ++++++++++++++++ packages/app-core/src/lib/split-scroll-sync.ts | 7 ++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/app-core/src/lib/split-scroll-sync.test.ts b/packages/app-core/src/lib/split-scroll-sync.test.ts index 8a30a780..a850c861 100644 --- a/packages/app-core/src/lib/split-scroll-sync.test.ts +++ b/packages/app-core/src/lib/split-scroll-sync.test.ts @@ -59,6 +59,22 @@ describe('mapSplitScrollTop (#859)', () => { expect(mapSplitScrollTop(g, 'editor', 800)).toBe(2400) }) + it.each([ + ['editor', 900, 2000], + ['preview', 700, 2800], + ['both panes', 900, 2800] + ] as const)('keeps both ends reachable when a block starts exactly at the end of %s', (_, editor, preview) => { + const g = geometry([[0, 16], [100, 200], [editor, preview]], 900, 2800) + expect(mapSplitScrollTop(g, 'editor', 900)).toBe(2800) + expect(mapSplitScrollTop(g, 'preview', 2800)).toBe(900) + expect(mapSplitScrollTop(g, 'editor', 899)).toBeLessThan(2800) + expect(mapSplitScrollTop(g, 'preview', 2799)).toBeLessThan(900) + for (const top of [0, 250, 450, 700, 899, 900]) { + const there = mapSplitScrollTop(g, 'editor', top) + expect(mapSplitScrollTop(g, 'preview', there)).toBeCloseTo(top, 6) + } + }) + it('clamps to the target pane and handles a pane that cannot scroll', () => { const g = geometry([[0, 0], [100, 400]], 500, 2000) expect(mapSplitScrollTop(g, 'editor', -50)).toBe(0) diff --git a/packages/app-core/src/lib/split-scroll-sync.ts b/packages/app-core/src/lib/split-scroll-sync.ts index ab133240..2a8d99c0 100644 --- a/packages/app-core/src/lib/split-scroll-sync.ts +++ b/packages/app-core/src/lib/split-scroll-sync.ts @@ -28,8 +28,9 @@ export interface SplitScrollAnchor { * that begins further down in BOTH panes than the last one kept (blocks the * editor folds into one widget, or that render with no height, share a * position; the first of them stands for all), then the two panes' ends. - * Blocks that begin inside a pane's last screenful are unreachable as a - * scroll position and end the list there. + * A block at either scroll limit belongs to the paired end anchor; keeping + * it would leave the other pane short of its end. Blocks beyond a limit + * are unreachable as scroll positions and end the list there too. */ export function splitScrollAnchors( blocks: Iterable, @@ -38,7 +39,7 @@ export function splitScrollAnchors( ): SplitScrollAnchor[] { const anchors: SplitScrollAnchor[] = [{ editor: 0, preview: 0 }] for (const block of blocks) { - if (block.editor > editorMax || block.preview > previewMax) break + if (block.editor >= editorMax || block.preview >= previewMax) break const last = anchors[anchors.length - 1] if (block.editor > last.editor && block.preview > last.preview) anchors.push(block) } From 08096c0c145c8174f7c20edf8ab71d14c018955a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 28 Sep 2026 14:43:14 -0500 Subject: [PATCH 20/20] Release: align desktop and shared packages at 2.57.0 --- apps/desktop/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c73ea65e..d4c561bc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.56.0", + "version": "2.57.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index de598711..1db36cbf 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 833ec664..f59949b9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 23b466bc..877b7067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.56.0", + "version": "2.57.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.56.0", + "version": "2.57.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.56.0", + "version": "2.57.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.56.0", + "version": "2.57.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.56.0", + "version": "2.57.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.56.1", + "version": "2.57.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.56.1", + "version": "2.57.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.56.1", + "version": "2.57.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.56.0" + "version": "2.57.0" } } } diff --git a/package.json b/package.json index 891815a5..d8adcde1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.56.0", + "version": "2.57.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index ecfd5065..2a6e732b 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.56.1", + "version": "2.57.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 82ee79a5..09c77017 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.56.1", + "version": "2.57.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 067bac8e..65e83d70 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.56.1", + "version": "2.57.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 43a22a46..df8b2242 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "exports": { ".": "./src/index.ts"