diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f17eecc9..ed2a228e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.51.1", + "version": "2.52.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index e4a79c10..57a4e7c3 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -28,7 +28,7 @@ import { ensureConfigFile, stopAppConfigWatcher } from './app-config' -import { CONFIG_VERSION, type AppConfigPortable } from '@shared/app-config' +import { CONFIG_VERSION, PORTABLE_PREF_KEYS, type AppConfigPortable } from '@shared/app-config' const tempDirs: string[] = [] async function tmp(prefix: string): Promise { @@ -200,6 +200,23 @@ describe('TOML serialization', () => { expect(portable.ripgrepBinaryPath).toBeNull() }) + // `keepViewModeAcrossNotes` sat in PORTABLE_PREF_KEYS for months with no + // field mapping, so it was "portable" in name only and never reached the + // file. Every portable key has to come back out of a freshly written config. + it('maps every portable preference into the file, so none stays on one machine', () => { + const { portable } = deserializeConfig(serializeConfig({})) + expect(PORTABLE_PREF_KEYS.filter((key) => !(key in portable))).toEqual([]) + }) + + it('carries both keep-across-notes preferences', () => { + const text = serializeConfig({ keepPanelsAcrossNotes: false, keepViewModeAcrossNotes: true }) + expect(text).toContain('keep_panels_across_notes = false') + expect(text).toContain('keep_view_mode_across_notes = true') + const { portable } = deserializeConfig(text) + expect(portable.keepPanelsAcrossNotes).toBe(false) + expect(portable.keepViewModeAcrossNotes).toBe(true) + }) + it('round-trips visual tweaks (colors + sliders) through the [tweaks] table', () => { const tweaks = { accent: '#ff3b30', density: 'comfortable', cornerRadius: 'rounded' } const text = serializeConfig({ themeTweaks: tweaks }) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 57c807a2..ea4c4030 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -225,6 +225,16 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'default_view_mode', comment: 'edit | split | preview — the mode a note opens in before it has a remembered one' }, + keepViewModeAcrossNotes: { + section: 'editor', + tomlKey: 'keep_view_mode_across_notes', + comment: 'true = stay in the current Edit / Split / Preview mode when opening another note; false = each note reopens in its own last mode' + }, + persistUndoHistory: { + section: 'editor', + tomlKey: 'persist_undo_history', + comment: "true = keep each note's undo history between launches, like Vim's undofile (stored with the app, never in the vault; holds deleted text; desktop only)" + }, lineNumberMode: { section: 'editor', tomlKey: 'line_number_mode', @@ -359,6 +369,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'auto_calendar_panel', comment: 'auto-show the calendar for daily / weekly notes' }, + keepPanelsAcrossNotes: { + section: 'view', + tomlKey: 'keep_panels_across_notes', + comment: 'true = Connections / Outline / Comments / Calendar stay as set while you switch notes; false = each note remembers its own panels (saved with the workspace, so they survive a restart)' + }, calendarWeekStart: { section: 'view', tomlKey: 'calendar_week_start', diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d9f9bd65..2fd2d629 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -214,6 +214,12 @@ import type { DatabaseSidecar, DbRow } from "@shared/databases"; import { VaultWatcher } from "./watcher"; import { WindowVaultRegistry } from "./window-vaults"; import { registerEphemeralRoot, isEphemeralRoot } from "./ephemeral-vaults"; +import { + clearUndoHistories, + pruneUndoHistories, + readUndoHistory, + writeUndoHistory, +} from "./undo-history-store"; import { renderTikz } from "./tikz"; import { fetchLinkMetadata } from "./link-metadata"; import { RemoteRequestError, RemoteServerClient } from "./remote/server-client"; @@ -3251,6 +3257,35 @@ function registerIpc(): void { await fsp.writeFile(path.join(dir, "workspace.json"), json, "utf8"); }); + // Undo history between launches (#793). It is keyed by the vault of the + // calling window, taken from main-process state like every other handler, + // and the note path only ever feeds a hash, so nothing the renderer sends + // can steer a read or a write outside /undo-history. + const undoHistoryVault = (): string => { + const v = requireVault(); + return isRemoteWorkspaceActive() + ? `remote:${currentRemoteWorkspaceProfileId ?? ""}:${v.root}` + : v.root; + }; + handle( + IPC.UNDO_HISTORY_READ, + async (_e, notePath: unknown): Promise => + await readUndoHistory(app.getPath("userData"), undoHistoryVault(), notePath), + ); + handle( + IPC.UNDO_HISTORY_WRITE, + async (_e, notePath: unknown, json: unknown): Promise => + await writeUndoHistory( + app.getPath("userData"), + undoHistoryVault(), + notePath, + json, + ), + ); + handle(IPC.UNDO_HISTORY_CLEAR, async (): Promise => { + await clearUndoHistories(app.getPath("userData")); + }); + handle(IPC.VAULT_ROOT_CONTENT_HIDDEN, async () => { // Local-vault only: a remote workspace manages its own layout server-side. if (isRemoteWorkspaceActive()) return false; @@ -5251,6 +5286,12 @@ app.whenReady().then(async () => { await migrateLegacyRemoteWorkspaceSecrets(); + // Saved undo histories (#793) expire and are capped per vault. Off the boot + // path: a slow or failing sweep must never delay the first window. + void pruneUndoHistories(app.getPath("userData")).catch((err) => + console.error("[undo-history] prune failed", err), + ); + // Heal the legacy command name and upgrade existing desktop-owned shortcuts. // A PATH or runtime staging failure must not delay or fail app startup. void migrateLegacyCliLink() diff --git a/apps/desktop/src/main/undo-history-store.test.ts b/apps/desktop/src/main/undo-history-store.test.ts new file mode 100644 index 00000000..fbd409e5 --- /dev/null +++ b/apps/desktop/src/main/undo-history-store.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + MAX_UNDO_HISTORY_AGE_MS, + MAX_UNDO_HISTORY_BYTES, + MAX_UNDO_HISTORY_FILES, + UNDO_HISTORY_DIR, + clearUndoHistories, + pruneUndoHistories, + readUndoHistory, + undoHistoryFile, + writeUndoHistory +} from './undo-history-store' + +let base: string +const VAULT = '/Users/test/Notes' + +beforeEach(async () => { + base = await mkdtemp(path.join(tmpdir(), 'zn-undo-')) +}) +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('undo history files (#793)', () => { + it('gives back what was written for a note, and nothing for a note it has not seen', async () => { + await writeUndoHistory(base, VAULT, 'inbox/A.md', '{"v":1}') + expect(await readUndoHistory(base, VAULT, 'inbox/A.md')).toBe('{"v":1}') + expect(await readUndoHistory(base, VAULT, 'inbox/B.md')).toBeNull() + }) + + it('keeps vaults apart, since note paths are relative and repeat', async () => { + await writeUndoHistory(base, VAULT, 'inbox/A.md', 'one') + await writeUndoHistory(base, '/Users/test/Work', 'inbox/A.md', 'two') + expect(await readUndoHistory(base, VAULT, 'inbox/A.md')).toBe('one') + expect(await readUndoHistory(base, '/Users/test/Work', 'inbox/A.md')).toBe('two') + }) + + // The renderer is not trusted: whatever it sends as a path, the file stays here. + it('never writes outside its own folder, whatever the note path says', async () => { + const root = path.join(base, UNDO_HISTORY_DIR) + for (const hostile of ['../../escape.md', '/etc/passwd', 'a/../../../b.md', 'C:\\Windows\\x.md']) { + const file = undoHistoryFile(base, VAULT, hostile) + expect(file).not.toBeNull() + expect(path.relative(root, file!).startsWith('..')).toBe(false) + expect(path.basename(file!)).toMatch(/^[0-9a-f]{32}\.json$/) + } + }) + + it('ignores anything that is not a plausible note path or payload', async () => { + for (const bad of [null, undefined, 7, '', 'a\0b', 'x'.repeat(5000)]) { + expect(undoHistoryFile(base, VAULT, bad)).toBeNull() + await writeUndoHistory(base, VAULT, bad, 'data') + expect(await readUndoHistory(base, VAULT, bad)).toBeNull() + } + await writeUndoHistory(base, VAULT, 'inbox/A.md', { not: 'a string' }) + await writeUndoHistory(base, VAULT, 'inbox/A.md', 'x'.repeat(MAX_UNDO_HISTORY_BYTES + 1)) + expect(await readUndoHistory(base, VAULT, 'inbox/A.md')).toBeNull() + }) + + it('forgets one note on request, and everything when the setting is turned off', async () => { + await writeUndoHistory(base, VAULT, 'inbox/A.md', 'a') + await writeUndoHistory(base, VAULT, 'inbox/B.md', 'b') + await writeUndoHistory(base, VAULT, 'inbox/A.md', null) + expect(await readUndoHistory(base, VAULT, 'inbox/A.md')).toBeNull() + expect(await readUndoHistory(base, VAULT, 'inbox/B.md')).toBe('b') + + await clearUndoHistories(base) + expect(await readUndoHistory(base, VAULT, 'inbox/B.md')).toBeNull() + expect(await readdir(base)).toEqual([]) + }) + + it('leaves no partial file behind after a write', async () => { + await writeUndoHistory(base, VAULT, 'inbox/A.md', 'data') + const dir = path.dirname(undoHistoryFile(base, VAULT, 'inbox/A.md')!) + expect((await readdir(dir)).every((name) => name.endsWith('.json'))).toBe(true) + }) +}) + +describe('pruning undo history files', () => { + it('drops histories nobody came back to, and stray partial writes', async () => { + await writeUndoHistory(base, VAULT, 'inbox/Old.md', 'old') + await writeUndoHistory(base, VAULT, 'inbox/New.md', 'new') + const oldFile = undoHistoryFile(base, VAULT, 'inbox/Old.md')! + const long = new Date(Date.now() - MAX_UNDO_HISTORY_AGE_MS - 60_000) + await utimes(oldFile, long, long) + await writeFile(path.join(path.dirname(oldFile), 'abc.json.123.tmp'), 'partial') + + await pruneUndoHistories(base) + expect(await readUndoHistory(base, VAULT, 'inbox/Old.md')).toBeNull() + expect(await readUndoHistory(base, VAULT, 'inbox/New.md')).toBe('new') + expect((await readdir(path.dirname(oldFile))).length).toBe(1) + }) + + it('keeps the most recently written notes when a vault has too many', async () => { + const extra = 3 + for (let n = 0; n < MAX_UNDO_HISTORY_FILES + extra; n++) { + await writeUndoHistory(base, VAULT, `inbox/${n}.md`, String(n)) + const file = undoHistoryFile(base, VAULT, `inbox/${n}.md`)! + const at = new Date(Date.now() - (MAX_UNDO_HISTORY_FILES + extra - n) * 1000) + await utimes(file, at, at) + } + await pruneUndoHistories(base) + expect(await readUndoHistory(base, VAULT, 'inbox/0.md')).toBeNull() + expect(await readUndoHistory(base, VAULT, `inbox/${extra - 1}.md`)).toBeNull() + expect(await readUndoHistory(base, VAULT, `inbox/${extra}.md`)).toBe(String(extra)) + expect(await readFile(undoHistoryFile(base, VAULT, `inbox/${MAX_UNDO_HISTORY_FILES}.md`)!, 'utf8')).toBe( + String(MAX_UNDO_HISTORY_FILES) + ) + }) + + it('is fine with nothing to prune', async () => { + await expect(pruneUndoHistories(base)).resolves.toBeUndefined() + }) +}) diff --git a/apps/desktop/src/main/undo-history-store.ts b/apps/desktop/src/main/undo-history-store.ts new file mode 100644 index 00000000..bc704910 --- /dev/null +++ b/apps/desktop/src/main/undo-history-store.ts @@ -0,0 +1,142 @@ +import { createHash } from 'node:crypto' +import { promises as fsp } from 'node:fs' +import path from 'node:path' + +/** + * Where undo history lives between launches, for the people who turn on + * "Keep undo history after quitting" (Vim's `undofile`, #793). + * + * The files sit under the app's own user-data folder, never in the vault: undo + * data is a machine-local convenience, it holds fragments of text the user + * deleted, and inside `.zennotes/` it would sync to other machines and land in + * the history of a git-backed vault. It is the same split Vim makes between a + * file and its entry in `undodir`. + * + * One file per note, named by hashes, so a note path can never steer a write + * outside this folder no matter what the renderer sends: + * + * /undo-history//.json + * + * The content is opaque here. The renderer decides what is in it and whether + * it still fits the note; this module only bounds how much disk it can take. + */ + +export const UNDO_HISTORY_DIR = 'undo-history' +/** One note's history. Beyond this the renderer trims its oldest steps first. */ +export const MAX_UNDO_HISTORY_BYTES = 2 * 1024 * 1024 +/** Notes remembered per vault; the ones written longest ago go first. */ +export const MAX_UNDO_HISTORY_FILES = 400 +/** A history nobody came back to for this long is not coming back. */ +export const MAX_UNDO_HISTORY_AGE_MS = 90 * 24 * 60 * 60 * 1000 +const MAX_NOTE_PATH_LENGTH = 4096 + +const digest = (value: string, length: number): string => + createHash('sha256').update(value).digest('hex').slice(0, length) + +function vaultDir(baseDir: string, vaultIdentity: string): string { + return path.join(baseDir, UNDO_HISTORY_DIR, digest(vaultIdentity, 16)) +} + +/** `null` for anything that is not a plausible note path. */ +export function undoHistoryFile( + baseDir: string, + vaultIdentity: string, + notePath: unknown +): string | null { + if (typeof notePath !== 'string' || notePath.length === 0) return null + if (notePath.length > MAX_NOTE_PATH_LENGTH || notePath.includes('\0')) return null + return path.join(vaultDir(baseDir, vaultIdentity), `${digest(notePath, 32)}.json`) +} + +export async function readUndoHistory( + baseDir: string, + vaultIdentity: string, + notePath: unknown +): Promise { + const file = undoHistoryFile(baseDir, vaultIdentity, notePath) + if (!file) return null + // One handle for the size check and the read. Checking the path and then + // reading the path again would let the file be swapped in between. + let handle: fsp.FileHandle + try { + handle = await fsp.open(file, 'r') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } + try { + const stat = await handle.stat() + if (stat.size > MAX_UNDO_HISTORY_BYTES) return null + return await handle.readFile('utf8') + } finally { + await handle.close() + } +} + +/** `json === null` forgets the note's history. Oversized or non-string input is dropped. */ +export async function writeUndoHistory( + baseDir: string, + vaultIdentity: string, + notePath: unknown, + json: unknown +): Promise { + const file = undoHistoryFile(baseDir, vaultIdentity, notePath) + if (!file) return + if (json === null) { + await fsp.rm(file, { force: true }) + return + } + if (typeof json !== 'string' || Buffer.byteLength(json, 'utf8') > MAX_UNDO_HISTORY_BYTES) return + await fsp.mkdir(path.dirname(file), { recursive: true }) + // Written beside the target and renamed over it: a quit in the middle of a + // write must not leave half a file for the next launch to parse. + const partial = `${file}.${process.pid}.tmp` + await fsp.writeFile(partial, json, 'utf8') + await fsp.rename(partial, file) +} + +/** Everything, for every vault: what turning the setting off promises. */ +export async function clearUndoHistories(baseDir: string): Promise { + await fsp.rm(path.join(baseDir, UNDO_HISTORY_DIR), { recursive: true, force: true }) +} + +/** + * Keep the folder bounded: drop histories older than the age limit, then the + * oldest ones beyond the per-vault count, and any stray partial writes. Cheap + * enough to run once per launch. + */ +export async function pruneUndoHistories(baseDir: string, now: number = Date.now()): Promise { + const root = path.join(baseDir, UNDO_HISTORY_DIR) + let vaults: string[] + try { + vaults = await fsp.readdir(root) + } catch { + return + } + for (const vault of vaults) { + const dir = path.join(root, vault) + let names: string[] + try { + names = await fsp.readdir(dir) + } catch { + continue + } + const kept: Array<{ file: string; mtimeMs: number }> = [] + for (const name of names) { + const file = path.join(dir, name) + try { + const stat = await fsp.stat(file) + const expired = now - stat.mtimeMs > MAX_UNDO_HISTORY_AGE_MS + if (!name.endsWith('.json') || expired) await fsp.rm(file, { force: true }) + else kept.push({ file, mtimeMs: stat.mtimeMs }) + } catch { + /* raced with a write or a clear; the next launch sees the result */ + } + } + kept.sort((a, b) => b.mtimeMs - a.mtimeMs) + for (const { file } of kept.slice(MAX_UNDO_HISTORY_FILES)) { + await fsp.rm(file, { force: true }).catch(() => undefined) + } + if (kept.length === 0) await fsp.rmdir(dir).catch(() => undefined) + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index df5f5542..36850542 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -115,7 +115,8 @@ const DESKTOP_CAPABILITIES: ZenCapabilities = { supportsCliInstall: process.platform === 'darwin' || process.platform === 'linux', supportsCustomTemplates: true, supportsCustomCodeLanguages: true, - supportsWorkflows: true + supportsWorkflows: true, + supportsUndoFile: true } const DESKTOP_APP_INFO: ZenAppInfo = { @@ -422,6 +423,11 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.WORKSPACE_STATE_READ), writeWorkspaceState: (json: string): Promise => ipcRenderer.invoke(IPC.WORKSPACE_STATE_WRITE, json), + readNoteUndoHistory: (path: string): Promise => + ipcRenderer.invoke(IPC.UNDO_HISTORY_READ, path), + writeNoteUndoHistory: (path: string, json: string | null): Promise => + ipcRenderer.invoke(IPC.UNDO_HISTORY_WRITE, path, json), + clearNoteUndoHistories: (): Promise => ipcRenderer.invoke(IPC.UNDO_HISTORY_CLEAR), rootContentHiddenByInboxMode: (): Promise => ipcRenderer.invoke(IPC.VAULT_ROOT_CONTENT_HIDDEN), diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index e9790b89..cf8fd2d3 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.51.1", + "version": "2.52.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 238459a3..297f12db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.51.1", + "version": "2.52.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 9ce1564e..a71e6c66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.51.1", + "version": "2.52.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.51.1", + "version": "2.52.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.51.1", + "version": "2.52.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.51.1", + "version": "2.52.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.51.1", + "version": "2.52.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.51.1", + "version": "2.52.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.51.1", + "version": "2.52.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.51.1", + "version": "2.52.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.51.1" + "version": "2.52.0" } } } diff --git a/package.json b/package.json index d5a85a5c..1dbe23b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.51.1", + "version": "2.52.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/README.md b/packages/app-core/README.md index f043a227..bf6cf953 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -214,6 +214,7 @@ keyed by the native host's stable vault token. - `requestRenameBrowseDatabase(host, directory)` prompts for a database title and preserves host collision numbering. Names starting with a dot are rejected because vault scanners hide those directories. Case-only renames retain existing host behavior and can receive a numbered suffix on case-insensitive filesystems. - `requestCreateBrowseFolder(host, directory = '')` prompts for a child folder. - `requestRenameBrowseFolder(host, directory)` prompts for an ordinary folder's leaf name. +- `requestMoveBrowseDirectory(host, directory)` prompts for a new parent for an ordinary folder or an entire database, using the move-note prompt's `inbox[/path]` values. Only existing notes-area folders are offered, never the directory itself, its descendants, or a database. The leaf name, and so a database's `.base` suffix, is kept. A destination that already holds that name is blocked in the prompt, and the host still refuses to overwrite. The store carries open tabs (database tabs included), folder icons and colors, favorites, and manual order to the new path. Pins are host-owned: a host that pins folders re-keys them after `completed`. - `requestDeleteBrowseDirectory(host, directory)` confirms permanent deletion of an ordinary folder or an entire database, with the appropriate warning. All directories are relative to the primary notes area. Root deletion, missing diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 6d238143..a4a8eb7f 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.51.1", + "version": "2.52.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 11453335..7edb77d3 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -584,6 +584,20 @@ function App(): JSX.Element { return () => window.removeEventListener('beforeunload', flush) }, [flushDirtyNotes]) + // "Keep undo history after quitting" off means the saved histories go too: + // they hold text the user deleted, so they do not outlive the setting that + // asked for them. Watched here rather than in the setter, because the + // preference can also be turned off from config.toml. (#793) + const persistUndoHistory = useStore((s) => s.persistUndoHistory) + const persistedUndoHistoryRef = useRef(persistUndoHistory) + useEffect(() => { + const was = persistedUndoHistoryRef.current + persistedUndoHistoryRef.current = persistUndoHistory + if (was && !persistUndoHistory) { + void window.zen?.clearNoteUndoHistories?.()?.catch(() => undefined) + } + }, [persistUndoHistory]) + // Apply theme: set html[data-theme=...] + html[data-theme-mode=...] based on // mode/family/id. Custom themes keep one id (`custom-`) and express // light/dark via `data-theme-mode`; built-ins encode mode in their id but we diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts index 001df601..2df424fd 100644 --- a/packages/app-core/src/browse-actions.test.ts +++ b/packages/app-core/src/browse-actions.test.ts @@ -287,4 +287,150 @@ describe('public Browse actions', () => { expect(await s.createBrowseDatabase(s.host, 'People.base')).toBe('unavailable') }) + it('moves a folder to the top level and keeps its name', async () => { + const s = await setup() + const result = s.requestMoveBrowseDirectory(s.host, 'Work/Nested') + const options = s.getPromptRequest()?.options + expect(options?.title).toBe('Move "Nested" to…') + // Empty on purpose: a prefilled path would filter the touch list down to + // the folder it is already in. + expect(options?.initialValue).toBeUndefined() + s.answer(' inbox ') + expect(await result).toBe('completed') + expect(s.rename).toHaveBeenCalledWith('inbox', 'Work/Nested', 'Nested', expect.any(Function)) + }) + + it('moves a database into a folder and keeps its .base suffix', async () => { + const s = await setup() + const result = s.requestMoveBrowseDirectory(s.host, 'People.base') + expect(s.getPromptRequest()?.options.title).toBe('Move "People" to…') + s.answer('inbox/Work/Nested') + expect(await result).toBe('completed') + expect(s.rename).toHaveBeenCalledWith( + 'inbox', + 'People.base', + 'Work/Nested/People.base', + expect.any(Function) + ) + }) + + it('offers only real destinations: not itself, its children, databases, or archive', async () => { + const s = await setup() + s.useStore.setState({ + folders: [ + ...s.useStore.getState().folders, + { folder: 'inbox', subpath: 'Home', siblingOrder: 0 }, + { folder: 'inbox', subpath: 'People.base/pages', siblingOrder: 0 }, + { folder: 'archive', subpath: 'Old', siblingOrder: 0 } + ] + }) + const result = s.requestMoveBrowseDirectory(s.host, 'Work') + expect(s.getPromptRequest()?.options.suggestions?.map((row) => row.value)).toEqual([ + 'inbox', + 'inbox/Home' + ]) + s.answer(null) + expect(await result).toBe('cancelled') + }) + + it('refuses impossible destinations at submission and never writes', async () => { + const s = await setup() + for (const value of [ + null, + '', + ' ', + 'Work', + 'archive', + 'inbox/Work/Nested', + 'inbox/Work/Nested/Deeper', + 'inbox/People.base', + 'inbox/People.base/pages', + 'inbox/Missing', + 'inbox/../Elsewhere', + 'inbox/.hidden', + 'inbox/bad\0name' + ]) { + const result = s.requestMoveBrowseDirectory(s.host, 'Work/Nested') + if (value?.trim()) expect(s.getPromptRequest()?.options.validate?.(value)).toBeTruthy() + s.answer(value) + expect(await result).toBe('cancelled') + } + // Its current parent is a valid answer that changes nothing. + const same = s.requestMoveBrowseDirectory(s.host, 'Work/Nested') + expect(s.getPromptRequest()?.options.validate?.('inbox/Work')).toBeNull() + s.answer('inbox/Work') + expect(await same).toBe('cancelled') + expect(s.rename).not.toHaveBeenCalled() + }) + + it('blocks a move onto an existing folder or database of the same name', async () => { + const s = await setup() + s.useStore.setState({ + folders: [ + ...s.useStore.getState().folders, + { folder: 'inbox', subpath: 'Nested', siblingOrder: 0 }, + { folder: 'inbox', subpath: 'Work/People.base', siblingOrder: 0 } + ] + }) + const folder = s.requestMoveBrowseDirectory(s.host, 'Work/Nested') + expect(s.getPromptRequest()?.options.validate?.('inbox')).toBe( + '"Nested" already exists in that folder.' + ) + s.answer('inbox') + expect(await folder).toBe('cancelled') + const database = s.requestMoveBrowseDirectory(s.host, 'People.base') + expect(s.getPromptRequest()?.options.validate?.('inbox/Work')).toBe( + '"People" already exists in that folder.' + ) + s.answer('inbox/Work') + expect(await database).toBe('cancelled') + expect(s.rename).not.toHaveBeenCalled() + }) + + it('validates a move against the folders that exist at submission', async () => { + const s = await setup() + const result = s.requestMoveBrowseDirectory(s.host, 'People.base') + s.useStore.setState({ + folders: s.useStore.getState().folders.filter((row) => row.subpath !== 'Work/Nested') + }) + s.answer('inbox/Work/Nested') + expect(await result).toBe('cancelled') + expect(s.rename).not.toHaveBeenCalled() + }) + + it('cannot move the root, missing folders, or database internals', async () => { + const s = await setup() + for (const directory of ['', 'Missing', 'People.base/pages']) + expect(await s.requestMoveBrowseDirectory(s.host, directory)).toBe('unavailable') + expect(s.getPromptRequest()).toBeNull() + }) + + it.each(['host', 'missing'] as const)( + 'stops a move after a %s change during the prompt', + async (kind) => { + const s = await setup() + let current = true + const result = s.requestMoveBrowseDirectory({ isCurrent: () => current }, 'People.base') + if (kind === 'host') current = false + if (kind === 'missing') + s.useStore.setState({ + folders: s.useStore.getState().folders.filter((row) => row.subpath !== 'People.base') + }) + s.answer('inbox/Work') + expect(await result).toBe('stale') + expect(s.rename).not.toHaveBeenCalled() + } + ) + + it('rejects a host refusal to move and releases the pending action', async () => { + const s = await setup() + s.rename.mockRejectedValueOnce(new Error('A folder already exists at "Work/People.base"')) + const failed = s.requestMoveBrowseDirectory(s.host, 'People.base') + s.answer('inbox/Work') + await expect(failed).rejects.toThrow('already exists') + const next = s.requestMoveBrowseDirectory(s.host, 'People.base') + s.answer(null) + expect(await next).toBe('cancelled') + }) + }) diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts index d5b46f5a..6af0e1f6 100644 --- a/packages/app-core/src/browse.ts +++ b/packages/app-core/src/browse.ts @@ -178,6 +178,7 @@ export { requestRenameBrowseDatabase, requestCreateBrowseFolder, requestRenameBrowseFolder, + requestMoveBrowseDirectory, requestDeleteBrowseDirectory, type BrowseActionHost, type BrowseActionResult diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index 5df5281a..e6fce104 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -111,7 +111,15 @@ function dotsFor(stats: NoteStats | undefined): { count: number; faint: boolean return { count: Math.min(MAX_DOTS, Math.ceil(stats.words / WORDS_PER_DOT)), faint: false } } -export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { +export function CalendarPanel({ + note, + fitWidth +}: { + note: NoteContent + /** Width to render at when the pane has less room than the width the user + * chose; see lib/side-panel-fit. (#805) */ + fitWidth?: number +}): JSX.Element { const notes = useStore((s) => s.notes) const vaultSettings = useStore((s) => s.vaultSettings) const openDailyNoteForDate = useStore((s) => s.openDailyNoteForDate) @@ -130,7 +138,7 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { const setPanelWidth = useStore((s) => s.setPanelWidth) const weekStart = useStore((s) => s.calendarWeekStart) const showWeekNumbers = useStore((s) => s.calendarShowWeekNumbers) - const { startResize } = usePanelResize(width, (px) => setPanelWidth('calendar', px)) + const { startResize } = usePanelResize(fitWidth ?? width, (px) => setPanelWidth('calendar', px)) const settings = useMemo(() => normalizeVaultSettings(vaultSettings), [vaultSettings]) const dailyEnabled = settings.dailyNotes.enabled @@ -860,7 +868,7 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { data-calendar-panel aria-label="Calendar" tabIndex={0} - style={{ width }} + style={{ width: fitWidth ?? width }} className="relative flex shrink-0 flex-col border-l border-paper-300/70 bg-paper-50/18 outline-none" > diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 811e4ade..60c8ed91 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -14,7 +14,10 @@ import { import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' -import { canReturnToCommandList } from '../lib/command-palette-mode' +import { + canReturnToCommandList, + shouldRefocusEditorAfterCommand +} from '../lib/command-palette-mode' import { THEMES, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes' import { buildVaultSwitcherEntries, @@ -26,6 +29,7 @@ import { runWorkflowById } from '../lib/workflow-trigger' import type { WorkflowIndexEntry } from '../lib/workflow-index' import { focusEditorNormalMode } from '../lib/editor-focus' import { useCloudSyncStatusStore } from '../lib/cloud-auto-sync' +import { getPublishNoteRequest } from '../lib/publish-note-requests' import { Modal } from './ui/Modal' type Mode = 'main' | 'theme' | 'vault' | 'workflow' @@ -304,16 +308,16 @@ export function CommandPalette(): JSX.Element { // explorer), and the editor's own focus-on-`focusedPanel` effect is a // single, no-retry `view.focus()` that races the palette unmount. Mirror // closePalette's focus restore; the retry wins that race. Skipped when the - // command opened the Settings modal so we don't pull focus behind it, and - // likewise for the Cloud conflict queue, whose dialog claims focus itself. - const s = useStore.getState() + // command opened Settings or another palette (search, vault text search, + // outline, …) so we don't pull focus behind it, and likewise for the Cloud + // conflict queue and the Publish Note dialog, which claim focus themselves + // and are tracked outside the store. if ( - s.focusedPanel === 'editor' && - !s.settingsOpen && - !s.embedDrawingPaletteOpen && - !s.templatePaletteOpen && - !s.bufferPaletteOpen && - !useCloudSyncStatusStore.getState().conflictReviewOpen + shouldRefocusEditorAfterCommand( + useStore.getState(), + useCloudSyncStatusStore.getState().conflictReviewOpen || + getPublishNoteRequest() !== null + ) ) focusEditorNormalMode() } catch (err) { diff --git a/packages/app-core/src/components/CommentsPanel.tsx b/packages/app-core/src/components/CommentsPanel.tsx index 0c51d180..5e945b79 100644 --- a/packages/app-core/src/components/CommentsPanel.tsx +++ b/packages/app-core/src/components/CommentsPanel.tsx @@ -31,6 +31,9 @@ export interface CommentDraft { } interface Props { + /** Width to render at when the pane has less room than the width the user + * chose; see lib/side-panel-fit. (#805) */ + fitWidth?: number note: NoteContent draft: CommentDraft | null onCaptureDraft: () => CommentDraft | null @@ -60,7 +63,8 @@ export function CommentsPanel({ draft, onCaptureDraft, onClearDraft, - onJump + onJump, + fitWidth }: Props): JSX.Element { const comments = useStore((s) => s.noteComments[note.path] ?? EMPTY_COMMENTS) const activeCommentId = useStore((s) => s.activeCommentId) @@ -73,7 +77,9 @@ export function CommentsPanel({ const focusedPanel = useStore((s) => s.focusedPanel) const panelWidth = useStore((s) => s.panelWidths.comments) const setPanelWidth = useStore((s) => s.setPanelWidth) - const { startResize } = usePanelResize(panelWidth, (px) => setPanelWidth('comments', px)) + const { startResize } = usePanelResize(fitWidth ?? panelWidth, (px) => + setPanelWidth('comments', px) + ) const [body, setBody] = useState('') const [editingId, setEditingId] = useState(null) @@ -220,7 +226,7 @@ export function CommentsPanel({ tabIndex={-1} onMouseDownCapture={() => setFocusedPanel('comments')} onFocusCapture={() => setFocusedPanel('comments')} - style={{ width: panelWidth }} + style={{ width: fitWidth ?? panelWidth }} className={[ 'relative flex shrink-0 flex-col border-l border-paper-300/70 bg-paper-50/24 shadow-[inset_1px_0_0_rgb(var(--z-bg)/0.25)] outline-none transition-shadow', commentsFocused ? 'ring-1 ring-inset ring-accent/18' : '' diff --git a/packages/app-core/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx index 66290a3d..b78a3520 100644 --- a/packages/app-core/src/components/ConnectionsPanel.tsx +++ b/packages/app-core/src/components/ConnectionsPanel.tsx @@ -33,7 +33,15 @@ interface MissingLinkItem { suggestedPath: string } -export function ConnectionsPanel({ note }: { note: NoteContent }): JSX.Element { +export function ConnectionsPanel({ + note, + fitWidth +}: { + note: NoteContent + /** Width to render at when the pane has less room than the width the user + * chose; see lib/side-panel-fit. (#805) */ + fitWidth?: number +}): JSX.Element { const notes = useStore((s) => s.notes) const selectNote = useStore((s) => s.selectNote) const createAndOpen = useStore((s) => s.createAndOpen) @@ -41,7 +49,9 @@ export function ConnectionsPanel({ note }: { note: NoteContent }): JSX.Element { const assetFiles = useStore((s) => s.assetFiles) const panelWidth = useStore((s) => s.panelWidths.connections) const setPanelWidth = useStore((s) => s.setPanelWidth) - const { startResize } = usePanelResize(panelWidth, (px) => setPanelWidth('connections', px)) + const { startResize } = usePanelResize(fitWidth ?? panelWidth, (px) => + setPanelWidth('connections', px) + ) const focusedPanel = useStore((s) => s.focusedPanel) const connectionsCursorIndex = useStore((s) => s.connectionsCursorIndex) const connectionPreview = useStore((s) => s.connectionPreview) @@ -242,7 +252,7 @@ export function ConnectionsPanel({ note }: { note: NoteContent }): JSX.Element { cancelScheduledClose() setFocusedPanel('connections') }} - style={{ width: panelWidth }} + style={{ width: fitWidth ?? panelWidth }} className="relative flex shrink-0 flex-col border-l border-paper-300/70 bg-paper-50/18" > diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 5492d1ed..4e6ac8d7 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -562,6 +562,22 @@ function registerVimCommands(): void { setImageWidthFromInput(view, arg); }, ); + // `:set undofile` / `:set noundofile` / `:set undofile?` (alias `udf`) is + // the ex twin of "Keep undo history after quitting", under the name Vim + // users already type. Only where the host can keep the files. (#793) + Vim.defineOption( + "undofile", + false, + "boolean", + ["udf"], + (value?: boolean) => { + const state = useStore.getState(); + if (value === undefined) return state.persistUndoHistory; + if (!window.zen?.getCapabilities?.().supportsUndoFile) return undefined; + if (state.persistUndoHistory !== !!value) state.setPersistUndoHistory(!!value); + return undefined; + }, + ); // `:harper on|off` (or bare `:harper` to toggle) is the ex twin of the // Settings toggle "Grammar and spelling with Harper". Vim.defineEx( diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index cb48e070..38d3b13c 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -14,7 +14,8 @@ import { useLayoutEffect, useMemo, useRef, - useState + useState, + type SetStateAction } from 'react' import { Annotation, @@ -42,14 +43,15 @@ import type { AssetMeta, ImportedAsset, NoteComment, NoteFolder } from '@shared/ import { registerNoteEditor } from '../lib/note-editor-context' import { noteEditorHostExtension } from '../lib/editor-host' import { - history, historyKeymap, indentWithTab, moveLineDown, moveLineUp, redo, + redoDepth, selectAll, - undo + undo, + undoDepth } from '@codemirror/commands' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { isImeComposing } from '../lib/ime' @@ -99,7 +101,7 @@ import { } from '../lib/cm-heading-fold' import { tags as t } from '@lezer/highlight' import { autocompletion } from '@codemirror/autocomplete' -import { useStore } from '../store' +import { MIN_RIGHT_PANEL_WIDTH, useStore } from '../store' import type { LineNumberMode } from '../store' import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' @@ -204,6 +206,7 @@ import { } from '../lib/editor-hydration' import { recordRendererPerf } from '../lib/perf' import { + forgetTabScroll, rememberTabScroll, recallTabScroll, type TabScrollPosition @@ -273,12 +276,32 @@ import { import { resolveCommentAnchor, selectionToCommentAnchor } from '../lib/comments' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT } from '../lib/keyboard-context-menu' import { armMiddleClickPasteGuard } from '../lib/middle-click-paste-guard' +import { isWorkspaceVirtualTabPath } from '../lib/workspace-tabs' +import { + followPathRewritesInNoteUndoHistories, + noteUndoHistoryFor, + noteUndoHistoryKey, + setAsideNoteUndoHistory +} from '../lib/note-undo-history' +import { noteUndoHistoryFromFile, serializeNoteUndoHistory } from '../lib/note-undo-file' +import { latestPathRewriteSeq, pathAfterRewrites } from '../lib/path-rewrites' +import { minimalTextChange } from '../lib/minimal-text-change' +import { + MIN_NOTE_WIDTH, + MIN_SPLIT_NOTE_WIDTH, + bumpSidePanel, + fitSidePanels, + syncSidePanelRecency, + type SidePanelId +} from '../lib/side-panel-fit' +import { TuckedPanelsRail } from './TuckedPanelsRail' import { CALENDAR_PANEL_CLOSED, calendarPanelOnNote, calendarPanelOnToggle, type CalendarPanelState } from '../lib/calendar-panel-auto' +import { usePanePanels } from '../lib/use-pane-panels' import { assetPathFromTab, assetTitleFromPath, @@ -891,11 +914,61 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { keepViewModeAcrossNotes && paneStickyMode ? paneStickyMode : paneModeForPath(modesByPath, activeTab, defaultPaneMode) - const [connectionsOpen, setConnectionsOpen] = useState(false) - const [outlineOpen, setOutlineOpen] = useState(false) + // One sticky set per pane, or one per note when "Keep panels when switching + // notes" is off (#794). Same names and setter shape as the four `useState`s + // this replaced, so every toggle and auto-open below is unchanged. + const { + connections: connectionsOpen, + outline: outlineOpen, + comments: commentsOpen, + calendar: calendarPanel, + calendarAutoOpenAllowed, + setConnectionsOpen: setConnectionsOpenRaw, + setOutlineOpen: setOutlineOpenRaw, + setCommentsOpen: setCommentsOpenRaw, + setCalendarPanel: setCalendarPanelRaw + } = usePanePanels(paneId, activeTab) + // Which panel was asked for last, most recent first. In a pane too narrow + // for all the open panels the most recent ones are shown and the rest are + // tucked into a rail, see lib/side-panel-fit. (#805) + const [sidePanelRecency, setSidePanelRecency] = useState([]) + const tuckedSidePanelsRef = useRef([]) + const revealSidePanel = useCallback((id: SidePanelId) => { + setSidePanelRecency((recency) => bumpSidePanel(recency, id)) + }, []) + // Opening a panel that is already open has to bring it forward, or code that + // asks for a tucked panel (jumping to a comment opens Comments) would get + // nothing. A panel that goes from closed to open is picked up by the effect + // that keeps the recency in line with what is open. + const setConnectionsOpen = useCallback( + (next: SetStateAction) => { + if (next === true) revealSidePanel('connections') + setConnectionsOpenRaw(next) + }, + [revealSidePanel, setConnectionsOpenRaw] + ) + const setOutlineOpen = useCallback( + (next: SetStateAction) => { + if (next === true) revealSidePanel('outline') + setOutlineOpenRaw(next) + }, + [revealSidePanel, setOutlineOpenRaw] + ) + const setCommentsOpen = useCallback( + (next: SetStateAction) => { + if (next === true) revealSidePanel('comments') + setCommentsOpenRaw(next) + }, + [revealSidePanel, setCommentsOpenRaw] + ) + const setCalendarPanel = useCallback( + (next: SetStateAction) => { + if (typeof next !== 'function' && next.open) revealSidePanel('calendar') + setCalendarPanelRaw(next) + }, + [revealSidePanel, setCalendarPanelRaw] + ) const [activeOutlineLine, setActiveOutlineLine] = useState(null) - const [commentsOpen, setCommentsOpen] = useState(false) - const [calendarPanel, setCalendarPanel] = useState(CALENDAR_PANEL_CLOSED) const calendarOpen = calendarPanel.open // The calendar panel is a date navigator. It auto-opens while the pane shows // a daily/weekly note, but stays available (Obsidian-style) on any note as @@ -973,6 +1046,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const tabSizeCompartmentRef = useRef(null) // history() lives in a compartment so we can reset undo history on a note // switch — otherwise Cmd+Z crosses notes and overwrites the current one (#247). + // 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) @@ -997,6 +1072,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { * sync effect updates it whenever we swap the view's document. */ const viewPathRef = useRef(null) + /** The newest entry of the store's `recentPathRewrites` this editor has + * accounted for. Only newer ones can explain a path change as a rename, so + * an old rename can never make a real note switch look like one. */ + const seenPathRewriteSeqRef = useRef(0) const updateSelectionCommentAction = useCallback((view: EditorView | null = viewRef.current): void => { setSelectionCommentAction(view ? getSelectionCommentAction(view) : null) @@ -1050,6 +1129,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }, []) const toggleConnectionsPanel = useCallback(() => { + // Open but tucked away: the key that would close it shows it instead. + if (tuckedSidePanelsRef.current.includes('connections')) { + revealSidePanel('connections') + return + } setConnectionsOpen((open) => { const next = !open if (!next) { @@ -1060,18 +1144,26 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } return next }) - }, [focusedPanel, setConnectionPreview, setFocusedPanel]) + }, [focusedPanel, revealSidePanel, setConnectionPreview, setConnectionsOpen, setFocusedPanel]) + + // The panel shortcuts act on the note this pane is showing. On a view tab + // (Trash, Tasks, Help, an asset) there is no panel to see, and the toggle + // used to flip the pane's panels anyway, so they turned up on the next note + // without having been asked for. Keyed on the kind of tab, not on loaded + // content, so a shortcut pressed while a note is still loading is kept. + const panelShortcutsApply = + isActive && activeTab != null && !isWorkspaceVirtualTabPath(activeTab) // ⌘2 toggles the connections panel — only the active pane responds so // the shortcut targets the pane the user is currently working in. useEffect(() => { - if (!isActive) return + if (!panelShortcutsApply) return const handler = (): void => { toggleConnectionsPanel() } window.addEventListener('zen:toggle-connections', handler) return () => window.removeEventListener('zen:toggle-connections', handler) - }, [isActive, toggleConnectionsPanel]) + }, [panelShortcutsApply, toggleConnectionsPanel]) // Mirror `set clipboard=unnamed`: when enabled, Vim yank/delete/change also // copy to the system clipboard, and `p` / `P` paste from it. The patch is @@ -1084,16 +1176,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }, [vimYankToClipboard]) const toggleOutlinePanel = useCallback(() => { + if (tuckedSidePanelsRef.current.includes('outline')) return revealSidePanel('outline') setOutlineOpen((open) => !open) - }, []) + }, [revealSidePanel, setOutlineOpen]) const toggleCommentsPanel = useCallback(() => { + if (tuckedSidePanelsRef.current.includes('comments')) return revealSidePanel('comments') setCommentsOpen((open) => !open) - }, []) + }, [revealSidePanel, setCommentsOpen]) const toggleCalendarPanel = useCallback(() => { + if (tuckedSidePanelsRef.current.includes('calendar')) return revealSidePanel('calendar') setCalendarPanel(calendarPanelOnToggle) - }, []) + }, [revealSidePanel, setCalendarPanel]) const applyPaneMode = useCallback((nextMode: PaneMode) => { @@ -1128,32 +1223,32 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // `zen:toggle-outline` — routed only to the active pane, same pattern // as the connections toggle. useEffect(() => { - if (!isActive) return + if (!panelShortcutsApply) return const handler = (): void => { toggleOutlinePanel() } window.addEventListener('zen:toggle-outline', handler) return () => window.removeEventListener('zen:toggle-outline', handler) - }, [isActive, toggleOutlinePanel]) + }, [panelShortcutsApply, toggleOutlinePanel]) useEffect(() => { - if (!isActive) return + if (!panelShortcutsApply) return const handler = (): void => { toggleCommentsPanel() } window.addEventListener('zen:toggle-comments', handler) return () => window.removeEventListener('zen:toggle-comments', handler) - }, [isActive, toggleCommentsPanel]) + }, [panelShortcutsApply, toggleCommentsPanel]) // `zen:toggle-calendar` — same active-pane routing as the panels above. useEffect(() => { - if (!isActive) return + if (!panelShortcutsApply) return const handler = (): void => { toggleCalendarPanel() } window.addEventListener('zen:toggle-calendar', handler) return () => window.removeEventListener('zen:toggle-calendar', handler) - }, [isActive, toggleCalendarPanel]) + }, [panelShortcutsApply, toggleCalendarPanel]) // `zen:close-right-panel` — Esc (when a right panel is focused) or the // "Close right panel" command dismiss whichever right-hand panel is open in @@ -1186,11 +1281,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { setCalendarPanel((state) => calendarPanelOnNote(state, { isDateNote, - autoEnabled: autoCalendarPanel, + // With panels kept per note, a calendar the user closed on this note + // stays closed when they come back to it. (#794) + autoEnabled: autoCalendarPanel && calendarAutoOpenAllowed, available: calendarAvailable }) ) - }, [content?.path, isDateNote, autoCalendarPanel, calendarAvailable]) + }, [content?.path, isDateNote, autoCalendarPanel, calendarAutoOpenAllowed, calendarAvailable]) useEffect(() => { if (!isActive) return @@ -1687,6 +1784,16 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { setSelectionCommentAction(null) const existingView = viewRef.current rememberCurrentTabScroll() + // The editor is torn down whenever the pane shows something that is + // not a note (Trash, Tasks, an asset), so this is a way of leaving a + // note too. (#793) + if (existingView && viewPathRef.current) { + setAsideNoteUndoHistory( + noteUndoHistoryKey(useStore.getState().vault?.root, viewPathRef.current), + existingView.state + ) + saveNoteUndoFile(viewPathRef.current, existingView.state) + } if ( existingView && useStore.getState().editorViewRef === existingView @@ -1731,6 +1838,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { richMarkdownDeferredRef.current = deferInitialRichMarkdown const stateStartedAt = performance.now() viewPathRef.current = initialPath + // A new editor starts on its note, so no earlier rename concerns it. + seenPathRewriteSeqRef.current = latestPathRewriteSeq(s0.recentPathRewrites) + followPathRewritesInNoteUndoHistories(s0.recentPathRewrites) const state = EditorState.create({ doc: initialBody, extensions: [ @@ -1743,7 +1853,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { vimImeGuard( () => useStore.getState().vimBlockImeInNormalMode && !isTouchPrimaryDevice() ), - historyCompartment.of(history()), + historyCompartment.of( + noteUndoHistoryFor( + initialPath ? noteUndoHistoryKey(s0.vault?.root, initialPath) : null, + initialBody + ) + ), drawSelectionCompartment.of( drawSelection({ cursorBlinkRate: s0.cursorBlink ? 1200 : 0 }) ), @@ -1986,6 +2101,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }) viewRef.current = view viewPathRef.current = initialPath + loadNoteUndoFile( + view, + historyCompartment, + initialPath, + () => viewRef.current === view && viewPathRef.current === initialPath + ) registerNoteEditor(view, () => viewPathRef.current, paneId) if (initialContent && useStore.getState().activePaneId === paneId) { setEditorViewRef(view) @@ -2025,6 +2146,18 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ] ) + // The note on screen is never left, so nothing above would save its undo + // history when the app quits. The host finishes the write after the window + // is gone, the same way it finishes the note saves fired from here. (#793) + useEffect(() => { + const save = (): void => { + const view = viewRef.current + if (view) saveNoteUndoFile(viewPathRef.current, view.state) + } + window.addEventListener('beforeunload', save) + return () => window.removeEventListener('beforeunload', save) + }, []) + // Register our view as the focused editor whenever our pane is active. useEffect(() => { const view = viewRef.current @@ -2054,12 +2187,41 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (!view) return const nextPath = content?.path ?? null const nextBody = content?.body ?? '' - const pathChanged = viewPathRef.current !== nextPath + const prevPath = viewPathRef.current + const pathChanged = prevPath !== nextPath + const vaultRoot = useStore.getState().vault?.root ?? '' + // A new path is not always a new note. When the note on screen was renamed + // or moved (or its folder was), this editor is already showing the right + // document, and treating it as a tab switch threw the caret to the top, + // reset the scroll and dropped the undo history of a note nobody left. + // The store logs every such rewrite in the same update that changes the + // path, so a path change it explains is the same note. + const rewrites = useStore.getState().recentPathRewrites + const renamed = + pathChanged && + prevPath !== null && + nextPath !== null && + pathAfterRewrites(rewrites, vaultRoot, prevPath, seenPathRewriteSeqRef.current) === nextPath + seenPathRewriteSeqRef.current = latestPathRewriteSeq(rewrites) + const switched = pathChanged && !renamed const bodyChanged = - pathChanged || + switched || view.state.doc.length !== nextBody.length || view.state.doc.toString() !== nextBody if (!pathChanged && !bodyChanged) return + followPathRewritesInNoteUndoHistories(rewrites) + if (renamed && prevPath && nextPath) { + // The remembered caret and scroll follow the note. The restore effect + // below must not run for this path change at all: it re-applies the + // remembered offsets on the next frame too, by which time the rename's + // heading rewrite has usually shifted the text under them. + const remembered = recallTabScroll(prevPath) + if (remembered) rememberTabScroll(nextPath, remembered) + forgetTabScroll(prevPath) + lastRestoredPathRef.current = nextPath + // A history saved under the old name would never be asked for again. + forgetNoteUndoFile(prevPath) + } if (deferredLivePreviewTimerRef.current != null) { clearTimeout(deferredLivePreviewTimerRef.current) deferredLivePreviewTimerRef.current = null @@ -2074,7 +2236,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const livePreviewCompartment = livePreviewCompartmentRef.current const livePreviewEnabled = useStore.getState().livePreview const deferRichMarkdown = - pathChanged && + switched && nextBody.length >= LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS && !livePreviewEnabled && !!markdownCompartment && @@ -2104,33 +2266,71 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } } const dispatchStartedAt = performance.now() + // While the editor still holds the outgoing note: its undo history is set + // aside under that note, to be handed back when it returns. (#793) + if (switched && prevPath) { + setAsideNoteUndoHistory(noteUndoHistoryKey(vaultRoot, prevPath), view.state) + saveNoteUndoFile(prevPath, view.state) + } viewPathRef.current = nextPath refreshNoteEditingLock(view) - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: nextBody }, - annotations: [ - noteEditingSync.of(true), - programmatic.of(true), - skipOrderedListRenumber.of(true), - // A programmatic swap (tab switch / external file sync) must never be - // undoable — otherwise Cmd+Z reverts the editor to the other document - // and the resulting change saves it over the current note (#247). - Transaction.addToHistory.of(false) - ], - effects: effects.length > 0 ? effects : undefined, - selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead } - }) - if (pathChanged) { + // The same note changed underneath the editor (another pane typed, a + // rename rewrote its title heading or a link, the file changed on disk): + // say only what changed. A whole-document replace makes CodeMirror map the + // caret and every undo step through "everything", which clamps the one and + // empties the other. Text with carriage returns keeps the whole replace: + // CodeMirror folds `\r\n` into one line break, so offsets in it are not + // offsets in the document. + const inPlaceChange = + !switched && bodyChanged && !nextBody.includes('\r') + ? minimalTextChange(view.state.doc.toString(), nextBody) + : null + if (bodyChanged || effects.length > 0) { + view.dispatch({ + changes: !bodyChanged + ? undefined + : inPlaceChange ?? { from: 0, to: view.state.doc.length, insert: nextBody }, + annotations: [ + noteEditingSync.of(true), + programmatic.of(true), + skipOrderedListRenumber.of(true), + // A programmatic swap (tab switch / external file sync) must never be + // undoable: otherwise Cmd+Z reverts the editor to the other document + // and the resulting change saves it over the current note (#247). + Transaction.addToHistory.of(false) + ], + effects: effects.length > 0 ? effects : undefined, + // A small change carries the selection along by itself. + selection: switched + ? { anchor: 0 } + : inPlaceChange || !bodyChanged + ? undefined + : { anchor: clampedAnchor, head: clampedHead } + }) + } + if (switched) { // Switching notes: also drop the previous note's undo history so undo // can't cross the boundary at all. There's no "clear history" command, so - // remove the history field then re-add it empty. (#247) + // remove the history field then re-add it (#247): empty, or holding the + // incoming note's own history if it was set aside and the note still + // reads as it did then (#793). const historyCompartment = historyCompartmentRef.current if (historyCompartment) { view.dispatch({ effects: historyCompartment.reconfigure([]) }) - view.dispatch({ effects: historyCompartment.reconfigure(history()) }) + view.dispatch({ + effects: historyCompartment.reconfigure( + noteUndoHistoryFor(nextPath ? noteUndoHistoryKey(vaultRoot, nextPath) : null, nextBody) + ) + }) + loadNoteUndoFile( + view, + historyCompartment, + nextPath, + () => viewRef.current === view && viewPathRef.current === nextPath + ) } } - if (pathChanged && pendingJumpLocation?.path !== nextPath) { + if (switched && pendingJumpLocation?.path !== nextPath) { // Clear scroll on a genuine tab switch; the activation effect below // restores a remembered position afterward when there is one. view.scrollDOM.scrollTop = 0 @@ -2139,14 +2339,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { recordRendererPerf('editor.doc.sync', performance.now() - dispatchStartedAt, { chars: nextBody.length, deferred: deferRichMarkdown, - pathChanged + pathChanged: switched }) requestAnimationFrame(() => { requestAnimationFrame(() => { recordRendererPerf('editor.doc.paint-latency', performance.now() - dispatchStartedAt, { chars: nextBody.length, deferred: deferRichMarkdown, - pathChanged + pathChanged: switched }) }) }) @@ -3303,6 +3503,68 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { [comments] ) + // The note keeps a readable width; the panels share what is left. (#805) + const paneRowRef = useRef(null) + const [paneRowWidth, setPaneRowWidth] = useState(0) + useLayoutEffect(() => { + const row = paneRowRef.current + if (!row) return + const measure = (): void => setPaneRowWidth(Math.round(row.getBoundingClientRect().width)) + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(row) + return () => observer.disconnect() + }, []) + const sidePanelWidths = useStore((s) => s.panelWidths) + const openSidePanels = useMemo(() => { + const open: SidePanelId[] = [] + if (!content || zenMode) return open + if (connectionsOpen && isActive) open.push('connections') + if (commentsOpen) open.push('comments') + if (outlineOpen) open.push('outline') + if (calendarOpen && calendarAvailable) open.push('calendar') + return open + }, [ + calendarAvailable, + calendarOpen, + commentsOpen, + connectionsOpen, + content, + isActive, + outlineOpen, + zenMode + ]) + useEffect(() => { + setSidePanelRecency((recency) => syncSidePanelRecency(recency, openSidePanels)) + }, [openSidePanels]) + const sidePanelFit = useMemo( + () => + fitSidePanels( + paneRowWidth, + mode === 'split' ? MIN_SPLIT_NOTE_WIDTH : MIN_NOTE_WIDTH, + // Synced here as well as in the effect above, so the render in which + // a panel opens already treats it as the most recent one. + syncSidePanelRecency(sidePanelRecency, openSidePanels).map((id) => ({ + id, + width: sidePanelWidths[id] + })), + MIN_RIGHT_PANEL_WIDTH + ), + [mode, openSidePanels, paneRowWidth, sidePanelRecency, sidePanelWidths] + ) + tuckedSidePanelsRef.current = sidePanelFit.tucked + const sidePanelShown = (id: SidePanelId): boolean => + openSidePanels.includes(id) && !sidePanelFit.tucked.includes(id) + // A panel that is tucked away while it has the keyboard would leave the keys + // going nowhere, so they go back to the note. + useEffect(() => { + if (!isActive) return + if (!focusedPanel || !(sidePanelFit.tucked as readonly string[]).includes(focusedPanel)) return + setFocusedPanel('editor') + viewRef.current?.focus() + }, [focusedPanel, isActive, setFocusedPanel, sidePanelFit.tucked]) + const toolbar = useMemo(() => { if (!content) return null const folder = content.folder @@ -3310,6 +3572,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // Markdown-specific controls (edit/split/preview, connections, comments, // outline, calendar, PDF export) don't apply to a canvas. const isDrawing = isExcalidrawPath(content.path) + // A tucked panel is open, but the button brings it forward rather than + // closing it, so its tooltip has to say so. + const tucked = sidePanelFit.tucked return (
{!isDrawing && ( @@ -3317,7 +3582,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
@@ -3325,7 +3594,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { 0 ? ` (${openCommentCount})` : ''}` } @@ -3335,7 +3604,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { @@ -3343,7 +3612,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {calendarAvailable && ( @@ -3395,6 +3666,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { calendarAvailable, calendarOpen, toggleCalendarPanel, + sidePanelFit.tucked, trashActive, deleteActivePermanently, archiveActive, @@ -3795,7 +4067,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {toolbar} )} -
+
)}
- {content && connectionsOpen && isActive && !zenMode && } - {content && commentsOpen && !zenMode && ( + {content && sidePanelShown('connections') && ( + + )} + {content && sidePanelShown('comments') && ( )} - {content && outlineOpen && !zenMode && ( + {content && sidePanelShown('outline') && ( )} - {content && calendarOpen && calendarAvailable && !zenMode && ( - + {content && sidePanelShown('calendar') && ( + )} + { + revealSidePanel(id) + // The button that was clicked is gone once its panel is showing, + // and focus must not fall to the page body with it. + viewRef.current?.focus() + }} + />
{content && showEditor && @@ -4303,6 +4588,60 @@ function EmptyPaneState({ ) } +/** + * Undo history between launches (Vim's `undofile`, #793): only with the + * setting on, and only on a host that can keep it somewhere that is not the + * vault. Everything here is fire and forget; a note whose history cannot be + * saved or read simply starts a clean one next time. + */ +function noteUndoFileEnabled(): boolean { + return useStore.getState().persistUndoHistory && !!window.zen?.writeNoteUndoHistory +} + +/** + * Save the history of the note `state` shows. With nothing to undo there is + * nothing to write, and nothing is erased either: the same note can be open in + * a second pane whose history was just saved, and a file that no longer fits + * the text is ignored when it is read and replaced by the next real save. + */ +function saveNoteUndoFile(path: string | null, state: EditorState): void { + if (!path || !noteUndoFileEnabled()) return + const saved = serializeNoteUndoHistory(state) + if (saved === null) return + void window.zen.writeNoteUndoHistory?.(path, saved)?.catch(() => undefined) +} + +function forgetNoteUndoFile(path: string | null): void { + if (!path || !noteUndoFileEnabled()) return + void window.zen.writeNoteUndoHistory?.(path, null)?.catch(() => undefined) +} + +/** + * Hand a note the history it had when the app last quit. It only applies while + * `view` still shows that note with nothing to undo yet: a history kept in + * memory, or an edit made while the file was being read, wins. + */ +function loadNoteUndoFile( + view: EditorView, + compartment: Compartment | null, + path: string | null, + stillShowing: () => boolean +): void { + if (!path || !compartment || !noteUndoFileEnabled()) return + if (undoDepth(view.state) > 0 || redoDepth(view.state) > 0) return + void window.zen + .readNoteUndoHistory?.(path) + ?.then((saved) => { + if (!saved || !stillShowing()) return + if (undoDepth(view.state) > 0 || redoDepth(view.state) > 0) return + const restored = noteUndoHistoryFromFile(saved, view.state.doc.toString()) + if (!restored) return + view.dispatch({ effects: compartment.reconfigure([]) }) + view.dispatch({ effects: compartment.reconfigure(restored) }) + }) + .catch(() => undefined) +} + function IconBtn({ children, onClick, diff --git a/packages/app-core/src/components/OutlinePanel.tsx b/packages/app-core/src/components/OutlinePanel.tsx index 40e163f0..4707bd12 100644 --- a/packages/app-core/src/components/OutlinePanel.tsx +++ b/packages/app-core/src/components/OutlinePanel.tsx @@ -20,6 +20,9 @@ import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' interface Props { + /** Width to render at when the pane has less room than the width the user + * chose; see lib/side-panel-fit. (#805) */ + fitWidth?: number note: NoteContent /** 1-based line of the heading the host wants visually marked. */ activeLine?: number | null @@ -27,7 +30,7 @@ interface Props { onJump: (line: number) => void } -export function OutlinePanel({ note, activeLine, onJump }: Props): JSX.Element { +export function OutlinePanel({ note, activeLine, onJump, fitWidth }: Props): JSX.Element { const items = useMemo(() => parseOutline(note.body), [note.body]) const [query, setQuery] = useState('') const activeItemRef = useRef(null) @@ -36,7 +39,7 @@ export function OutlinePanel({ note, activeLine, onJump }: Props): JSX.Element { const focusedPanel = useStore((s) => s.focusedPanel) const cursorIndex = useStore((s) => s.outlineCursorIndex) const setCursorIndex = useStore((s) => s.setOutlineCursorIndex) - const { startResize } = usePanelResize(width, (px) => setPanelWidth('outline', px)) + const { startResize } = usePanelResize(fitWidth ?? width, (px) => setPanelWidth('outline', px)) const isOutlineFocused = focusedPanel === 'outline' // Reset the filter when the note changes so the outline reflects the @@ -68,7 +71,7 @@ export function OutlinePanel({ note, activeLine, onJump }: Props): JSX.Element {
diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 4b70abef..80b1e33b 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -130,8 +130,15 @@ import companyLogo from "../assets/lumary-labs-logo.svg"; import { confirmApp } from "../lib/confirm-requests"; import { promptApp } from "../lib/prompt-requests"; import { isImeComposing } from "../lib/ime"; +import { + isSettingsFindKey, + settingsSearchFieldAction, + settingsSearchStep, +} from "../lib/settings-search-keys"; import { RemoteWorkspaceProfileModal } from "./RemoteWorkspaceProfileModal"; import { Button } from "./ui/Button"; +import { trapDialogTab, useDialogFocus } from "./ui/Modal"; +import { isTouchPrimaryDevice } from "../lib/cm-vim-ime-guard"; import { ignoredKeyTokenFromEvent, setIgnoredKeysRecorderActive, @@ -509,6 +516,10 @@ export function SettingsModal(): JSX.Element { const keepViewModeAcrossNotes = useStore((s) => s.keepViewModeAcrossNotes); const defaultPaneMode = useStore((s) => s.defaultPaneMode); const setDefaultPaneMode = useStore((s) => s.setDefaultPaneMode); + const keepPanelsAcrossNotes = useStore((s) => s.keepPanelsAcrossNotes); + const setKeepPanelsAcrossNotes = useStore((s) => s.setKeepPanelsAcrossNotes); + const persistUndoHistory = useStore((s) => s.persistUndoHistory); + const setPersistUndoHistory = useStore((s) => s.setPersistUndoHistory); const setKeepViewModeAcrossNotes = useStore( (s) => s.setKeepViewModeAcrossNotes, ); @@ -618,6 +629,9 @@ export function SettingsModal(): JSX.Element { : zenBridge.getCapabilities().supportsCustomTemplates === true; const supportsCustomCodeLanguages = !!zenBridge.getCapabilities().supportsCustomCodeLanguages; + // Undo history between launches needs somewhere machine-local that is not + // the vault, which only the desktop app has. (#793) + const supportsUndoFile = !!zenBridge.getCapabilities().supportsUndoFile; const [templateEditor, setTemplateEditor] = useState<{ initialRaw?: string; sourcePath?: string; @@ -1192,6 +1206,14 @@ export function SettingsModal(): JSX.Element { }; const ref = useRef(null); + const navSearchRef = useRef(null); + // Settings draws its own backdrop and panel, so it never got the focus + // handling the shared Modal shell gives every other dialog: the keyboard + // stayed on the editor underneath and typing edited the note behind the + // open window. Opening lands on the settings search, the first thing a + // keyboard user reaches for. On a touch device a focused input would raise + // the on-screen keyboard over the panel, so the panel takes focus instead. + useDialogFocus(ref, isTouchPrimaryDevice() ? ref : navSearchRef); const settingsSearchHighlightTimerRef = useRef(null); const [initialSettingsTarget] = useState(consumeSettingsTarget); const [activeCategory, setActiveCategory] = useState( @@ -2031,6 +2053,50 @@ export function SettingsModal(): JSX.Element { "loose", ], }, + { + id: "keep-view-mode", + title: "Keep view mode when switching notes", + description: + "Stay in the current Edit / Split / Preview mode when you open another note.", + keywords: ["view mode", "edit", "split", "preview", "sticky", "switch", "per note"], + }, + { + id: "keep-panels", + title: "Keep panels when switching notes", + description: + "Connections, Outline, Comments and Calendar stay as you set them, or each note remembers its own.", + keywords: [ + "panels", + "connections", + "outline", + "comments", + "calendar", + "sticky", + "per note", + "remember", + "switch", + ], + }, + ...(supportsUndoFile + ? [ + { + id: "persist-undo-history", + title: "Keep undo history after quitting", + description: + "Undo still works on a note after you quit and reopen ZenNotes, like Vim's undofile.", + keywords: [ + "undofile", + "undo", + "redo", + "history", + "persistent", + "restart", + "quit", + "vim", + ], + }, + ] + : []), { id: "sync-title-heading-on-rename", title: "Sync title heading on rename", @@ -2462,6 +2528,9 @@ export function SettingsModal(): JSX.Element { "render-tables", "harper-enabled", "harper-dialect", + "keep-view-mode", + "keep-panels", + "persist-undo-history", "sync-title-heading-on-rename", "markdown-overrides", "heading-level-labels", @@ -2594,6 +2663,22 @@ export function SettingsModal(): JSX.Element { settingId="keep-view-mode" onChange={setKeepViewModeAcrossNotes} /> + + {supportsUndoFile && ( + + )} { + setActiveCategory(result.category.id); + setActiveSearchResultId(result.id); + if (result.type === "setting") { + // If the target lives on a sub-tab, open that sub-tab first + // so the element is mounted before we scroll to it. + const subTabId = result.category.subTabs?.find( + (tab) => tab.searchIds?.includes(result.targetId), + )?.id; + if (subTabId) { + setActiveSubTabByCategory((prev) => ({ + ...prev, + [result.category.id]: subTabId, + })); + } + jumpToSettingsSearchTarget(result.targetId); + } + }; + const onSearchFieldKeyDown = ( + e: React.KeyboardEvent, + ): void => { + if (isImeComposing(e)) return; + const action = settingsSearchFieldAction(e); + if (!action) return; + const current = searchResults.findIndex( + (result) => result.id === visibleSearchResult?.id, + ); + const target = searchResults[settingsSearchStep(action, current, searchResults.length)]; + if (!target) return; + e.preventDefault(); + openSearchResult(target); + }; + // When the visible search result is a setting that lives on a sub-tab, open // that sub-tab so the matched control is actually shown — not only when the // result is clicked, but also when search auto-selects it. Mirrors the @@ -5237,6 +5356,14 @@ export function SettingsModal(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [visibleSettingResultId]); + // Walking the results from the search field can pick a row the list has + // scrolled away from; keep the picked row on screen. + const selectedResultRef = useRef(null); + const selectedResultId = visibleSearchResult?.id ?? null; + useEffect(() => { + selectedResultRef.current?.scrollIntoView?.({ block: "nearest" }); + }, [selectedResultId]); + // Header summary follows the active sub-tab so it describes what's actually on // screen, instead of always showing the category's first-sub-tab blurb. const activeSubTabForHeader = visibleCategory?.subTabs?.find( @@ -5258,8 +5385,29 @@ export function SettingsModal(): JSX.Element { >
e.stopPropagation()} + onKeyDown={(e) => { + trapDialogTab(e, ref.current); + // Handled here, not on the window: the shortcut recorders capture + // keys at the window and must win while they are recording. + const target = e.target as HTMLElement; + const typing = + target.isContentEditable || + /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName); + if ( + !isSettingsFindKey(e, { vimMode, mac: isMacPlatform(), typing }) + ) + return; + e.preventDefault(); + e.stopPropagation(); + navSearchRef.current?.focus(); + navSearchRef.current?.select(); + }} >