diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ddacd42..2b16594b 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, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 138980e3..71838b3f 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/desktop/src/main/note-sidecars.test.ts b/apps/desktop/src/main/note-sidecars.test.ts index 43df2013..2b60047e 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 bba4e33d..72442af0 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 00000000..22f6b64e --- /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 5f92a3c9..cb636443 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 f7d64f35..17123deb 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 00000000..ac006d0b --- /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/workflow-apply.test.ts b/apps/desktop/src/main/workflow-apply.test.ts index b8727527..1601e91a 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/apps/desktop/src/main/workflows.test.ts b/apps/desktop/src/main/workflows.test.ts index 44368e20..bc8655e7 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 41cf1d7a..b87e9afe 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.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index 15169c2b..c168efca 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 d343c8c4..31373780 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 @@ -230,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 @@ -1021,8 +1024,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) { diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index b8edcf00..e50e0dfb 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 988824be..a41bf1cb 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 2a8e2640..8debf4cc 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 726507e1..fa4f5c5b 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 4fab0b1a..191ae6aa 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 76680b03..18f3b29f 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/app-core/src/lib/cm-markdown-language.test.ts b/packages/app-core/src/lib/cm-markdown-language.test.ts index 15f4344c..d7fb492c 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 bfe30281..0f0422fb 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/table-to-database.test.ts b/packages/app-core/src/lib/table-to-database.test.ts index ad2e7fc6..c72f6d71 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 e9a4d2cd..34aa82c5 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, diff --git a/packages/app-core/src/lib/task-rollup.test.ts b/packages/app-core/src/lib/task-rollup.test.ts index c25b4424..48c721fb 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/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index b4e7cca9..2b5ed888 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: [ diff --git a/packages/app-core/src/note-actions.test.ts b/packages/app-core/src/note-actions.test.ts index 0f85f29d..7f138c33 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/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 50d4b759..737a8478 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 14cf40ac..4d4fc69e 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 b66e2b21..3bc0345b 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" diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs index 3d0f8620..72681021 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 = [] @@ -132,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)