Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
81798b4
Fix(vim): Escape ends a block edit in normal mode
adibhanna Sep 17, 2026
86d9576
Fix(tooling): resolve electron from the desktop workspace in the CDP …
adibhanna Sep 17, 2026
6f3b623
Fix(sidebar): the Vim cursor survives a trip to the editor and back
adibhanna Sep 17, 2026
50ab256
Fix(palette): typing goes to the search a command opened, not the note
adibhanna Sep 17, 2026
9a6f5b6
Fix(settings): Settings takes the keyboard instead of leaving it in t…
adibhanna Sep 17, 2026
eb09088
Fix(settings): the Settings search can be driven from the keyboard
adibhanna Sep 17, 2026
c048134
Fix(panels): panel shortcuts leave a pane alone when it is not showin…
adibhanna Sep 17, 2026
ecbc010
Fix(config): "Keep view mode when switching notes" travels in config.…
adibhanna Sep 17, 2026
007f52c
Feat(panels): each note can remember its own panels (#794)
adibhanna Sep 17, 2026
53c339c
Feat(panels): per-note panels come back after a restart (#794)
adibhanna Sep 17, 2026
27c09c9
Fix(editor): each note keeps its own undo history across tab switches…
adibhanna Sep 17, 2026
5b39bc8
Fix(editor): renaming or moving the open note keeps the caret and und…
adibhanna Sep 17, 2026
7537be3
Fix(panels): the note keeps a readable width when side panels do not …
adibhanna Sep 17, 2026
bd0dc11
Feat(editor): undo history can survive quitting, Vim's undofile (#793)
adibhanna Sep 17, 2026
7d8e731
Fix(wikilinks): | in the link picker starts the display text (#804)
adibhanna Sep 17, 2026
f8f967c
Feat(browse): a folder or database can move to another folder
adibhanna Sep 17, 2026
ff6210d
Release: align desktop and shared packages at 2.52.0
adibhanna Sep 17, 2026
d836b1c
Fix(tooling): the caret smoke check waits for a caret that has been l…
adibhanna Sep 17, 2026
f90161f
Fix(undo): read a saved undo history through one file handle
adibhanna Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down Expand Up @@ -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 })
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
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',
Expand Down Expand Up @@ -359,6 +369,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
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',
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <userData>/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<string | null> =>
await readUndoHistory(app.getPath("userData"), undoHistoryVault(), notePath),
);
handle(
IPC.UNDO_HISTORY_WRITE,
async (_e, notePath: unknown, json: unknown): Promise<void> =>
await writeUndoHistory(
app.getPath("userData"),
undoHistoryVault(),
notePath,
json,
),
);
handle(IPC.UNDO_HISTORY_CLEAR, async (): Promise<void> => {
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;
Expand Down Expand Up @@ -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()
Expand Down
117 changes: 117 additions & 0 deletions apps/desktop/src/main/undo-history-store.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
142 changes: 142 additions & 0 deletions apps/desktop/src/main/undo-history-store.ts
Original file line number Diff line number Diff line change
@@ -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:
*
* <userData>/undo-history/<sha256(vault)[:16]>/<sha256(note path)[:32]>.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<string | null> {
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<void> {
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<void> {
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<void> {
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)
}
}
Loading
Loading