From 6d750154ea15235f8a514af00ffdcd56f6e39510 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 12:58:51 -0500 Subject: [PATCH 1/8] Fix(rename): on Windows, renaming onto another note's name no longer replaces that note Renaming a note, an asset or a folder first asks whether the destination already exists, and lets it through only when the destination is the file itself, which is how a case-only rename looks on a case-insensitive disk. That question compared fs.stat().ino as a JS number. A Windows file id is 64 bits, a reuse count above the record number, and once that count passes 31, two files saved one after the other round to the same number. The guard then took the other note for the one being renamed, and the rename replaced it. The MCP server's rename and the shared sidecar mover asked the same way; a workflow rename's cleanup did too, and kept the old file instead of removing it. Every one of these guards now compares bigint stats (device and inode) through main/same-file.ts, the way cloud sync and the CLI's vault check already did. Six tests report the ids Node gives on Windows, the full id from a bigint stat and a rounded number otherwise, and fail on the old guards with the symptom CI showed: note and folder renames for the desktop and the MCP server, an asset rename, and a workflow rename. CI caught the note case once, on the windows-11-arm job on main after 2.69.0 was tagged (vault.test.ts, "says when another note already has the name": the rename resolved). The guard dates from 2.55 (#839). The Go server and zn compare with os.SameFile, which reads the whole id, and are not affected. --- apps/desktop/src/main/note-sidecars.test.ts | 41 +++++++++++++++++++ apps/desktop/src/main/note-sidecars.ts | 7 ++-- apps/desktop/src/main/same-file.ts | 18 ++++++++ apps/desktop/src/main/vault.test.ts | 18 ++++++++ apps/desktop/src/main/vault.ts | 15 +++---- .../src/main/windows-file-ids.testing.ts | 25 +++++++++++ apps/desktop/src/main/workflows.test.ts | 17 ++++++++ apps/desktop/src/main/workflows.ts | 5 ++- apps/desktop/src/mcp/vault-ops.ts | 5 ++- 9 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/main/same-file.ts create mode 100644 apps/desktop/src/main/windows-file-ids.testing.ts diff --git a/apps/desktop/src/main/note-sidecars.test.ts b/apps/desktop/src/main/note-sidecars.test.ts index 43df20131..2b60047e9 100644 --- a/apps/desktop/src/main/note-sidecars.test.ts +++ b/apps/desktop/src/main/note-sidecars.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ import * as desktop from './vault' import * as mcp from '../mcp/vault-ops' +import { NEIGHBOUR_FILE_IDS, useWindowsFileIds } from './windows-file-ids.testing' const roots: string[] = [] @@ -175,3 +176,43 @@ describe.each(clients)('%s: a note keeps its comments', (_name, client) => { expect(await readFile(path.join(root, 'inbox/Keep.md'), 'utf8')).toBe('# Keep\n') }) }) + +// A Windows file id is 64 bits, and two notes saved one after the other can +// share one id as a JS number. The guards that tell a case-only rename from a +// rename onto another note compared those numbers, so on Windows the rename +// went through and replaced the other note. +describe.each(clients)('%s: Windows file ids that round to one number', (_name, client) => { + it('a rename onto another note’s name is refused, and both notes stay', async () => { + const root = await makeVault() + await client.writeNote(root, 'inbox/One.md', 'First.\n') + await client.writeNote(root, 'inbox/Two.md', 'Second.\n') + const ids = useWindowsFileIds({ + [path.join('inbox', 'One.md')]: NEIGHBOUR_FILE_IDS[0], + [path.join('inbox', 'Two.md')]: NEIGHBOUR_FILE_IDS[1] + }) + try { + await expect(client.renameNote(root, 'inbox/One.md', 'Two')).rejects.toThrow(/A note named .Two. already exists/) + } finally { + ids.mockRestore() + } + expect(await readFile(path.join(root, 'inbox/One.md'), 'utf8')).toBe('First.\n') + expect(await readFile(path.join(root, 'inbox/Two.md'), 'utf8')).toBe('Second.\n') + }) + + it('a folder rename onto another folder is refused, and both folders stay', async () => { + const root = await makeVault() + await client.writeNote(root, 'inbox/Work/One.md', 'First.\n') + await mkdir(path.join(root, 'inbox', 'Empty'), { recursive: true }) + const ids = useWindowsFileIds({ + [path.join('inbox', 'Work')]: NEIGHBOUR_FILE_IDS[0], + [path.join('inbox', 'Empty')]: NEIGHBOUR_FILE_IDS[1] + }) + try { + await expect(client.renameFolder(root, 'inbox', 'Work', 'Empty')).rejects.toThrow(/already exist/) + } finally { + ids.mockRestore() + } + expect(await readFile(path.join(root, 'inbox/Work/One.md'), 'utf8')).toBe('First.\n') + expect((await stat(path.join(root, 'inbox', 'Empty'))).isDirectory()).toBe(true) + }) +}) diff --git a/apps/desktop/src/main/note-sidecars.ts b/apps/desktop/src/main/note-sidecars.ts index bba4e33d6..72442af09 100644 --- a/apps/desktop/src/main/note-sidecars.ts +++ b/apps/desktop/src/main/note-sidecars.ts @@ -12,6 +12,7 @@ import path from 'node:path' import { NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX } from '@shared/note-comments' import { noteMetadataPath, removeNoteCreation } from './note-creation-metadata' import { agentEditPath, removeAgentEditRecord } from './agent-edit-files' +import { fileIdentity, isSameFile } from './same-file' const INTERNAL_VAULT_DIR = '.zennotes' @@ -162,13 +163,13 @@ export async function relocateFolderTrees( for (const [from, to] of moves) { let source try { - source = await fs.stat(from) + source = await fileIdentity(from) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } try { - const target = await fs.stat(to) - if (!source || source.ino !== target.ino || source.dev !== target.dev) + const target = await fileIdentity(to) + if (!source || !isSameFile(source, target)) throw new Error('The destination folder or its comments already exist') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error diff --git a/apps/desktop/src/main/same-file.ts b/apps/desktop/src/main/same-file.ts new file mode 100644 index 000000000..22f6b64e7 --- /dev/null +++ b/apps/desktop/src/main/same-file.ts @@ -0,0 +1,18 @@ +import { promises as fs, type BigIntStats } from 'node:fs' + +/** + * A file's identity for the guards that tell a case-only rename (the + * destination is the file itself, on a case-insensitive disk) from a rename + * onto another file. Always a bigint stat: a Windows file id is 64 bits, a + * reuse count above the record number, and as a JS number two files saved one + * after the other round to one value once that count passes 31. Read as + * numbers, renaming a note onto another note's name looked like a case-only + * rename, and the rename replaced the other note. + */ +export function fileIdentity(absolutePath: string): Promise { + return fs.stat(absolutePath, { bigint: true }) +} + +export function isSameFile(a: BigIntStats, b: BigIntStats): boolean { + return a.dev === b.dev && a.ino === b.ino +} diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 5f92a3c94..cb6364433 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -55,6 +55,7 @@ import { } from './vault' import { registerEphemeralRoot, unregisterEphemeralRoot } from './ephemeral-vaults' +import { NEIGHBOUR_FILE_IDS, useWindowsFileIds } from './windows-file-ids.testing' const tempDirs: string[] = [] @@ -633,6 +634,23 @@ describe('deleteAsset', () => { await expect(readFile(path.join(root, duplicated.path), 'utf8')).resolves.toBe('image-bytes') }) + it('refuses a rename onto another asset whose Windows file id rounds to the same number', async () => { + const root = await makeTempDir('zennotes-asset-rename-taken-') + await ensureVaultLayout(root) + await writeFile(path.join(root, 'One.png'), 'first', 'utf8') + await writeFile(path.join(root, 'Two.png'), 'second', 'utf8') + const ids = useWindowsFileIds({ 'One.png': NEIGHBOUR_FILE_IDS[0], 'Two.png': NEIGHBOUR_FILE_IDS[1] }) + try { + await expect(renameAsset(root, 'One.png', 'Two.png')).rejects.toThrow( + 'An asset named "Two.png" already exists in this folder.' + ) + } finally { + ids.mockRestore() + } + await expect(readFile(path.join(root, 'One.png'), 'utf8')).resolves.toBe('first') + await expect(readFile(path.join(root, 'Two.png'), 'utf8')).resolves.toBe('second') + }) + it('rewrites every reference to a renamed asset, in the shapes people write them (#785)', async () => { const root = await makeTempDir('zennotes-asset-rename-links-') await ensureVaultLayout(root) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index f7d64f354..17123debc 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -16,6 +16,7 @@ import { recordMainPerf } from './perf' import { resolveCommandViaLoginShell } from './login-shell-path' import { isEphemeralRoot } from './ephemeral-vaults' import { atomicWriteTarget, renameWithRetry } from './atomic-write' +import { fileIdentity, isSameFile } from './same-file' export { renameWithRetry } import { @@ -3622,13 +3623,13 @@ export async function renameNote( // Said plainly, since the app shows this reason to whoever is renaming. A // case-only rename finds the note itself on a case-insensitive disk. const [source, taken] = await Promise.all([ - fs.stat(abs), - fs.stat(target).catch((error: NodeJS.ErrnoException) => { + fileIdentity(abs), + fileIdentity(target).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null throw error }) ]) - if (taken && (taken.ino !== source.ino || taken.dev !== source.dev)) + if (taken && !isSameFile(taken, source)) throw new Error(`A note named “${trimmed}” already exists in this folder`) } // Snapshot the vault before the rename so inbound [[wikilinks]] still @@ -3832,8 +3833,8 @@ export async function renameAsset( if (willRename) { try { await fs.access(destAbs) - const [srcStat, dstStat] = await Promise.all([fs.stat(source.abs), fs.stat(destAbs)]) - if (srcStat.ino !== dstStat.ino) { + const [srcStat, dstStat] = await Promise.all([fileIdentity(source.abs), fileIdentity(destAbs)]) + if (!isSameFile(srcStat, dstStat)) { throw new Error(`An asset named "${cleanName}" already exists in this folder.`) } } catch (e) { @@ -4107,8 +4108,8 @@ export async function renameFolder( try { await fs.access(newAbs) // Check if old and new are the same file (case-only rename) - const [oldStat, newStat] = await Promise.all([fs.stat(oldAbs), fs.stat(newAbs)]) - if (oldStat.ino !== newStat.ino) { + const [oldStat, newStat] = await Promise.all([fileIdentity(oldAbs), fileIdentity(newAbs)]) + if (!isSameFile(oldStat, newStat)) { throw new Error(`A folder already exists at "${newClean}"`) } } catch (e) { diff --git a/apps/desktop/src/main/windows-file-ids.testing.ts b/apps/desktop/src/main/windows-file-ids.testing.ts new file mode 100644 index 000000000..ac006d0b7 --- /dev/null +++ b/apps/desktop/src/main/windows-file-ids.testing.ts @@ -0,0 +1,25 @@ +import { promises as fs, type PathLike, type StatOptions } from 'node:fs' +import { vi } from 'vitest' + +/** + * Two files saved one after the other on Windows: neighbouring records with + * the same reuse count. A Windows file id is 64 bits (the reuse count above + * the record number), and once the count passes 31 these two round to one + * value as JS numbers. As bigints they stay apart. + */ +export const NEIGHBOUR_FILE_IDS = [0x0028000000037683n, 0x0028000000037684n] as const + +/** + * Report Windows file ids for the paths ending in the given suffixes, the way + * Node does: the full id from a bigint stat, a rounded number otherwise. + * Call `mockRestore()` on the result. + */ +export function useWindowsFileIds(ids: Record) { + const stat = fs.stat.bind(fs) + return vi.spyOn(fs, 'stat').mockImplementation((async (target: PathLike, options?: StatOptions) => { + const stats = await stat(target, options) + const id = Object.entries(ids).find(([suffix]) => String(target).endsWith(suffix))?.[1] + if (id === undefined) return stats + return Object.assign(stats, options?.bigint ? { ino: id, dev: 7n } : { ino: Number(id), dev: 7 }) + }) as typeof fs.stat) +} diff --git a/apps/desktop/src/main/workflows.test.ts b/apps/desktop/src/main/workflows.test.ts index 44368e206..bc8655e74 100644 --- a/apps/desktop/src/main/workflows.test.ts +++ b/apps/desktop/src/main/workflows.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promis import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { NEIGHBOUR_FILE_IDS, useWindowsFileIds } from './windows-file-ids.testing' import { MAX_IMPORT_BYTES, deleteWorkflowFile, @@ -313,6 +314,22 @@ describe('writeWorkflowFile', () => { expect(await readFile(path.join(workflowsDirOf(root), 'my-flow.md'), 'utf8')).toBe('new\n') }) + it('removes the old file on a rename, even where Windows file ids round to one number', async () => { + const root = await makeVault() + await writeWorkflowFile(root, { slug: 'old-name', raw: 'old\n' }) + const ids = useWindowsFileIds({ 'old-name.md': NEIGHBOUR_FILE_IDS[0], 'new-name.md': NEIGHBOUR_FILE_IDS[1] }) + try { + await writeWorkflowFile(root, { + slug: 'new-name', + raw: 'new\n', + previousSourcePath: '.zennotes/workflows/old-name.md' + }) + } finally { + ids.mockRestore() + } + expect(await entriesOf(root)).toEqual(['new-name.md']) + }) + it('tolerates a previousSourcePath that no longer exists', async () => { const root = await makeVault() const saved = await writeWorkflowFile(root, { diff --git a/apps/desktop/src/main/workflows.ts b/apps/desktop/src/main/workflows.ts index 41cf1d7a4..b87e9afea 100644 --- a/apps/desktop/src/main/workflows.ts +++ b/apps/desktop/src/main/workflows.ts @@ -8,6 +8,7 @@ import path from 'node:path' import type { WorkflowFile, WriteWorkflowInput } from '@zennotes/bridge-contract/workflows' import { WORKFLOWS_REL_DIR } from '@shared/workflows-view' import { writeFileAtomic } from './vault' +import { fileIdentity, isSameFile } from './same-file' function workflowsDir(root: string): string { return path.join(root, '.zennotes', 'workflows') @@ -148,8 +149,8 @@ export async function writeWorkflowFile( if (prevAbs && prevAbs !== abs) { let sameFile = false try { - const [prevStat, newStat] = await Promise.all([fs.stat(prevAbs), fs.stat(abs)]) - sameFile = prevStat.dev === newStat.dev && prevStat.ino === newStat.ino + const [prevStat, newStat] = await Promise.all([fileIdentity(prevAbs), fileIdentity(abs)]) + sameFile = isSameFile(prevStat, newStat) } catch { // Either side unstattable: the remove below treats a missing file as done. } diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index d343c8c43..7b3f9c73f 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -1,6 +1,7 @@ import { readNoteCreatedAt, prepareNoteCreation, removeNoteCreation, noteMetadataPath } from '../main/note-creation-metadata' import { noteCommentsPath, noteCommentsRoot, relocateFolderTrees, relocateNote } from '../main/note-sidecars' import { agentEditFolderPath, removeAgentEditRecord } from '../main/agent-edit-files' +import { fileIdentity, isSameFile } from '../main/same-file' import { readSavedServerToken as readZnSavedServerToken } from '../main/remote/zn-credentials' /** * Vault operations used by the MCP server. Mirrors the filesystem @@ -1021,8 +1022,8 @@ export async function renameNote(root: string, rel: string, nextTitle: string): if (target !== abs) { try { await fs.access(target) - const [srcStat, dstStat] = await Promise.all([fs.stat(abs), fs.stat(target)]) - if (srcStat.ino !== dstStat.ino) { + const [srcStat, dstStat] = await Promise.all([fileIdentity(abs), fileIdentity(target)]) + if (!isSameFile(srcStat, dstStat)) { throw new Error(`A note named "${trimmed}" already exists in ${folder}`) } } catch (e) { From c35ae4381968242efbfa718f9ee5ee12868cdd56 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 12:59:09 -0500 Subject: [PATCH 2/8] Fix(release): publishing a boundary package no longer builds a desktop release release.yml fired on tags matching "v*", and the share viewer's boundary tags start with a v too (viewer-2.69.0-viewer.h5e7ef0f99f145d0a). Publishing a viewer draft creates its tag, so every viewer publish since 2.63.0 ran the whole desktop release: about 45 runner-minutes, two notarizations, and every installer and update feed uploaded onto the viewer pre-release. The desktop's own release and its assets were never touched; only the viewer pre-releases carry the extra files. Desktop tags are a v and a digit, so the trigger is now "v[0-9]*". The manual rebuild path (workflow_dispatch with a tag) is unchanged. Removing the stray files from the old viewer pre-releases is left for its own decision. --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ddacd42b..2b16594b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,10 @@ name: Release on: push: tags: - - "v*" + # A digit after the v: the boundary packages publish tags such as + # viewer-2.69.0-viewer.h5e7e..., which "v*" also matched, and every one + # built and uploaded a whole desktop release onto its pre-release. + - "v[0-9]*" # Rebuild one platform of a release that already has its tag. A re-run of the # tag's own run can only repeat every failed job with the workflow as it was # at the tag; this runs the workflow from the branch it is dispatched on, From 15dc5229031dadb9898826da1d20c8b2ba7f99d0 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 12:59:39 -0500 Subject: [PATCH 3/8] Docs(whats-new): the 2.69.1 highlight What's New opens on the first launch after the update with one highlight: on Windows, renaming a note or an attachment to a name another one already has always stops and says so, instead of sometimes replacing the other file. An update from 2.68.0 shows the 2.69.0 highlights after it. --- packages/app-core/src/lib/whats-new.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index b4e7cca90..2b5ed8880 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,15 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.69.1', + highlights: [ + { + title: 'Renames on Windows', + body: 'Renaming a note or an attachment to a name another one already has now always stops and says so on Windows, instead of sometimes replacing the other file.' + } + ] + }, { version: '2.69.0', highlights: [ From e23170782f405aac2fb5c080d45fa6f21c837c42 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 13:02:07 -0500 Subject: [PATCH 4/8] Release: align desktop and shared packages at 2.69.1 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 138980e37..71838b3fa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.69.0", + "version": "2.69.1", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index b8edcf006..e50e0dfbc 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.69.0", + "version": "2.69.1", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 988824be8..a41bf1cb8 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.69.0", + "version": "2.69.1", "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 2a8e2640f..8debf4ccb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.69.0", + "version": "2.69.1", "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 726507e12..fa4f5c5ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.69.0", + "version": "2.69.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.69.0", + "version": "2.69.1", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.69.0", + "version": "2.69.1", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.69.0", + "version": "2.69.1", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.69.0", + "version": "2.69.1", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.69.0", + "version": "2.69.1", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16390,7 +16390,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.69.0", + "version": "2.69.1", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16478,14 +16478,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.69.0", + "version": "2.69.1", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.69.0", + "version": "2.69.1", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16499,7 +16499,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.69.0" + "version": "2.69.1" } } } diff --git a/package.json b/package.json index 4fab0b1a3..191ae6aaf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.69.0", + "version": "2.69.1", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 76680b039..18f3b29fd 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.69.0", + "version": "2.69.1", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 50d4b759e..737a8478f 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.69.0", + "version": "2.69.1", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 14cf40ac6..4d4fc69ee 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.69.0", + "version": "2.69.1", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index b66e2b216..3bc0345b4 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.69.0", + "version": "2.69.1", "type": "module", "exports": { ".": "./src/index.ts" From ba74c54f850c1cd73da4dcd66957599483191a24 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 13:12:17 -0500 Subject: [PATCH 5/8] Fix(databases): converting a table offers the heading right above it, in a long note too Convert to database offers a name from the nearest heading above the table, read from the syntax tree when the parse has reached the table and by a line scan otherwise. "Has reached" was syntaxTree(state).length >= pos, but noteMarkdown's outer tree spans the whole note even while its body parse is still short of the table. In a long note, converting a table before the background parse got there offered an earlier heading's name. It asks ensureSyntaxTree now, which parses up to the table within 50 ms or returns no tree, and then the line scan answers. The name was always editable before saving, so nothing was written under the wrong name unseen. A new test cuts the first parse short with a clock that moves 30 ms per read; it failed on the old check ("Early" for "Roadmap"). --- .../app-core/src/lib/table-to-database.test.ts | 17 +++++++++++++++++ packages/app-core/src/lib/table-to-database.ts | 11 +++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/app-core/src/lib/table-to-database.test.ts b/packages/app-core/src/lib/table-to-database.test.ts index ad2e7fc62..c72f6d710 100644 --- a/packages/app-core/src/lib/table-to-database.test.ts +++ b/packages/app-core/src/lib/table-to-database.test.ts @@ -8,6 +8,7 @@ import { EditorView } from '@codemirror/view' import type { DatabaseDoc } from '@shared/databases' import { afterEach, describe, expect, it, vi } from 'vitest' import { tablePlugin } from './cm-table' +import { noteMarkdown } from './cm-markdown-language' import { closeTableContextMenu, openTableContextMenu } from './cm-table-menu' import { parseTable } from './markdown-table' import { registerNoteEditor } from './note-editor-context' @@ -127,6 +128,22 @@ describe('defaultDatabaseTitle / headingAbove', () => { view.destroy() }) + it('names the heading right above in a long note the parser has not reached yet', () => { + const filler = Array.from({ length: 50 }, (_, i) => `Paragraph ${i}.`).join('\n\n') + const doc = `# Early\n\n${filler}\n\n# Roadmap\n\n| A | B |\n| - | - |\n| 1 | 2 |\n` + // A slow machine: every clock read moves 30 ms, so the editor's first parse + // stops long before the table, as it does in a large note. + let now = Date.now() + const clock = vi.spyOn(Date, 'now').mockImplementation(() => (now += 30)) + let state: EditorState + try { + state = EditorState.create({ doc, extensions: [noteMarkdown()] }) + } finally { + clock.mockRestore() + } + expect(headingAbove(state, doc.indexOf('| A |'))).toBe('Roadmap') + }) + it('skips a # comment inside a fenced code block', () => { const doc = '## Setup\n\n```bash\n# install deps\nnpm i\n```\n\n| A |\n| --- |\n| 1 |' const view = mount(doc, false) diff --git a/packages/app-core/src/lib/table-to-database.ts b/packages/app-core/src/lib/table-to-database.ts index e9a4d2cdc..34aa82c56 100644 --- a/packages/app-core/src/lib/table-to-database.ts +++ b/packages/app-core/src/lib/table-to-database.ts @@ -15,7 +15,7 @@ * update listener and one `u` away. The database itself stays; undo restores * the table, it does not delete files. */ -import { syntaxTree } from '@codemirror/language' +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' import type { EditorState } from '@codemirror/state' import type { EditorView } from '@codemirror/view' import type { DatabaseSeed } from '@shared/databases' @@ -66,11 +66,14 @@ const HEADING_LINE_RE = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/ /** * The nearest heading above `pos`, its markers stripped. Read from the * syntax tree where it has parsed that far (a `# comment` inside a fenced code - * block is not a heading), by line scan otherwise. + * block is not a heading), by line scan otherwise. "That far" is what + * ensureSyntaxTree answers: noteMarkdown's outer tree always spans the whole + * note, even while its body parse is still short of `pos`, so the length of + * syntaxTree(state) named an earlier heading in a long note. */ export function headingAbove(state: EditorState, pos: number): string | null { - const tree = syntaxTree(state) - if (tree.length >= pos) { + const tree = ensureSyntaxTree(state, pos, 50) + if (tree) { const last: { range: { from: number; to: number } | null } = { range: null } tree.iterate({ from: 0, From 65d30daf539abcb224220aaf383050d63a3fa99a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 13:12:17 -0500 Subject: [PATCH 6/8] Test: CI failures from a partial parse, a cold import, a slow runner and a port race stop Five failures on main since September had no cause written down. None is a product bug, and each has its fix: - task-rollup.test.ts read syntaxTree(state), the tree from creation after a 20 ms parse budget, which a slow runner left partial (macOS 09-14, Windows 10-08). An empty transaction picks up the tree ensureSyntaxTree finished. - The incremental parsing tests in cm-markdown-language.test.ts ran out of an update's 20 ms budget ("no block at 634", Windows 09-23). A frozen clock lets the update's own parse, the one that reuses blocks, finish. - search-codeblock.test.ts read code tokens before the first parse reached the code block (macOS 10-02). It forces the parse, as cm-math-render.test.ts does. - The first note relocation test pays for the store's cold import (Windows 10-09), and workflow-apply's serialization test ran 4 to 6 times slower on one runner (Windows 09-16). Those two get longer timeouts. - test-app-core-browser.mjs closed each port before asking for the next, so the API server and Chrome could get the same one, and /json/list then answered with the app's HTML. That is the Ubuntu "Exercise the installed editor in Chrome" failure the release runbook listed as a known flake. The three ports are now read while every listener is still open. --- apps/desktop/src/main/workflow-apply.test.ts | 4 +++- .../src/lib/cm-markdown-language.test.ts | 16 ++++++++++++---- .../app-core/src/lib/search-codeblock.test.ts | 7 ++++++- packages/app-core/src/lib/task-rollup.test.ts | 5 ++++- packages/app-core/src/note-actions.test.ts | 4 +++- tooling/scripts/test-app-core-browser.mjs | 19 +++++++++++-------- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/workflow-apply.test.ts b/apps/desktop/src/main/workflow-apply.test.ts index b87275274..1601e91a6 100644 --- a/apps/desktop/src/main/workflow-apply.test.ts +++ b/apps/desktop/src/main/workflow-apply.test.ts @@ -1644,6 +1644,8 @@ describe('ops after a path op follow the note', () => { }) describe('one run at a time per vault', () => { + // Many journaled, fsynced writes in a row: on a slow Windows runner the + // file-system-heavy suites ran 4 to 6 times slower and this passed 5 s. it('concurrent applies serialize instead of interleaving read-modify-write', async () => { const root = await makeVault() await seed(root, 'inbox/Log.md', 'start\n') @@ -1660,7 +1662,7 @@ describe('one run at a time per vault', () => { for (const receipt of receipts) expect(receipt.rolledBack).toBeUndefined() const body = (await readOrNull(root, 'inbox/Log.md')) ?? '' for (const text of lines) expect(body).toContain(text) - }) + }, 30_000) it('an undo queued behind a run sees the run completed, not half of it', async () => { const root = await makeVault() diff --git a/packages/app-core/src/lib/cm-markdown-language.test.ts b/packages/app-core/src/lib/cm-markdown-language.test.ts index 15f4344ca..d7fb492c0 100644 --- a/packages/app-core/src/lib/cm-markdown-language.test.ts +++ b/packages/app-core/src/lib/cm-markdown-language.test.ts @@ -2,7 +2,7 @@ import { markdownLanguage } from '@codemirror/lang-markdown' import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' import { EditorState } from '@codemirror/state' import type { SyntaxNode, Tree } from '@lezer/common' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { frontmatterRange } from './cm-frontmatter' import { noteMarkdown } from './cm-markdown-language' @@ -188,9 +188,17 @@ describe('noteMarkdown: incremental parsing', () => { const BODY = paragraphs.join('\n\n') function update(state: EditorState, from: number, to: number, insert: string): EditorState { - const next = state.update({ changes: { from, to, insert } }).state - // The state update parses for a bounded slice of time; make sure the - // tree is complete before comparing it. + // The state update parses for a 20 ms slice of time, and on a slow CI + // runner the slice ran out before the reused blocks ("no block at 634"). + // A frozen clock lets this update's own parse finish, which is the one + // that reuses blocks. + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now()) + let next: EditorState + try { + next = state.update({ changes: { from, to, insert } }).state + } finally { + clock.mockRestore() + } ensureSyntaxTree(next, next.doc.length, 5000) return next } diff --git a/packages/app-core/src/lib/search-codeblock.test.ts b/packages/app-core/src/lib/search-codeblock.test.ts index bfe30281b..0f0422fb6 100644 --- a/packages/app-core/src/lib/search-codeblock.test.ts +++ b/packages/app-core/src/lib/search-codeblock.test.ts @@ -6,6 +6,7 @@ import { HighlightStyle, LanguageDescription, defaultHighlightStyle, + forceParsing, syntaxHighlighting } from '@codemirror/language' import { SearchQuery, openSearchPanel, search, setSearchQuery } from '@codemirror/search' @@ -52,7 +53,7 @@ const resolveCodeLanguage = (info: string): LanguageDescription | null => { function mountEditor(doc: string): EditorView { const parent = document.createElement('div') document.body.append(parent) - return new EditorView({ + const view = new EditorView({ parent, state: EditorState.create({ doc, @@ -68,6 +69,10 @@ function mountEditor(doc: string): EditorView { ] }) }) + // The first parse runs on a time budget; on a slow CI runner it stopped + // before the code block, whose tokens the tests read straight away. + forceParsing(view, view.state.doc.length, 5000) + return view } function runSearch(view: EditorView, query: string): void { diff --git a/packages/app-core/src/lib/task-rollup.test.ts b/packages/app-core/src/lib/task-rollup.test.ts index c25b44248..48c721fbd 100644 --- a/packages/app-core/src/lib/task-rollup.test.ts +++ b/packages/app-core/src/lib/task-rollup.test.ts @@ -12,7 +12,10 @@ function rollups(lines: string[], first = 1, last = lines.length): Map { diff --git a/packages/app-core/src/note-actions.test.ts b/packages/app-core/src/note-actions.test.ts index 0f85f29df..7f138c334 100644 --- a/packages/app-core/src/note-actions.test.ts +++ b/packages/app-core/src/note-actions.test.ts @@ -101,6 +101,8 @@ async function setup() { } describe("note relocation saves", () => { + // The first test pays for the cold import of the store (over a second on a + // Windows runner, past 5 s once on a slow one), so it gets a longer timeout. it("saves before moving and keeps edits made during the move at the canonical path", async () => { const s = await setup(), gate = deferred(); @@ -125,7 +127,7 @@ describe("note relocation saves", () => { ); expect(s.useStore.getState().selectedPath).toBe("inbox/Work/One 2.md"); expect(s.useStore.getState().noteDirty["inbox/Work/One 2.md"]).toBe(false); - }); + }, 20_000); it("does not relocate a note if its save failed", async () => { const s = await setup(); diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs index 3d0f86204..32f5f10e7 100644 --- a/tooling/scripts/test-app-core-browser.mjs +++ b/tooling/scripts/test-app-core-browser.mjs @@ -30,15 +30,18 @@ async function until(check, label, timeout = 30000) { // so every scripted navigation after a page load waits for the shell's // readiness signal, exactly as a host would. const workspaceReady = () => until(() => client.evaluate('window.packageShell?.getShellSnapshot().workspaceRestored === true'), 'workspace restored') -async function port() { - return new Promise((done, reject) => { +// Hold every listener open until all the ports are read. Closing each one +// before asking for the next let the OS hand one port out twice: the API server +// and Chrome both got it once, and /json/list answered with the app's HTML. +async function ports(count) { + const servers = await Promise.all(Array.from({ length: count }, () => new Promise((done, reject) => { const server = net.createServer() server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - const number = server.address().port - server.close(() => done(number)) - }) - }) + server.listen(0, '127.0.0.1', () => done(server)) + }))) + const numbers = servers.map((server) => server.address().port) + await Promise.all(servers.map((server) => new Promise((done) => server.close(done)))) + return numbers } class CDP { nextId = 0 @@ -74,7 +77,7 @@ class CDP { } close() { this.socket.close() } } -const apiPort = await port(), uiPort = await port(), debugPort = await port() +const [apiPort, uiPort, debugPort] = await ports(3) // The server is the pinned ZenNotes/znserver release (or an explicit binary or checkout). const binary = await resolveServerBinary() const children = [] From a2b4bfe7a6451da5980b2a439b465c14187a7adb Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 13:32:00 -0500 Subject: [PATCH 7/8] Fix(mcp): assistants see tasks in ordered lists and quotes, as the app does The app's Tasks view reads task lines with @shared/tasklists' TASK_LINE_RE: bullets, ordered lists (`1. [ ]`, `1) [ ]`) and quoted tasks (`> - [ ]`). The MCP server scanned with its own copy, commented "kept in sync" but bullets only, so list_tasks never showed an ordered or quoted task, toggle_task could not reach one, and every task below one in the same note got a different index than the app counts. The copy now matches the same lines; toggling still changes only the character inside the brackets, so ordered numbers and quote markers stay as written. Two tests: the scanner sees exactly the lines the shared regex matches in a note with every shape, and an ordered and a quoted task toggle in place. Both failed on the old copy. The Go server's and zn's task regexes still differ from the app's (no quoted tasks; the server also has no `1)`), which is theirs to fix in their repos. --- apps/desktop/src/mcp/vault-ops.test.ts | 29 ++++++++++++++++++++++++++ apps/desktop/src/mcp/vault-ops.ts | 10 +++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index 15169c2b0..c168efca2 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -14,6 +14,7 @@ import { searchText, toggleTaskInBody } from './vault-ops' +import { TASK_LINE_RE as SHARED_TASK_LINE_RE } from '@shared/tasklists' // Every note-shaped MCP result carries `link`, the zennotes:// deep link a // model renders as a markdown link so the user can click from chat straight @@ -90,6 +91,34 @@ describe('mcp task states', () => { expect(byContent.get('done')?.checked).toBe(true) }) + // The app's Tasks view reads task lines with @shared/tasklists; the MCP + // scanner kept its own copy, which missed ordered lists and quotes, so an + // assistant never saw tasks the app listed. + it('sees the task lines the app sees: ordered lists and quotes too', async () => { + const body = [ + '- [ ] bullet', + '1. [ ] ordered', + '2) [x] ordered done', + '> - [ ] quoted', + '> > 3. [/] deeply quoted', + '-[ ] not a task', + 'text [ ] not a task', + '' + ].join('\n') + await writeFile(path.join(root, 'inbox', 'Shapes.md'), `# Shapes\n\n${body}`) + const tasks = (await scanAllTasks(root)).filter((t) => t.sourcePath === 'inbox/Shapes.md') + expect(tasks.map((t) => t.content)).toEqual(['bullet', 'ordered', 'ordered done', 'quoted', 'deeply quoted']) + expect(tasks.map((t) => t.content)).toHaveLength(body.split('\n').filter((line) => SHARED_TASK_LINE_RE.test(line)).length) + expect(tasks.find((t) => t.content === 'ordered done')?.checked).toBe(true) + expect(tasks.find((t) => t.content === 'deeply quoted')?.inProgress).toBe(true) + }) + + it('toggles an ordered or quoted task in place', () => { + const body = '1. [ ] first\n> - [ ] quoted\n' + expect(toggleTaskInBody(body, 0)).toBe('1. [x] first\n> - [ ] quoted\n') + expect(toggleTaskInBody(body, 1)).toBe('1. [ ] first\n> - [x] quoted\n') + }) + it('toggle follows the app rules: [/] checks off, records stay (#599)', () => { const body = '- [ ] open\n- [/] started\n- [x] done\n- [-] scrapped\n- [>] gone\n' expect(toggleTaskInBody(body, 0)).toContain('- [x] open') diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 7b3f9c73f..31373780a 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -231,10 +231,12 @@ async function folderRoot(root: string, folder: NoteFolder): Promise { } const FENCE_LINE_RE = /^(\s{0,3})(`{3,}|~{3,})/ -// `>` = forwarded (#316), `-` = cancelled (#450), `/` = in progress (#512) — -// all recognized so those tasks aren't invisible to the MCP scanner. Kept in -// sync with @shared/tasklists. -const TASK_LINE_RE = /^\s*[-*+]\s+\[([ xX>/-])\](.*)$/ +// The task lines @shared/tasklists' TASK_LINE_RE matches, so the MCP scanner +// lists the tasks the app lists and counts them the same way: bullets, +// ordered lists (`1.` and `1)`) and quoted tasks (`> - [ ]`). Every state the +// app knows is recognized: `>` forwarded (#316), `-` cancelled (#450) and `/` +// in progress (#512). Groups: the state char, then the text after `]`. +const TASK_LINE_RE = /^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\[([ xX>/-])\](.*)$/ export interface NoteMeta { path: string From 86e98b49d6a0be5e9c52e4e9b1c414977cbfbd29 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 13:41:12 -0500 Subject: [PATCH 8/8] Test: the browser check gives a cold Chrome 90 seconds to start On 2.69.1's release PR, Ubuntu's "Exercise the installed editor in Chrome" failed with "Chrome startup: The operation was aborted due to timeout". The ports were distinct this time (server 35179, preview 42673, DevTools 34861), so it was not the port race fixed in 65d30daf: Chrome printed nothing for 20 seconds after launch and opened DevTools about 26 seconds in, against a 30 second wait. The same step on the previous push finished the whole browser check in 21 seconds. Chrome's startup wait is now 90 seconds; the servers keep 30. --- tooling/scripts/test-app-core-browser.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs index 32f5f10e7..726810213 100644 --- a/tooling/scripts/test-app-core-browser.mjs +++ b/tooling/scripts/test-app-core-browser.mjs @@ -135,10 +135,12 @@ try { ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : 'google-chrome') launch('chrome', chrome, [`--remote-debugging-port=${debugPort}`, `--user-data-dir=${join(run, 'chrome-profile')}`, '--headless=new', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-extensions', 'about:blank']) + // A cold Chrome on a busy CI runner took 26 s to open DevTools (2.69.1's + // release PR), so its startup gets more than the 30 s the servers get. const page = await until(async () => { const pages = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`, { signal: AbortSignal.timeout(1000) })).json() return pages.find((page) => page.type === 'page' && page.webSocketDebuggerUrl) - }, 'Chrome startup') + }, 'Chrome startup', 90000) const socket = new WebSocket(page.webSocketDebuggerUrl) await new Promise((done, reject) => { socket.addEventListener('open', done, { once: true }); socket.addEventListener('error', reject, { once: true }) }) client = new CDP(socket)