From 383a2e9ab95bae5141f40cf7733180a0f18e60c0 Mon Sep 17 00:00:00 2001 From: Matt Derman <47120649+MDerman@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:54:50 +0200 Subject: [PATCH 01/53] fix(desktop): prevent large vaults exhausting macOS file watchers --- README.md | 2 +- apps/desktop/package.json | 4 +- apps/desktop/src/main/watcher.test.ts | 71 ++++++++++++++++++++++++++- apps/desktop/src/main/watcher.ts | 37 +++++++++----- package-lock.json | 37 +++++--------- package.json | 2 +- 6 files changed, 109 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 26f190317..1b2505dd8 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ Read [docs/monorepo-architecture.md](docs/monorepo-architecture.md) for the arch ### Requirements -- Node.js 22+ +- Node.js 22.22 or newer - npm - Go 1.22+ for the server build path - Docker optional, for self-hosting diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 120038a1f..e001993c2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,7 +19,7 @@ }, "homepage": "https://github.com/ZenNotes/zennotes/releases/latest", "engines": { - "node": ">=22" + "node": ">=22.22" }, "scripts": { "dev": "electron-vite dev", @@ -58,7 +58,7 @@ "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", - "chokidar": "^4.0.3", + "chokidar": "^6.0.0", "codemirror": "^6.0.1", "docx": "^9.7.1", "dompurify": "^3.3.4", diff --git a/apps/desktop/src/main/watcher.test.ts b/apps/desktop/src/main/watcher.test.ts index f938291b8..172236739 100644 --- a/apps/desktop/src/main/watcher.test.ts +++ b/apps/desktop/src/main/watcher.test.ts @@ -1,7 +1,9 @@ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { getActiveResourcesInfo } from 'node:process' +import chokidar from 'chokidar' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { VaultChangeEvent, VaultSettings } from '@shared/ipc' import { VaultWatcher } from './watcher' @@ -9,8 +11,9 @@ const tempDirs: string[] = [] const watchers: VaultWatcher[] = [] afterEach(async () => { - for (const watcher of watchers.splice(0)) watcher.stop() + await Promise.all(watchers.splice(0).map((watcher) => watcher.stop())) await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) + vi.restoreAllMocks() }) async function makeVault(): Promise { @@ -125,6 +128,70 @@ describe('VaultWatcher atomic saves', () => { ) }) +describe('VaultWatcher large vaults', () => { + it('watches external edits with bounded native handles and releases them on stop', async () => { + const root = await makeVault() + const directoryCount = 20 + for (let index = 0; index < directoryCount; index++) { + const dir = path.join(root, `folder-${index}`) + await mkdir(dir) + await Promise.all(Array.from({ length: 50 }, (_, note) => + writeFile(path.join(dir, `Note-${note}.md`), '# Note\n') + )) + } + const nativeHandles = (): number => + getActiveResourcesInfo().filter((resource) => resource === 'FSEventWrap').length + const baseline = nativeHandles() + const events: VaultChangeEvent[] = [] + const watcher = new VaultWatcher() + watchers.push(watcher) + watcher.start(root, (event) => events.push(event)) + + // Keep writing until the initial scan has completed, even on a slow disk. + await expect.poll(async () => { + await writeFile(path.join(root, 'folder-0', 'Note-0.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'folder-0/Note-0.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + // Allow per-directory watching on Linux, but never one handle per file. + expect(nativeHandles() - baseline).toBeLessThan(directoryCount * 2 + 10) + + await watcher.stop() + await expect.poll(nativeHandles).toBeLessThanOrEqual(baseline) + + const nextRoot = await makeVault() + events.length = 0 + watcher.start(nextRoot, (event) => events.push(event)) + await expect.poll(async () => { + await writeFile(path.join(nextRoot, 'Next.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'Next.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + }, 30_000) + + it('closes a failed watcher without an unhandled error and can start again', async () => { + const root = await makeVault() + const watch = vi.spyOn(chokidar, 'watch') + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + const events: VaultChangeEvent[] = [] + const watcher = new VaultWatcher() + watchers.push(watcher) + watcher.start(root, (event) => events.push(event)) + const active = watch.mock.results.find((result) => result.type === 'return')?.value + if (!active) throw new Error('Watcher did not start') + const closed = vi.spyOn(active, 'close') + + active.emit('error', Object.assign(new Error('Watch limit reached'), { code: 'EMFILE' })) + expect(closed).toHaveBeenCalledOnce() + expect(report).toHaveBeenCalledOnce() + await active.close() + + watcher.start(root, (event) => events.push(event)) + await expect.poll(async () => { + await writeFile(path.join(root, 'Recovered.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'Recovered.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + }, 15_000) +}) + describe('VaultWatcher custom templates (#723)', () => { it( 'announces a template file under its own scope, and ignores what is not a template', diff --git a/apps/desktop/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts index faa79458f..0ed9d25a6 100644 --- a/apps/desktop/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import chokidar, { FSWatcher } from 'chokidar' +import chokidar, { type FSWatcher } from 'chokidar' import type { NoteFolder, VaultChangeEvent, VaultChangeKind, VaultSettings } from '@shared/ipc' import { databaseCsvPathFor } from '@shared/databases' import { AGENT_EDITS_DIR, AGENT_EDITS_SUFFIX } from '@shared/agent-edits' @@ -104,12 +104,14 @@ export class VaultWatcher { } start(root: string, onEvent: (ev: VaultChangeEvent) => void): void { - this.stop() + void this.stop() this.root = root this.settings = null this.pending = [] this.refreshSettings(root) - this.watcher = chokidar.watch(root, { + // Chokidar 6 uses native recursive watching on macOS and Windows, avoiding + // a file handle for every note and asset in a large vault. + const watcher = chokidar.watch(root, { ignoreInitial: true, persistent: true, ignored: (p: string) => { @@ -127,6 +129,7 @@ export class VaultWatcher { pollInterval: 40 } }) + this.watcher = watcher const handler = (kind: VaultChangeKind) => (absPath: string) => { const base = path.basename(absPath) @@ -218,7 +221,12 @@ export class VaultWatcher { }) } - this.watcher + watcher + .on('error', (error) => { + if (this.watcher !== watcher) return + console.error('Vault watcher failed; live updates have stopped', error) + void this.stop() + }) .on('add', handler('add')) .on('change', handler('change')) .on('unlink', handler('unlink')) @@ -226,15 +234,18 @@ export class VaultWatcher { .on('unlinkDir', dirHandler('unlink')) } - stop(): void { - if (this.watcher) { - void this.watcher.close() - this.watcher = null - this.root = null - // Anything still waiting on the first load belongs to the vault we just - // stopped watching; nobody wants it now. - this.pending = null - this.settings = null + async stop(): Promise { + const watcher = this.watcher + this.watcher = null + this.root = null + // Anything still waiting on the first load belongs to the vault we just + // stopped watching; nobody wants it now. + this.pending = null + this.settings = null + try { + await watcher?.close() + } catch (error) { + console.error('Failed to close vault watcher', error) } } } diff --git a/package-lock.json b/package-lock.json index 1dc724e53..c322f43a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "vite": "^6.4.3" }, "engines": { - "node": ">=22" + "node": ">=22.22" } }, "apps/desktop": { @@ -46,7 +46,7 @@ "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", - "chokidar": "^4.0.3", + "chokidar": "^6.0.0", "codemirror": "^6.0.1", "docx": "^9.7.1", "dompurify": "^3.3.4", @@ -99,7 +99,7 @@ "vitest": "^3.2.6" }, "engines": { - "node": ">=22" + "node": ">=22.22" } }, "apps/desktop/node_modules/@codemirror/lint": { @@ -6970,15 +6970,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-6.0.0.tgz", + "integrity": "sha512-Ec2Z7wpcKBiMpR7DxteoqS/sq5G5d2A4fpxzPv6+fD7xNNdrTghn7L11YwV5EN1B3i72Erc3XVT5FkWIDx90+A==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.1.1" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 22.22" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -13631,12 +13631,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -14113,19 +14113,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/sass/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", diff --git a/package.json b/package.json index 532043046..560133f79 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { - "node": ">=22" + "node": ">=22.22" }, "workspaces": [ "apps/*", From c82c334d2be418134e95f491fc52c06d960e4102 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 14:58:58 -0500 Subject: [PATCH 02/53] Feat(browse): the phones can open a folder's Gallery from its menu The desktop folder menu has Open as Gallery since 2.79.0, but the action lived only on the store (openFolderGallery), which is in none of app-core's public entries, so the phone shells could not offer it on their folder sheets. openBrowseFolderGallery(host, directory) joins the other folder actions in @zennotes/app-core/browse. It takes a Browse directory (relative to the primary notes area, '' for its root), refuses a database, its internals and a missing folder with 'unavailable' as the desktop menu has no Gallery there, and does not open behind an open dialog. The cards draw vault covers from the file list, which is read lazily, so the action reads it first and opens nothing when the host's context changed during that read ('stale'). Desktop is unchanged: its menu, palette and :gallery still call the store action. The shells adopt this after core 2.80.0 ships. --- packages/app-core/README.md | 1 + packages/app-core/src/browse-actions.test.ts | 55 ++++++++++++++++++++ packages/app-core/src/browse.ts | 1 + packages/app-core/src/lib/browse-actions.ts | 25 +++++++++ 4 files changed, 82 insertions(+) diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 4e9fa55ed..fdedddbdc 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -225,6 +225,7 @@ host's stable vault token. - `requestCreateBrowseFolder(host, directory = '')` prompts for a child folder. - `requestCreateBrowseNote(host, directory = '')` creates a note in an ordinary Browse folder (`''` is the primary root) and opens it with its title focused, without a prompt. A missing folder or a database is refused with `unavailable`. - `requestRenameBrowseFolder(host, directory)` prompts for an ordinary folder's leaf name. +- `openBrowseFolderGallery(host, directory = '')` opens an ordinary Browse folder (`''` is the primary root) as a Gallery, the desktop folder menu's Open as Gallery, with no prompt. It reads the file index first when it has not been read, since cards draw vault covers from it, and opens nothing if the context changed during that read (`stale`). A missing folder, a database, or a database internal is refused with `unavailable`. - `requestMoveBrowseDirectory(host, directory)` prompts for a new parent for an ordinary folder or an entire database, using the move-note prompt's `inbox[/path]` values. Only existing notes-area folders are offered, never the directory itself, its descendants, or a database. The leaf name, and so a database's `.base` suffix, is kept. A destination that already holds that name is blocked in the prompt, and the host still refuses to overwrite. The store carries open tabs (database tabs included), folder icons and colors, favorites, and manual order to the new path. Pins are host-owned: a host that pins folders re-keys them after `completed`. - `requestDeleteBrowseDirectory(host, directory)` confirms permanent deletion of an ordinary folder or an entire database, with the appropriate warning. diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts index f6b6e47b8..386aea094 100644 --- a/packages/app-core/src/browse-actions.test.ts +++ b/packages/app-core/src/browse-actions.test.ts @@ -490,6 +490,61 @@ describe('public Browse actions', () => { } ) + it('opens a folder or the notes root as a Gallery tab through the public entry', async () => { + const s = await setup() + const browse = await import('./browse') + const { galleryTabPath } = await import('./lib/folder-gallery') + const { findLeaf } = await import('./lib/pane-layout') + const activeTab = () => { + const state = s.useStore.getState() + return findLeaf(state.paneLayout, state.activePaneId)?.activeTab + } + expect(await browse.openBrowseFolderGallery(s.host, 'Work/Nested')).toBe('completed') + expect(activeTab()).toBe(galleryTabPath('inbox', 'Work/Nested')) + expect(s.refreshAssets).toHaveBeenCalled() + expect(await browse.openBrowseFolderGallery(s.host)).toBe('completed') + expect(activeTab()).toBe(galleryTabPath('inbox', '')) + }) + + it('has no Gallery for databases, their internals, or missing folders', async () => { + const s = await setup() + const openFolderGallery = vi.fn(async () => {}) + s.useStore.setState({ openFolderGallery }) + for (const directory of ['People.base', 'People.base/pages', 'Missing']) + expect(await s.openBrowseFolderGallery(s.host, directory)).toBe('unavailable') + expect(openFolderGallery).not.toHaveBeenCalled() + }) + + it('opens nothing when the context changes while the file list is read', async () => { + const s = await setup() + const openFolderGallery = vi.fn(async () => {}) + let current = true + s.refreshAssets.mockImplementationOnce(async () => { + current = false + }) + s.useStore.setState({ openFolderGallery }) + expect(await s.openBrowseFolderGallery({ isCurrent: () => current }, 'Work')).toBe('stale') + expect(openFolderGallery).not.toHaveBeenCalled() + // Read once, the list is not read again. + current = true + s.useStore.setState({ assetFilesListed: true }) + s.refreshAssets.mockClear() + expect(await s.openBrowseFolderGallery(s.host, 'Work')).toBe('completed') + expect(s.refreshAssets).not.toHaveBeenCalled() + expect(openFolderGallery).toHaveBeenCalledWith('inbox', 'Work') + }) + + it('does not open a Gallery behind an open dialog', async () => { + const s = await setup() + const openFolderGallery = vi.fn(async () => {}) + s.useStore.setState({ openFolderGallery }) + const rename = s.requestRenameBrowseFolder(s.host, 'Work') + expect(await s.openBrowseFolderGallery(s.host, 'Work')).toBe('unavailable') + s.answer(null) + expect(await rename).toBe('cancelled') + expect(openFolderGallery).not.toHaveBeenCalled() + }) + it('rejects a host refusal to move and releases the pending action', async () => { const s = await setup() s.rename.mockRejectedValueOnce(new Error('A folder already exists at "Work/People.base"')) diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts index 9cafb91fc..dd316e58f 100644 --- a/packages/app-core/src/browse.ts +++ b/packages/app-core/src/browse.ts @@ -276,6 +276,7 @@ export function getBrowseDirectory( export { createBrowseDatabase, + openBrowseFolderGallery, requestRenameBrowseDatabase, requestCreateBrowseFolder, requestCreateBrowseNote, diff --git a/packages/app-core/src/lib/browse-actions.ts b/packages/app-core/src/lib/browse-actions.ts index cdea4e102..e34b57990 100644 --- a/packages/app-core/src/lib/browse-actions.ts +++ b/packages/app-core/src/lib/browse-actions.ts @@ -141,6 +141,31 @@ export async function requestCreateBrowseNote( } } +/** + * Open a primary-notes directory as a Gallery, the desktop folder menu's + * Open as Gallery. An empty directory means its root. A database lists its + * record pages in its own grid, so it has no Gallery, as on desktop. The + * cards draw vault covers from the file list, which is read lazily: it is + * read here first, so a vault switch during that read opens nothing. + */ +export async function openBrowseFolderGallery( + host: BrowseActionHost, + directory = '' +): Promise { + if (formDirContaining(directory)) return 'unavailable' + const context = start(host, directory, true) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + if (!useStore.getState().assetFilesListed) await useStore.getState().refreshAssets() + if (!isCurrent() || !targetExists()) return 'stale' + await useStore.getState().openFolderGallery('inbox', directory) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + /** Prompt for an ordinary folder's leaf name. Database renaming uses its own feature. */ export async function requestRenameBrowseFolder( host: BrowseActionHost, From 3c8fb85025d3199ecb7d3c96b0d1fab3715dee17 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 15:11:55 -0500 Subject: [PATCH 03/53] Fix(cloud): one file Cloud refuses no longer stops the whole sync (#1004) Cloud and the apps keep separate lists of which vault paths may sync. When they drifted (an assistant edit record under .zennotes/agent-edits/ that the server had never been told about), Cloud answered the batch with 422 VALIDATION_FAILED naming mutations.N.path, the run failed before it saved anything, and every run after it planned the same upload and failed the same way. The server list was fixed in ZenNotes/website#98, but the next drift would wedge sync again. The coordinator now reads a refusal that names only upsert and move paths (cloud-sync-refusal.ts), sets those paths aside in sync state (refused_paths, keyed by path), and sends the rest of the batch again. A path set aside is left out of planning for a day, then offered once more, since Cloud may have learned to take it; it is forgotten when the file leaves this device. Only the write that carries the path waits: tracked state is untouched, so a refused move leaves the item where Cloud has it instead of turning into a delete. Any other validation failure still stops the run as before. Which field Cloud refused is only in the answer's errors keys, so the desktop transport now keeps that object under details.errors. Desktop also sends one batch as several requests (inline posts and direct uploads); a refusal there names its place in the batch and carries what the earlier requests applied, so sync commits that and resends only what went unanswered. The phone transports drop the keys today: until the shells forward them, a refusal there still stops the run, now with its own sentence instead of "something ZenNotes didn't expect". The run summary carries refused_paths, and Cloud's copy words them calmly: the status bar says "1 file stays on this device", Settings lists the file with Cloud's answer under Details. The shared-domain tests also carry a synced copy of the website's shouldSync dataset, so the two lists are compared in both repos. --- .../src/main/cloud-sync-client.test.ts | 73 ++++++ apps/desktop/src/main/cloud-sync-client.ts | 77 +++++- apps/desktop/src/main/cloud-sync-service.ts | 3 +- .../app-core/src/lib/cloud-auto-sync.test.ts | 37 +++ packages/app-core/src/lib/cloud-auto-sync.ts | 33 ++- packages/app-core/src/lib/help.ts | 2 +- packages/bridge-contract/src/cloud-sync.ts | 12 + .../src/cloud-error-copy.test.ts | 19 ++ .../shared-domain/src/cloud-error-copy.ts | 28 ++ .../src/cloud-sync-coordinator.ts | 174 ++++++++++--- .../shared-domain/src/cloud-sync-engine.ts | 13 + .../src/cloud-sync-host-service.ts | 3 +- .../src/cloud-sync-refusal.test.ts | 242 ++++++++++++++++++ .../shared-domain/src/cloud-sync-refusal.ts | 112 ++++++++ packages/shared-domain/src/cloud-sync.test.ts | 21 ++ 15 files changed, 790 insertions(+), 59 deletions(-) create mode 100644 packages/shared-domain/src/cloud-sync-refusal.test.ts create mode 100644 packages/shared-domain/src/cloud-sync-refusal.ts diff --git a/apps/desktop/src/main/cloud-sync-client.test.ts b/apps/desktop/src/main/cloud-sync-client.test.ts index 2d5c45de5..9cfea78c5 100644 --- a/apps/desktop/src/main/cloud-sync-client.test.ts +++ b/apps/desktop/src/main/cloud-sync-client.test.ts @@ -5,6 +5,7 @@ import os from 'node:os' import path from 'node:path' import type { CloudSyncUpsertMutation } from '@zennotes/bridge-contract/cloud-sync' import { CloudServiceRequestError, createCloudSyncClient } from './cloud-sync-client' +import { CloudSyncPathRefusedError, cloudSyncPathRefusal } from '@zennotes/shared-domain/cloud-sync-refusal' import { rememberCloudSyncUploadSource } from './cloud-sync-upload-source' vi.mock('node:fs', { spy: true }) @@ -168,6 +169,78 @@ describe('createCloudSyncClient', () => { ) }) + it('keeps which fields a 422 refused, so sync can set one path aside (#1004)', async () => { + const refused = 'This path is not eligible for vault sync.' + const fetchImplementation = vi.fn().mockResolvedValue( + jsonResponse( + { + error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, + errors: { 'mutations.1.path': [refused] } + }, + 422 + ) + ) + const client = createCloudSyncClient('https://zennotes.org', 'token', fetchImplementation) + const body = { + mutations: [ + { ...upsertMutation(4, 'YWJjZA=='), path: 'inbox/Note.md' }, + { ...upsertMutation(4, 'YWJjZA=='), operation_id: 'second', path: '.zennotes/agent-edits/inbox/Note.md.json' } + ] + } + + const error = await client.mutate('vault-1', body).catch((caught: unknown) => caught) + + expect(error).toMatchObject({ + status: 422, + code: 'VALIDATION_FAILED', + message: refused, + details: { errors: { 'mutations.1.path': [refused] } } + }) + expect(cloudSyncPathRefusal(error, body)?.indices).toEqual([1]) + }) + + it('names a direct upload refused for its path by its place in the batch, with what went before', async () => { + const refused = 'This path is not eligible for vault sync.' + const bytes = Buffer.alloc(INLINE_UPLOAD_LIMIT_BYTES + 1, 17) + const small = { ...upsertMutation(4, 'YWJjZA=='), operation_id: 'small', item_id: 'small-item', path: 'inbox/Note.md' } + const large = upsertMutation(bytes.byteLength, bytes.toString('base64')) + const after = { ...small, operation_id: 'after', item_id: 'after-item', path: 'inbox/After.md' } + const requests: string[] = [] + const fetchImplementation = vi.fn(async (input) => { + const url = String(input) + requests.push(url.slice(url.lastIndexOf('/') + 1)) + if (url.endsWith('/mutations')) { + return jsonResponse({ + acknowledged: [{ operation_id: 'small', item_id: 'small-item', revision: 1, sequence: 7 }], + conflicts: [], + cursor: 7 + }) + } + if (url.endsWith('/uploads')) { + return jsonResponse( + { error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, errors: { path: [refused] } }, + 422 + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + const client = createCloudSyncClient('https://zennotes.org', 'token', fetchImplementation) + + const error = await client.mutate('vault-1', { mutations: [small, large, after] }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(CloudSyncPathRefusedError) + const refusal = (error as CloudSyncPathRefusedError).refusal + expect(refusal.indices).toEqual([1]) + expect(refusal.applied).toEqual({ + acknowledged: [{ operation_id: 'small', item_id: 'small-item', revision: 1, sequence: 7 }], + conflicts: [], + cursor: 7 + }) + expect(refusal.detail).toBe(`HTTP 422 · VALIDATION_FAILED · ${refused}`) + // After.md was never sent; sync sends it again without the refused file. + expect(requests).toEqual(['mutations', 'uploads']) + }) + it('lets fetch set the multipart boundary for published-note assets', async () => { const fetchImplementation = vi.fn().mockResolvedValue( new Response( diff --git a/apps/desktop/src/main/cloud-sync-client.ts b/apps/desktop/src/main/cloud-sync-client.ts index f67efea69..245448a1f 100644 --- a/apps/desktop/src/main/cloud-sync-client.ts +++ b/apps/desktop/src/main/cloud-sync-client.ts @@ -13,6 +13,11 @@ import os from 'node:os' import path from 'node:path' import { zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee' import { cloudProtocolError } from '@zennotes/shared-domain/cloud-error-copy' +import { + CloudSyncPathRefusedError, + cloudSyncPathRefusal, + isCloudSyncUploadPathRefused +} from '@zennotes/shared-domain/cloud-sync-refusal' import type { CloudVaultCipher, CloudVaultCipherSource } from '@zennotes/shared-domain/cloud-e2ee-vault' import type { CloudPublishUploadTarget, @@ -133,27 +138,48 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { const responses: CloudSyncMutationResponse[] = [] let inlineMutations: CloudSyncMutation[] = [] + let inlineIndices: number[] = [] + // The batch goes out as several requests, so a path Cloud refuses in one + // of them comes back after the earlier ones were applied. The refusal + // names positions in the batch this was given and carries what was + // applied, so sync commits that and resends only what went unanswered. + const refused = (error: unknown, indices: number[]): never => { + const refusal = indices.length === 1 && isCloudSyncUploadPathRefused(error) + ? cloudSyncPathRefusal(withMutationField(error, 0), { mutations: [body.mutations[indices[0]]] }) + : cloudSyncPathRefusal(error, { mutations: indices.map((index) => body.mutations[index]) }) + if (!refusal) throw error + throw new CloudSyncPathRefusedError( + { ...refusal, indices: refusal.indices.map((index) => indices[index]), applied: merged(responses) }, + error + ) + } const flushInline = async (): Promise => { if (inlineMutations.length === 0) return - responses.push(await super.mutate(vaultId, { mutations: inlineMutations })) + try { + responses.push(await super.mutate(vaultId, { mutations: inlineMutations })) + } catch (error) { + refused(error, inlineIndices) + } inlineMutations = [] + inlineIndices = [] } - for (const mutation of body.mutations) { + for (const [index, mutation] of body.mutations.entries()) { if (direct(mutation)) { await flushInline() - responses.push(await this.directUpload(vaultId, mutation)) + try { + responses.push(await this.directUpload(vaultId, mutation)) + } catch (error) { + refused(error, [index]) + } } else { inlineMutations.push(mutation) + inlineIndices.push(index) } } await flushInline() - return { - acknowledged: responses.flatMap((response) => response.acknowledged), - conflicts: responses.flatMap((response) => response.conflicts), - cursor: Math.max(0, ...responses.map((response) => response.cursor)) - } + return merged(responses) } private async directUpload( @@ -414,6 +440,16 @@ function asErrorPayload(payload: unknown): { }) : response const validationMessage = firstValidationMessage(response.errors) + const details = + candidate.details && typeof candidate.details === 'object' && !Array.isArray(candidate.details) + ? (candidate.details as Record) + : null + // Which field a 422 refused is only in the answer's `errors` keys + // (`mutations.3.path`); sync needs it to set one refused path aside (#1004). + const errors = + response.errors && typeof response.errors === 'object' && !Array.isArray(response.errors) + ? response.errors + : null return { ...(typeof candidate.code === 'string' ? { code: candidate.code } : {}), @@ -422,14 +458,29 @@ function asErrorPayload(payload: unknown): { : typeof candidate.message === 'string' ? { message: candidate.message } : {}), - ...(candidate.details && - typeof candidate.details === 'object' && - !Array.isArray(candidate.details) - ? { details: candidate.details as Record } - : {}) + ...(details || errors ? { details: { ...details, ...(errors ? { errors } : {}) } } : {}) } } +function merged(responses: CloudSyncMutationResponse[]): CloudSyncMutationResponse { + return { + acknowledged: responses.flatMap((response) => response.acknowledged), + conflicts: responses.flatMap((response) => response.conflicts), + cursor: Math.max(0, ...responses.map((response) => response.cursor)) + } +} + +/** A direct upload's refusal names its own request's `path`; as one + * mutation of a batch it is that mutation's path. */ +function withMutationField(error: unknown, index: number): unknown { + if (!(error instanceof CloudServiceRequestError)) return error + const errors = error.details?.errors as Record | undefined + return new CloudServiceRequestError(error.message, error.status, error.code, { + ...error.details, + errors: { [`mutations.${index}.path`]: errors?.path ?? [] } + }, error.headers) +} + function usesDirectUpload(mutation: CloudSyncMutation, encrypted: boolean): mutation is CloudSyncUpsertMutation { if (mutation.type !== 'upsert') return false const bytes = encrypted ? zne1CiphertextLength(mutation.content.byte_length) : mutation.content.byte_length diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index d7b0df3e1..0d1b6b553 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -662,7 +662,8 @@ export class DesktopCloudSyncService { bootstrap_conflicts: result.bootstrapConflicts, local_conflicts: result.localConflicts, pending_conflicts: result.pendingConflicts, - legacy_conflict_copies: result.legacyConflictCopies + legacy_conflict_copies: result.legacyConflictCopies, + refused_paths: result.refusedPaths } } catch (error) { if (isCloudVaultSuperseded(error) && await this.retireMissingLink(localRoot, link)) { diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index e7e2ad8ab..6e6f711e2 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -814,6 +814,43 @@ describe("cloudSyncAttentionItems (Discord: name the file, not the count)", () = expect(items.map((item) => item.path)).toEqual(["inbox/Old.md", "A file"]); }); + it("names a file Cloud refused, calmly, with Cloud's answer under Details (#1004)", async () => { + const record = ".zennotes/agent-edits/inbox/Note.md.json"; + const detail = "HTTP 422 · VALIDATION_FAILED · This path is not eligible for vault sync."; + const summary: CloudSyncRunSummary = { + ...base, + pushed: 3, + refused_paths: [{ path: record, detail }], + }; + expect(cloudSyncAttentionItems(summary)).toEqual([ + { + kind: "refused", + path: record, + detail: + "ZenNotes Cloud didn't accept this file, so it stays on this device. Sync offers it again once a day.", + conflictCopyPath: null, + technical: detail, + }, + ]); + expect(cloudSyncAttentionMessage(summary)).toMatch( + /^ZenNotes Cloud didn't accept “Note\.md\.json”, so it stays on this device while the rest of the vault keeps syncing\./, + ); + expect(cloudSyncAttentionLabel(summary)).toBe("1 file stays on this device"); + // Anything else wrong in the same run keeps its own words. + expect( + cloudSyncAttentionLabel({ + ...summary, + conflicts: [{ operation_id: "op", item_id: "i", code: "REVISION_CONFLICT", current_revision: 2, current_path: null }], + }), + ).toBeNull(); + + clearCloudSyncStatus(); + await syncCloudVaultWithStatus({ syncCloudVault: async () => summary }, "Notes"); + expect(useCloudSyncStatusStore.getState().phase).toBe("attention"); + expect(useCloudSyncStatusStore.getState().error).toBe(cloudSyncAttentionMessage(summary)); + clearCloudSyncStatus(); + }); + it("remembers the last run summary so Review can show it without another sync", async () => { clearCloudSyncStatus(); const summary: CloudSyncRunSummary = { diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 267a811d4..d7b54c249 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -17,6 +17,9 @@ import { } from "@zennotes/shared-domain/cloud-auto-sync"; import { formatCloudBytes } from "@zennotes/shared-domain/cloud-bytes"; import { + CLOUD_SYNC_REFUSED_PATH_ROW, + cloudSyncRefusedPathsLabel, + cloudSyncRefusedPathsMessage, describeCloudError, type CloudErrorCopy, type CloudErrorKind, @@ -1093,16 +1096,24 @@ export function oversizedFileConflicts(summary: CloudSyncRunSummary): CloudSyncC /** * The few words the status row and the Settings heading give a run that could - * not finish because of a Cloud limit. A phone has no hover to show the - * sentence behind "Sync incomplete", so the limit names itself. Null when the - * run stopped for any other reason. + * not finish because of a Cloud limit, or whose only trouble is a file Cloud + * refused. A phone has no hover to show the sentence behind "Sync incomplete", + * so the reason names itself. Null when the run stopped for any other reason. */ export function cloudSyncAttentionLabel( summary: CloudSyncRunSummary | null, ): string | null { if (!summary) return null; const capacityConflict = firstCapacityConflict(summary); - if (!capacityConflict) return null; + if (!capacityConflict) { + // A run whose only trouble is a file Cloud refused names that, calmly. + const refused = summary.refused_paths ?? []; + const onlyRefused = + refused.length > 0 && + cloudSyncAttentionMessage(summary) === + cloudSyncRefusedPathsMessage(refused.map((row) => row.path)); + return onlyRefused ? cloudSyncRefusedPathsLabel(refused.length) : null; + } const dimension = capacityConflict.capacity?.dimension; if (dimension === "sync_max_file_bytes" || capacityConflict.code === "FILE_SIZE_LIMIT_EXCEEDED") { const count = Math.max(1, oversizedFileConflicts(summary).length); @@ -1156,6 +1167,8 @@ export function cloudSyncAttentionMessage( const count = summary.conflicts.length; return `${count} ${count === 1 ? "change" : "changes"} didn't sync. The list below says what happened to each file.`; } + const refused = summary.refused_paths ?? []; + if (refused.length > 0) return cloudSyncRefusedPathsMessage(refused.map((row) => row.path)); return null; } @@ -1174,7 +1187,8 @@ export interface CloudSyncAttentionItem { | "kept-local" | "settings" | "bootstrap" - | "rejected"; + | "rejected" + | "refused"; /** The file on this device the item is about. */ path: string; /** What happened and what to do, in plain words. */ @@ -1290,6 +1304,15 @@ export function cloudSyncAttentionItems( technical: known ? null : `${conflict.code} · item ${conflict.item_id}`, }); } + for (const refused of summary.refused_paths ?? []) { + items.push({ + kind: "refused", + path: refused.path, + detail: CLOUD_SYNC_REFUSED_PATH_ROW, + conflictCopyPath: null, + technical: refused.detail || null, + }); + } return items; } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 48823d901..d9679be43 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -249,7 +249,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'When Cloud reports a problem', body: - 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.' + 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.\n\nWhen Cloud refuses one file, usually one ZenNotes keeps for itself under `.zennotes`, the rest of the vault keeps syncing: the status bar says **1 file stays on this device**, and Settings → Cloud lists the file with Cloud\'s answer under Details. Nothing is lost, and there is nothing to change on your side. Sync offers the file to Cloud again once a day, so it goes up by itself once Cloud takes it; if it keeps coming back, copy the details and choose Report an Issue.' }, { title: 'Customize the look: themes vs. overrides', diff --git a/packages/bridge-contract/src/cloud-sync.ts b/packages/bridge-contract/src/cloud-sync.ts index 0a953a713..fa41e927d 100644 --- a/packages/bridge-contract/src/cloud-sync.ts +++ b/packages/bridge-contract/src/cloud-sync.ts @@ -656,6 +656,16 @@ export interface CloudSyncLocalConflict { conflict_copy_path: string | null; } +/** + * A file Cloud refused for its path (#1004). It stays on this device and the + * rest of the vault keeps syncing; sync offers it to Cloud again once a day. + */ +export interface CloudSyncRefusedPath { + path: string; + /** Cloud's answer, for Details. */ + detail: string; +} + export type CloudSyncPendingConflictKind = "content" | "delete" | "move" | "path"; /** A durable conflict kept in private app storage, never as a vault note. */ @@ -751,6 +761,8 @@ export interface CloudSyncRunSummary { pending_conflicts?: CloudSyncPendingConflict[]; /** Possible copies made by the pre-v2.44 conflict strategy. Never auto-deleted. */ legacy_conflict_copies?: Array<{ path: string; original_path: string }>; + /** Present on clients that set refused paths aside (v2.80 and later). */ + refused_paths?: CloudSyncRefusedPath[]; } /** Desktop-wide sync handshake. Drafts must be durable before a run starts. */ export type CloudSyncWindowEvent = diff --git a/packages/shared-domain/src/cloud-error-copy.test.ts b/packages/shared-domain/src/cloud-error-copy.test.ts index cb52117dc..7a13e35ea 100644 --- a/packages/shared-domain/src/cloud-error-copy.test.ts +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -11,6 +11,8 @@ import { CloudIssueError, cloudProtocolError, cloudSignInExchangeError, + cloudSyncRefusedPathsLabel, + cloudSyncRefusedPathsMessage, describeCloudError, encodeCloudErrorMessage } from './cloud-error-copy' @@ -188,6 +190,23 @@ describe('describeCloudError', () => { ).message).toBe('Use a Cloud vault name of 255 characters or fewer.') }) + it('words a refused path a client could not set aside, and keeps Cloud\'s answer for Details', () => { + const copy = describeCloudError( + serviceError('This path is not eligible for vault sync.', 422, 'VALIDATION_FAILED') + ) + expect(copy.message).toMatch(/^ZenNotes Cloud didn't accept the name or location of a file in this vault, so this sync stopped\. Update ZenNotes/) + expect(copy.detail).toBe('HTTP 422 · VALIDATION_FAILED · This path is not eligible for vault sync.') + }) + + it('names a refused file that sync set aside, calmly', () => { + expect(cloudSyncRefusedPathsMessage(['.zennotes/agent-edits/inbox/Note.md.json'])).toBe( + "ZenNotes Cloud didn't accept “Note.md.json”, so it stays on this device while the rest of the vault keeps syncing. Sync offers it to Cloud again once a day. If it keeps happening, copy the details and choose Report an Issue." + ) + expect(cloudSyncRefusedPathsMessage(['a.json', 'b.json'])).toMatch(/^ZenNotes Cloud didn't accept 2 files, so they stay on this device/) + expect(cloudSyncRefusedPathsLabel(1)).toBe('1 file stays on this device') + expect(cloudSyncRefusedPathsLabel(3)).toBe('3 files stay on this device') + }) + it('words internal checks and keeps their text for support', () => { const copy = describeCloudError(cloudProtocolError('Expected sync sequence 41, received 43')) expect(copy).toEqual({ diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts index ad4d1eef5..65c97266a 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -98,6 +98,10 @@ const UPLOAD_UNEXPECTED = 'ZenNotes Cloud gave an unexpected answer while a file was uploading, so ZenNotes stopped to keep your data safe. Sync will try again.' const UPLOAD_BROKEN = "A large file didn't arrive in ZenNotes Cloud in one piece. It is safe on this device, and sync will upload it again." +// A client that can tell which file it was sets that file aside instead +// (cloudSyncRefusedPathsMessage), so this is for one that cannot. +const PATH_REFUSED = + `ZenNotes Cloud didn't accept the name or location of a file in this vault, so this sync stopped. Update ZenNotes, then choose Sync now. ${REPORT}` const DOWNLOAD_INCOMPLETE = "A file from ZenNotes Cloud didn't finish downloading. Nothing on this device was changed. Sync will try again." const REMOTE_CHANGED = @@ -273,6 +277,8 @@ export function describeCloudError(error: unknown, fallback = CLOUD_UNEXPECTED_M return { message: raw.text || fallback, detail: null, kind: 'other' } } + if (raw.name === 'CloudSyncPathRefusedError') return worded(PATH_REFUSED) + if (raw.name === 'CloudSyncLocalEditConflictError' && raw.path) { return worded( `Sync stopped because “${fileName(raw.path)}” is a folder on this device but a file in Cloud. Rename one of them, then choose Sync now.` @@ -291,6 +297,27 @@ export function describeCloudError(error: unknown, fallback = CLOUD_UNEXPECTED_M return { message: raw.text, detail: null, kind: cloudErrorKindOf(raw.text) } } +/** + * Files Cloud refused for their path, set aside so the rest of the vault + * syncs (#1004). Nothing failed that the person can fix, and nothing is + * lost, so the words are calm; the file names come first because a refused + * file is usually one the app keeps under `.zennotes`, not a note. + */ +export function cloudSyncRefusedPathsMessage(paths: readonly string[]): string { + const subject = paths.length === 1 ? `“${fileName(paths[0])}”` : `${paths.length} files` + const stays = paths.length === 1 ? 'it stays' : 'they stay' + return `ZenNotes Cloud didn't accept ${subject}, so ${stays} on this device while the rest of the vault keeps syncing. Sync offers ${paths.length === 1 ? 'it' : 'them'} to Cloud again once a day. ${REPORT}` +} + +/** The few words for the status row: a phone has no hover for the sentence. */ +export function cloudSyncRefusedPathsLabel(count: number): string { + return `${count} ${count === 1 ? 'file stays' : 'files stay'} on this device` +} + +/** One refused file's row in Settings → Cloud, above its Details. */ +export const CLOUD_SYNC_REFUSED_PATH_ROW = + "ZenNotes Cloud didn't accept this file, so it stays on this device. Sync offers it again once a day." + /** * A failed sign-in exchange, in sign-in words. Sign-in never retries by itself * and the request it answered is spent, so the sync sentences ("sync will try @@ -381,6 +408,7 @@ function serviceMessage(raw: RawCloudError): { message: string; kind: CloudError case 'RESOURCE_BUSY': return other(RESOURCE_BUSY) case 'VALIDATION_FAILED': + if (/not eligible for vault sync/i.test(raw.text)) return other(PATH_REFUSED) if (/^The name field must not be greater than/i.test(raw.text)) return other(VAULT_NAME_TOO_LONG) if (/^The name field is required/i.test(raw.text)) return other(CLOUD_VAULT_NAME_REQUIRED_MESSAGE) return other(CLOUD_UNEXPECTED_ANSWER_MESSAGE) diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index c53a5290b..cbcf29201 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -17,6 +17,7 @@ import type { CloudSyncPendingConflict, CloudSyncPendingConflictDetails, CloudSyncPendingConflictResolution, + CloudSyncRefusedPath, CloudSyncRevisionResponse } from '@zennotes/bridge-contract/cloud-sync' import { @@ -35,10 +36,12 @@ import { type CloudSyncLocalItem, type CloudSyncState, type CloudSyncStoredConflict, + type CloudSyncStoredRefusedPath, type CloudSyncTrackedItem } from './cloud-sync-engine' import { mergeCloudSyncText, resolveCloudSyncMerge } from './cloud-sync-merge' import { CloudIssueError, cloudProtocolError } from './cloud-error-copy' +import { CLOUD_SYNC_REFUSED_PATH_RETRY_MS, cloudSyncPathRefusal } from './cloud-sync-refusal' import { validateCloudSyncContentReference, validateCloudSyncDownloadInstruction, cloudSyncStagedHandle, releaseCloudSyncStagedFile, throwIfCloudSyncCancelled, @@ -173,6 +176,8 @@ export interface CloudSyncRunResult { localConflicts: CloudSyncLocalConflict[] pendingConflicts: CloudSyncPendingConflict[] legacyConflictCopies: Array<{ path: string; original_path: string }> + /** Files Cloud refused for their path, still set aside after this run. */ + refusedPaths: CloudSyncRefusedPath[] } /** @@ -501,7 +506,8 @@ export class CloudSyncCoordinator { bootstrapConflicts: bootstrap.conflicts, localConflicts: bootstrap.localConflicts, pendingConflicts: pendingConflictSummaries(bootstrap.state), - legacyConflictCopies: [] + legacyConflictCopies: [], + refusedPaths: refusedPathSummaries(bootstrap.state) } } @@ -533,6 +539,11 @@ export class CloudSyncCoordinator { // Plan from the merged bytes, never from the scan preceding the write. localItems = await this.repository.scan() } + const refusals = currentRefusedPaths(state, localItems, Date.now()) + if (refusals !== state.refused_paths) { + state = withRefusedPaths(state, refusals) + await this.states.save(state) + } const pendingPathKeys = new Set([ ...repositoryPendingPaths.map(cloudSyncPathKey), ...pendingConflictPaths(state).map(cloudSyncPathKey) @@ -551,51 +562,92 @@ export class CloudSyncCoordinator { const mutationItems = localItems.filter( (item) => !pendingPathKeys.has(cloudSyncPathKey(item.path)) ) + // A path Cloud refused waits out its retry window. Only the write that + // carries it waits: tracked state is untouched, so a move held back here + // leaves the item where Cloud has it rather than turning into a delete. const plan = planCloudSyncMutations(mutationState, mutationItems, this.ids) + plan.mutations = plan.mutations.filter( + (mutation) => mutation.type === 'delete' || !refusals?.[cloudSyncPathKey(mutation.path)] + ) const conflicts: CloudSyncConflict[] = [] const pausedPathsByKey = new Map>() const acknowledgedSequences = new Set() let mutationCursor = state.cursor let pushed = 0 - for (const batch of mutationBatches(plan.mutations)) { - const response = await this.remote.mutate(this.vaultId, batch) - const before = state - const resolution = resolveCloudSyncMutations(state, batch, response) - state = resolution.state - pushed += response.acknowledged.length - // The server names rejected operations by id; the file they were about - // is only known here. Attach it so the user can be told which file - // needs attention instead of how many. - const byOperation = new Map( - batch.mutations.map((mutation) => [mutation.operation_id, mutation]) - ) - const annotatedConflicts = resolution.conflicts.map((conflict) => ({ - ...conflict, - path: conflictPath(conflict, byOperation.get(conflict.operation_id), before) - })) - conflicts.push(...annotatedConflicts) - for (const conflict of annotatedConflicts) { - if (conflict.code !== 'PATH_CONFLICT') continue - const mutation = byOperation.get(conflict.operation_id) - const paths = [ - conflict.path, - conflict.current_path, - mutation ? before.items[mutation.item_id]?.path : null - ].filter((path): path is string => Boolean(path)) - for (const keyPath of [conflict.path, conflict.current_path]) { - if (!keyPath) continue - const key = cloudSyncPathKey(keyPath) - const values = pausedPathsByKey.get(key) ?? new Set() - paths.forEach((path) => values.add(path)) - pausedPathsByKey.set(key, values) + for (const planned of mutationBatches(plan.mutations)) { + let batch = planned + while (batch.mutations.length > 0) { + let response: CloudSyncMutationResponse + let refused: CloudSyncMutation[] = [] + let refusalDetail = '' + try { + response = await this.remote.mutate(this.vaultId, batch) + } catch (error) { + // One path Cloud will not take used to fail the run before anything + // was saved, so every later run failed on it too (#1004). The rest + // of the batch goes again without it. + const refusal = cloudSyncPathRefusal(error, batch) + if (!refusal) throw error + response = refusal.applied + refused = refusal.indices.map((index) => batch.mutations[index]) + refusalDetail = refusal.detail + } + const before = state + const resolution = resolveCloudSyncMutations(state, batch, response) + state = resolution.state + pushed += response.acknowledged.length + // The server names rejected operations by id; the file they were about + // is only known here. Attach it so the user can be told which file + // needs attention instead of how many. + const byOperation = new Map( + batch.mutations.map((mutation) => [mutation.operation_id, mutation]) + ) + const annotatedConflicts = resolution.conflicts.map((conflict) => ({ + ...conflict, + path: conflictPath(conflict, byOperation.get(conflict.operation_id), before) + })) + conflicts.push(...annotatedConflicts) + for (const conflict of annotatedConflicts) { + if (conflict.code !== 'PATH_CONFLICT') continue + const mutation = byOperation.get(conflict.operation_id) + const paths = [ + conflict.path, + conflict.current_path, + mutation ? before.items[mutation.item_id]?.path : null + ].filter((path): path is string => Boolean(path)) + for (const keyPath of [conflict.path, conflict.current_path]) { + if (!keyPath) continue + const key = cloudSyncPathKey(keyPath) + const values = pausedPathsByKey.get(key) ?? new Set() + paths.forEach((path) => values.add(path)) + pausedPathsByKey.set(key, values) + } + } + mutationCursor = Math.max(mutationCursor, response.cursor) + for (const acknowledgement of response.acknowledged) { + acknowledgedSequences.add(acknowledgement.sequence) + } + if (refused.length > 0) { + const now = Date.now() + const next = { ...state.refused_paths } + for (const mutation of refused) { + if (mutation.type === 'delete') continue + next[cloudSyncPathKey(mutation.path)] = { path: mutation.path, refused_at: now, detail: refusalDetail } + } + state = withRefusedPaths(state, next) + } + await this.states.save(state) + if (refused.length === 0) break + const answered = new Set( + [...response.acknowledged, ...response.conflicts].map((answer) => answer.operation_id) + ) + batch = { + mutations: batch.mutations.filter( + (mutation) => !refused.includes(mutation) && !answered.has(mutation.operation_id) + ) } } - mutationCursor = Math.max(mutationCursor, response.cursor) - for (const acknowledgement of response.acknowledged) { - acknowledgedSequences.add(acknowledgement.sequence) - } - await this.states.save(state) } if (mutationCursor > state.cursor) { @@ -625,7 +677,8 @@ export class CloudSyncCoordinator { legacyConflictCopies: localItems.flatMap((item) => { const original = cloudSyncLegacyConflictOriginalPath(item.path) return original ? [{ path: item.path, original_path: original }] : [] - }) + }), + refusedPaths: refusedPathSummaries(state) } } @@ -2196,6 +2249,51 @@ function conflictPath( return state.items[conflict.item_id]?.path ?? conflict.current_path ?? null } +/** + * The refused paths still set aside at `now`: one whose retry window has + * passed is offered to Cloud again, and one no longer on this device has + * nothing left to wait for. State written by an older client, or damaged, + * reads as none. Returns the state's own record when nothing changed. + */ +function currentRefusedPaths( + state: CloudSyncState, + localItems: readonly CloudSyncLocalItem[], + now: number +): Record | undefined { + const stored = state.refused_paths + if (stored === undefined) return undefined + const present = new Set(localItems.map((item) => cloudSyncPathKey(item.path))) + const kept: Record = {} + let changed = typeof stored !== 'object' || stored === null || Array.isArray(stored) + for (const [key, entry] of changed ? [] : Object.entries(stored)) { + const valid = + entry !== null && + typeof entry === 'object' && + typeof entry.path === 'string' && + typeof entry.refused_at === 'number' && + typeof entry.detail === 'string' + if (valid && present.has(key) && now - entry.refused_at < CLOUD_SYNC_REFUSED_PATH_RETRY_MS) kept[key] = entry + else changed = true + } + return changed ? kept : stored +} + +function withRefusedPaths( + state: CloudSyncState, + refused: Record | undefined +): CloudSyncState { + const next = { ...state } + if (refused && Object.keys(refused).length > 0) next.refused_paths = refused + else delete next.refused_paths + return next +} + +function refusedPathSummaries(state: CloudSyncState): CloudSyncRefusedPath[] { + return Object.values(state.refused_paths ?? {}) + .map(({ path, detail }) => ({ path, detail })) + .sort((left, right) => left.path.localeCompare(right.path)) +} + function mutationBatches(mutations: CloudSyncMutation[]): CloudSyncMutationRequest[] { const batches: CloudSyncMutationRequest[] = [] let batch: CloudSyncMutation[] = [] diff --git a/packages/shared-domain/src/cloud-sync-engine.ts b/packages/shared-domain/src/cloud-sync-engine.ts index c3a89f4e9..e1f591162 100644 --- a/packages/shared-domain/src/cloud-sync-engine.ts +++ b/packages/shared-domain/src/cloud-sync-engine.ts @@ -72,6 +72,19 @@ export interface CloudSyncState { items: Record /** Optional for backward compatibility with state written before v2.44. */ pending_conflicts?: Record + /** Paths Cloud refused, by path key, set aside so the rest of the vault + * syncs (#1004). Absent in state written before v2.80. */ + refused_paths?: Record +} + +/** A path Cloud would not take. Sync offers it again once the retry window + * has passed, since Cloud may have learned to take it. */ +export interface CloudSyncStoredRefusedPath { + path: string + /** When Cloud last refused it, in milliseconds since the epoch. */ + refused_at: number + /** Cloud's answer, for Details. */ + detail: string } export interface CloudSyncIdSource { diff --git a/packages/shared-domain/src/cloud-sync-host-service.ts b/packages/shared-domain/src/cloud-sync-host-service.ts index dda11c1e5..260612ce2 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.ts @@ -317,7 +317,8 @@ export class CloudSyncHostService { bootstrap_conflicts: result.bootstrapConflicts, local_conflicts: result.localConflicts, pending_conflicts: result.pendingConflicts, - legacy_conflict_copies: result.legacyConflictCopies + legacy_conflict_copies: result.legacyConflictCopies, + refused_paths: result.refusedPaths } } catch (error) { // Another device moved this vault into an encrypted one. The link has diff --git a/packages/shared-domain/src/cloud-sync-refusal.test.ts b/packages/shared-domain/src/cloud-sync-refusal.test.ts new file mode 100644 index 000000000..11976ba92 --- /dev/null +++ b/packages/shared-domain/src/cloud-sync-refusal.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CloudSyncContent, + CloudSyncMutationRequest, + CloudSyncMutationResponse +} from '@zennotes/bridge-contract/cloud-sync' +import { CloudSyncCoordinator, type CloudSyncRemote, type CloudSyncStateStore } from './cloud-sync-coordinator' +import type { CloudSyncLocalItem, CloudSyncState } from './cloud-sync-engine' +import { + CLOUD_SYNC_REFUSED_PATH_RETRY_MS, + CloudSyncPathRefusedError, + cloudSyncPathRefusal, + isCloudSyncUploadPathRefused +} from './cloud-sync-refusal' + +const NOTE = 'inbox/Note.md' +const RECORD = '.zennotes/agent-edits/inbox/Note.md.json' +const REFUSED = 'This path is not eligible for vault sync.' + +function content(data: string): CloudSyncContent { + return { encoding: 'utf8', data, sha256: `hash:${data}`, byte_length: data.length, media_type: 'text/markdown' } +} + +function text(path: string, data = path): CloudSyncLocalItem { + return { path, kind: 'text', content: content(data) } +} + +/** What the desktop and phone transports throw for a Laravel 422. */ +function validationError(errors: Record): Error { + return Object.assign(new Error(Object.values(errors)[0][0]), { + name: 'CloudServiceRequestError', + status: 422, + code: 'VALIDATION_FAILED', + details: { errors } + }) +} + +function harness(items: CloudSyncLocalItem[], initial?: Partial) { + let state: CloudSyncState | null = { version: 1, vault_id: 'vault-1', cursor: 0, items: {}, ...initial } + const states: CloudSyncStateStore & { current: () => CloudSyncState | null } = { + current: () => state, + async load() { + return state + }, + async save(next) { + state = structuredClone(next) + } + } + const local = { items } + const requests: CloudSyncMutationRequest[] = [] + const cloud = { refuses: (path: string) => path.startsWith('.zennotes/agent-edits/') } + let sequence = 0 + const answer = (body: CloudSyncMutationRequest): CloudSyncMutationResponse => { + const acknowledged = body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, + item_id: mutation.item_id, + revision: 1, + sequence: ++sequence + })) + return { acknowledged, conflicts: [], cursor: sequence } + } + const server: CloudSyncRemote = { + async manifest() { + return { data: [], cursor: 0, next_page: null } + }, + async changes(_vaultId, after) { + return { data: [], cursor: after, has_more: false } + }, + async mutate(_vaultId, body) { + requests.push(structuredClone(body)) + // Laravel validates the whole request before applying any of it. + const errors: Record = {} + body.mutations.forEach((mutation, index) => { + if (mutation.type !== 'delete' && cloud.refuses(mutation.path)) errors[`mutations.${index}.path`] = [REFUSED] + }) + if (Object.keys(errors).length > 0) throw validationError(errors) + return answer(body) + } + } + let operation = 0 + let item = 0 + const coordinator = new CloudSyncCoordinator('vault-1', server, { + async scan() { + return local.items + }, + async apply() {} + }, states, { + itemId: () => `item-${++item}`, + operationId: () => `operation-${++operation}` + }) + const paths = (request: CloudSyncMutationRequest) => + request.mutations.map((mutation) => (mutation.type === 'delete' ? `delete ${mutation.item_id}` : mutation.path)) + return { coordinator, states, local, requests, cloud, server, answer, paths } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-29T12:00:00Z')) +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe('a path Cloud refuses (#1004)', () => { + it('is set aside while the rest of the vault syncs', async () => { + const h = harness([text(NOTE), text(RECORD)]) + + const result = await h.coordinator.sync() + + // The refused request, then the same batch without the refused path. + expect(h.requests.map(h.paths)).toEqual([[RECORD, NOTE], [NOTE]]) + expect(result.pushed).toBe(1) + expect(Object.values(h.states.current()!.items).map((row) => row.path)).toEqual([NOTE]) + expect(result.refusedPaths).toEqual([ + { path: RECORD, detail: `HTTP 422 · VALIDATION_FAILED · ${REFUSED}` } + ]) + expect(h.states.current()!.refused_paths).toEqual({ + [RECORD.toLowerCase()]: { + path: RECORD, + refused_at: Date.parse('2026-10-29T12:00:00Z'), + detail: `HTTP 422 · VALIDATION_FAILED · ${REFUSED}` + } + }) + }) + + it('is not offered again on every run, and is once a day has passed', async () => { + const h = harness([text(NOTE), text(RECORD)]) + await h.coordinator.sync() + h.requests.length = 0 + + vi.advanceTimersByTime(CLOUD_SYNC_REFUSED_PATH_RETRY_MS - 1) + const later = await h.coordinator.sync() + expect(h.requests).toEqual([]) + expect(later.refusedPaths.map((row) => row.path)).toEqual([RECORD]) + + // Still refused a day on: set aside again, from now. + vi.advanceTimersByTime(1) + const nextDay = await h.coordinator.sync() + expect(h.requests.map(h.paths)).toEqual([[RECORD]]) + expect(nextDay.refusedPaths.map((row) => row.path)).toEqual([RECORD]) + expect(h.states.current()!.refused_paths?.[RECORD.toLowerCase()].refused_at).toBe(Date.now()) + + // Cloud learned to take it (the #1004 fix was on the server). + h.cloud.refuses = () => false + h.requests.length = 0 + vi.advanceTimersByTime(CLOUD_SYNC_REFUSED_PATH_RETRY_MS) + const accepted = await h.coordinator.sync() + expect(h.requests.map(h.paths)).toEqual([[RECORD]]) + expect(accepted.pushed).toBe(1) + expect(accepted.refusedPaths).toEqual([]) + expect(h.states.current()!).not.toHaveProperty('refused_paths') + }) + + it('stops waiting once the file is gone from this device', async () => { + const h = harness([text(NOTE), text(RECORD)]) + await h.coordinator.sync() + h.local.items = [text(NOTE)] + + const result = await h.coordinator.sync() + + expect(result.refusedPaths).toEqual([]) + expect(h.states.current()!).not.toHaveProperty('refused_paths') + }) + + it('holds back a refused move without deleting the file in Cloud', async () => { + const tracked = { + item_id: 'note', path: NOTE, kind: 'text' as const, revision: 1, + sha256: 'hash:body', byte_length: 4, media_type: 'text/markdown' + } + const moved = '.zennotes/agent-edits/moved.json' + const h = harness([text(moved, 'body')], { items: { note: tracked } }) + + const first = await h.coordinator.sync() + const second = await h.coordinator.sync() + + expect(h.requests.map(h.paths)).toEqual([[moved]]) + expect(h.requests[0].mutations[0].type).toBe('move') + expect(first.refusedPaths.map((row) => row.path)).toEqual([moved]) + expect(second.refusedPaths.map((row) => row.path)).toEqual([moved]) + expect(h.states.current()!.items.note.path).toBe(NOTE) + }) + + it('commits what a split batch already applied and resends only what went unanswered', async () => { + const h = harness([text('a.md'), text('b.md'), text('c.md')]) + // A host that sends the batch as several requests: a.md went through, + // then b.md's own upload was refused, and c.md was never sent. + let first = true + h.server.mutate = async (_vaultId, body) => { + h.requests.push(structuredClone(body)) + if (!first) return h.answer(body) + first = false + throw new CloudSyncPathRefusedError({ + indices: [1], + applied: h.answer({ mutations: [body.mutations[0]] }), + detail: `HTTP 422 · VALIDATION_FAILED · ${REFUSED}` + }) + } + + const result = await h.coordinator.sync() + + expect(h.requests.map(h.paths)).toEqual([['a.md', 'b.md', 'c.md'], ['c.md']]) + expect(result.pushed).toBe(2) + expect(Object.values(h.states.current()!.items).map((row) => row.path).sort()).toEqual(['a.md', 'c.md']) + expect(result.refusedPaths.map((row) => row.path)).toEqual(['b.md']) + }) + + it('still stops the run for any other validation failure', async () => { + const h = harness([text(NOTE)]) + h.server.mutate = async () => { + throw validationError({ mutations: ['The sync batch is too large. Send fewer notes in this request.'] }) + } + + await expect(h.coordinator.sync()).rejects.toMatchObject({ status: 422 }) + expect(h.states.current()!).not.toHaveProperty('refused_paths') + }) +}) + +describe('cloudSyncPathRefusal', () => { + const batch: CloudSyncMutationRequest = { + mutations: [ + { type: 'upsert', operation_id: 'o1', item_id: 'i1', base_revision: null, path: NOTE, kind: 'text', content: content('a') }, + { type: 'delete', operation_id: 'o2', item_id: 'i2', base_revision: 1 } + ] + } + + it('reads the refused positions from the field names', () => { + expect(cloudSyncPathRefusal(validationError({ 'mutations.0.path': [REFUSED] }), batch)?.indices).toEqual([0]) + }) + + it('is null for a field that is not an upsert or move path, or a host that kept no fields', () => { + expect(cloudSyncPathRefusal(validationError({ 'mutations.1.path': [REFUSED] }), batch)).toBeNull() + expect(cloudSyncPathRefusal(validationError({ 'mutations.0.content': ['Content is required.'] }), batch)).toBeNull() + expect(cloudSyncPathRefusal(validationError({ 'mutations.5.path': [REFUSED] }), batch)).toBeNull() + expect(cloudSyncPathRefusal(Object.assign(new Error(REFUSED), { status: 422, code: 'VALIDATION_FAILED' }), batch)).toBeNull() + expect(cloudSyncPathRefusal(new Error('fetch failed'), batch)).toBeNull() + }) + + it('recognizes a direct upload refused for its own path', () => { + expect(isCloudSyncUploadPathRefused(validationError({ path: [REFUSED] }))).toBe(true) + expect(isCloudSyncUploadPathRefused(validationError({ 'content.sha256': ['Bad hash.'] }))).toBe(false) + }) +}) diff --git a/packages/shared-domain/src/cloud-sync-refusal.ts b/packages/shared-domain/src/cloud-sync-refusal.ts new file mode 100644 index 000000000..68060c519 --- /dev/null +++ b/packages/shared-domain/src/cloud-sync-refusal.ts @@ -0,0 +1,112 @@ +/** + * A file Cloud will not take, recognized so sync can move past it (#1004). + * + * Cloud and the apps keep separate lists of which vault paths may sync + * (VaultPathService::shouldSync on the server, shouldSyncVaultPath here). When + * the lists drifted, Cloud refused the whole batch with a 422 naming the path, + * the run failed before it saved anything, and every later run planned the + * same upload and failed the same way. Recognizing the refusal lets the + * coordinator set that path aside and send everything else. + */ +import type { + CloudSyncMutationRequest, + CloudSyncMutationResponse +} from '@zennotes/bridge-contract/cloud-sync' +import { describeCloudError } from './cloud-error-copy' + +/** How long a refused path waits before sync offers it to Cloud again. Cloud + * may learn to take it (the #1004 fix was on the server), so it is retried, + * but not on every run. */ +export const CLOUD_SYNC_REFUSED_PATH_RETRY_MS = 24 * 60 * 60 * 1000 + +export interface CloudSyncPathRefusal { + /** Positions in the batch that was sent, each refused for its path. */ + indices: number[] + /** What Cloud already applied from this batch: a host that sends one batch + * as several requests (inline posts and direct uploads) may have finished + * some before the refusal. Empty when Cloud refused the request whole. */ + applied: CloudSyncMutationResponse + /** Cloud's answer, as Details shows it. */ + detail: string +} + +/** + * Thrown from a host's mutate() when it sent the batch as several requests + * and one of them was refused for a path. Indices are into the batch the + * host was given, and `applied` carries what the earlier requests did, so + * the coordinator commits those and resends only what was never answered. + */ +export class CloudSyncPathRefusedError extends Error { + constructor( + readonly refusal: CloudSyncPathRefusal, + cause?: unknown + ) { + super(`ZenNotes Cloud refused ${refusal.indices.length} sync path(s): ${refusal.detail}`, { cause }) + this.name = 'CloudSyncPathRefusedError' + } +} + +const EMPTY_RESPONSE: CloudSyncMutationResponse = { acknowledged: [], conflicts: [], cursor: 0 } +const MUTATION_PATH_FIELD = /^mutations\.(\d+)\.path$/ + +/** + * The field errors of a 422 VALIDATION_FAILED, which a host's transport keeps + * from the answer's `errors` object under `details.errors`. Null for any + * other failure, or a host that does not keep them. + */ +export function cloudValidationFieldErrors(error: unknown): Record | null { + const value = error as { status?: unknown; code?: unknown; details?: unknown } | null + if (!value || typeof value !== 'object' || value.status !== 422 || value.code !== 'VALIDATION_FAILED') { + return null + } + const details = value.details as { errors?: unknown } | null | undefined + const errors = details && typeof details === 'object' ? details.errors : undefined + if (!errors || typeof errors !== 'object' || Array.isArray(errors)) return null + const fields: Record = {} + for (const [field, messages] of Object.entries(errors)) { + fields[field] = Array.isArray(messages) ? messages.filter((m): m is string => typeof m === 'string') : [] + } + return Object.keys(fields).length > 0 ? fields : null +} + +/** + * The paths Cloud refused in `batch`, or null when what mutate() threw is + * anything else. Only a refusal that names nothing but upsert and move paths + * counts: any other field error means the request itself was wrong, and + * stopping is right. + */ +export function cloudSyncPathRefusal( + error: unknown, + batch: CloudSyncMutationRequest +): CloudSyncPathRefusal | null { + if (error instanceof CloudSyncPathRefusedError) { + const { indices } = error.refusal + return indices.length > 0 && indices.every((index) => refusablePath(batch, index)) ? error.refusal : null + } + const fields = cloudValidationFieldErrors(error) + if (!fields) return null + const indices = new Set() + for (const field of Object.keys(fields)) { + const match = MUTATION_PATH_FIELD.exec(field) + if (!match) return null + const index = Number(match[1]) + if (!refusablePath(batch, index)) return null + indices.add(index) + } + return { + indices: [...indices].sort((a, b) => a - b), + applied: EMPTY_RESPONSE, + detail: describeCloudError(error).detail ?? '' + } +} + +/** Whether a direct upload's own request was refused for its `path`. */ +export function isCloudSyncUploadPathRefused(error: unknown): boolean { + const fields = cloudValidationFieldErrors(error) + return fields !== null && Object.keys(fields).every((field) => field === 'path') +} + +function refusablePath(batch: CloudSyncMutationRequest, index: number): boolean { + const mutation = batch.mutations[index] + return mutation !== undefined && (mutation.type === 'upsert' || mutation.type === 'move') +} diff --git a/packages/shared-domain/src/cloud-sync.test.ts b/packages/shared-domain/src/cloud-sync.test.ts index e1ec7b77f..eff8ec11b 100644 --- a/packages/shared-domain/src/cloud-sync.test.ts +++ b/packages/shared-domain/src/cloud-sync.test.ts @@ -110,6 +110,27 @@ describe('shouldSyncVaultPath', () => { expect(shouldSyncVaultPath(path)).toBe(false) }) + // A SYNCED COPY of Cloud's list. The website repo's + // tests/Feature/Api/SyncApiTest.php ("the server accepts every internal path + // the desktop client uploads") holds the same dataset for + // VaultPathService::shouldSync. A path this client uploads and Cloud + // refuses once failed every sync of that vault (#1004), so a row added or + // changed here is added or changed there, in the same words, and the other + // way round. Since 2.80 sync sets a refused path aside instead of stopping, + // but the file still never reaches Cloud. + it.each([ + ['vault settings', '.zennotes/vault.json', true], + ['comments', '.zennotes/comments/inbox/Note.md.comments.json', true], + ['assistant edit record', '.zennotes/agent-edits/inbox/Note.md.json', true], + ['creation metadata', '.zennotes/note-metadata/inbox/Note.md.metadata.json', true], + ['templates', '.zennotes/templates/Meeting.md', true], + ['workflows', '.zennotes/workflows/Daily.workflow', true], + ['an assistant edit that is not json', '.zennotes/agent-edits/inbox/Note.md.tmp', false], + ['sync state', '.zennotes/sync/cursor.json', false] + ] as const)('agrees with Cloud about %s', (_name, path, synced) => { + expect(shouldSyncVaultPath(path)).toBe(synced) + }) + it('prunes repository metadata and dependency directories during traversal', () => { expect(shouldTraverseCloudSyncDirectory('notes')).toBe(true) expect(shouldTraverseCloudSyncDirectory('.zennotes/templates')).toBe(true) From 3c8023f315504d6f0efde5e8b7aa43112ae869c4 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 15:29:43 -0500 Subject: [PATCH 04/53] Feat(database): Delete or Backspace deletes the selected rows, after asking 2.79.0 took every letter away from the database grid with Vim mode off and deliberately left Delete and Backspace untaken: deleting a row asked first only when the row had a linked page, and a key next to text editing should not delete a plain row without a confirm the grid did not have. So with Vim off, deleting rows from the keyboard meant the row menu or the selection bar. deleteDatabaseRows takes { confirm: true }, which asks before any row goes, plain ones included ("Delete this row?" / "Delete 3 rows?", This cannot be undone), with a real Cancel. Rows with a linked page then get the existing question about their notes. Without the option nothing changes: dd, the row menu's Delete row and the selection bar's Delete behave as before. Delete and Backspace join the grid's universal keys, in both modes, and always pass the confirm: they act on the selected rows, else the row at the cursor, and a cancelled confirm keeps the selection. The view is inert while rows are deleted, which drops the grid's focus to the body, so the key path hands focus back to the grid afterwards. With Vim on, dd is unchanged. On the built app over CDP with Vim off: Delete on the cursor row asked "Delete this row?" and Escape kept all four rows; Space, Down, Space and Backspace asked "Delete 2 rows?" and Enter left Gamma and Delta in data.csv, with the grid still answering Delete; dd did nothing. With Vim on, dd removed Alpha at once and Delete asked first. --- .../src/components/DatabaseTableView.tsx | 19 +++++++++ .../app-core/src/database-row-actions.test.ts | 41 +++++++++++++++++++ .../src/lib/database-grid-keys.test.ts | 16 +++----- .../app-core/src/lib/database-grid-keys.ts | 10 ++++- packages/app-core/src/lib/help.ts | 5 ++- packages/app-core/src/store.ts | 22 +++++++++- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index 9c0421afc..594aa0ed1 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -464,6 +464,25 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns setActive((s) => ({ row: rows.length, col: s.col })) return } + case 'delete': { + // The selected rows, else the one at the cursor, and only after the + // confirm: unlike dd, these keys work with Vim mode off and sit next + // to text editing. A cancelled confirm keeps the selection. + const c = cell() + const ids = selected.size > 0 ? [...selected] : c ? [c.row.id] : [] + if (ids.length === 0) return + e.preventDefault() + void deleteDatabaseRows(csvPath, ids, { confirm: true }).then(() => { + const live = new Set(useStore.getState().databases[csvPath]?.rows.map((row) => row.id)) + setSelected((prev) => new Set([...prev].filter((id) => live.has(id)))) + // The view is inert while rows are deleted, which drops the grid's + // focus to the body; the keys that deleted them keep working after. + requestAnimationFrame(() => { + if (document.activeElement === document.body) gridRef.current?.focus({ preventScroll: true }) + }) + }) + return + } case 'escape': e.preventDefault() if (editingPicks) { diff --git a/packages/app-core/src/database-row-actions.test.ts b/packages/app-core/src/database-row-actions.test.ts index 075ce199b..3b35cc0ad 100644 --- a/packages/app-core/src/database-row-actions.test.ts +++ b/packages/app-core/src/database-row-actions.test.ts @@ -151,6 +151,47 @@ describe('database row lifecycle', () => { expect(s.files.has(TWO)).toBe(false) expect(flushed).toBe(true) }) + it('asks before a plain row goes when the caller asks for a confirm, and a Cancel keeps it', async () => { + const s = await setup() + const plain = { ...s.doc, pages: {}, pageHasContent: {} } + s.useStore.setState({ databases: { [CSV]: plain } }) + const cancelled = s.useStore.getState().deleteDatabaseRows(CSV, ['one'], { confirm: true }) + const request = s.prompts.getConfirmRequest()! + expect(request.options).toMatchObject({ title: 'Delete this row?', description: 'This cannot be undone.', confirmLabel: 'Delete row', danger: true }) + s.prompts.settleConfirmRequest(request, false) + await cancelled + expect(s.useStore.getState().databases[CSV].rows.map(row => row.id)).toEqual(['one', 'two']) + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + + const confirmed = s.useStore.getState().deleteDatabaseRows(CSV, ['one', 'two'], { confirm: true }) + expect(s.prompts.getConfirmRequest()!.options.title).toBe('Delete 2 rows?') + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await confirmed + expect(s.disk().rows).toEqual([]) + // Without the option a plain row still goes at once (dd, the menus). + s.useStore.setState({ databases: { [CSV]: plain } }) + const direct = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + expect(s.prompts.getConfirmRequest()).toBeNull() + await direct + expect(s.disk().rows.map(row => row.id)).toEqual(['two']) + }) + it('asks about the rows first, then what happens to their notes', async () => { + const s = await setup() + const declined = s.useStore.getState().deleteDatabaseRows(CSV, ['one', 'two'], { confirm: true }) + expect(s.prompts.getConfirmRequest()!.options.description).toBe('This cannot be undone. You choose what happens to the linked page notes next.') + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, false) + await declined + expect(s.prompts.getConfirmRequest()).toBeNull() + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one', 'two'], { confirm: true }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await vi.waitFor(() => expect(s.prompts.getConfirmRequest()?.options.title).toBe('Delete 2 rows and their notes?')) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, false) + await pending + expect(s.disk().rows).toEqual([]) + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) it('does not replace the first row confirmation when deletion is dispatched twice', async () => { const s = await setup() const first = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) diff --git a/packages/app-core/src/lib/database-grid-keys.test.ts b/packages/app-core/src/lib/database-grid-keys.test.ts index 39e820529..f4ff2bb9c 100644 --- a/packages/app-core/src/lib/database-grid-keys.test.ts +++ b/packages/app-core/src/lib/database-grid-keys.test.ts @@ -24,7 +24,8 @@ const VIM_KEYS: Array<[string, string]> = [ ['a', 'add-row'] ] -// What stays with Vim mode off: the arrows, Enter, Space, Escape, the menu keys. +// What stays with Vim mode off: the arrows, Enter, Space, Escape, the menu +// keys, and Delete / Backspace (whose deletion always asks first). const UNIVERSAL_KEYS: Array<[{ key: string; shiftKey?: boolean }, string]> = [ [{ key: 'ArrowDown' }, 'down'], [{ key: 'ArrowUp' }, 'up'], @@ -34,7 +35,9 @@ const UNIVERSAL_KEYS: Array<[{ key: string; shiftKey?: boolean }, string]> = [ [{ key: ' ' }, 'space'], [{ key: 'Escape' }, 'escape'], [{ key: 'ContextMenu' }, 'menu'], - [{ key: 'F10', shiftKey: true }, 'menu'] + [{ key: 'F10', shiftKey: true }, 'menu'], + [{ key: 'Delete' }, 'delete'], + [{ key: 'Backspace' }, 'delete'] ] describe('databaseGridKeyAction: the grid keys and Vim mode', () => { @@ -54,7 +57,7 @@ describe('databaseGridKeyAction: the grid keys and Vim mode', () => { } }) - it('keeps the arrows, Enter, Space, Escape and the menu keys in both modes', () => { + it('keeps the arrows, Enter, Space, Escape, the menu keys and Delete in both modes', () => { for (const vim of [true, false]) { for (const [event, action] of UNIVERSAL_KEYS) { expect(databaseGridKeyAction(event, vim), `${event.key} vim=${vim}`).toBe(action) @@ -71,13 +74,6 @@ describe('databaseGridKeyAction: the grid keys and Vim mode', () => { } }) - it('does not take Delete or Backspace (row deletion has no confirm of its own)', () => { - for (const vim of [true, false]) { - expect(databaseGridKeyAction({ key: 'Delete' }, vim)).toBeNull() - expect(databaseGridKeyAction({ key: 'Backspace' }, vim)).toBeNull() - } - }) - it('never resolves a key named like an object property', () => { expect(databaseGridKeyAction({ key: 'constructor' }, true)).toBeNull() expect(databaseGridKeyAction({ key: 'toString' }, false)).toBeNull() diff --git a/packages/app-core/src/lib/database-grid-keys.ts b/packages/app-core/src/lib/database-grid-keys.ts index c2f085210..073e09116 100644 --- a/packages/app-core/src/lib/database-grid-keys.ts +++ b/packages/app-core/src/lib/database-grid-keys.ts @@ -9,7 +9,10 @@ * the field on the header, Space selects the row (or toggles a checkbox * cell), Escape clears and leaves, and Shift+F10 or the Context Menu key * opens the row's menu (the column's on the header), as in the sidebar and - * the note list. A chord (Ctrl, Alt or Cmd held) is never the grid's. + * the note list. Delete and Backspace delete the selected rows, or the row + * at the cursor, in either mode, and always ask first: a row has no undo, + * and the keys sit beside text editing. A chord (Ctrl, Alt or Cmd held) is + * never the grid's. * * One function, so the gating can be tested apart from the component. */ @@ -33,6 +36,7 @@ export type DatabaseGridKeyAction = | 'columns' | 'menu' | 'add-row' + | 'delete' | 'escape' export interface GridKey { @@ -52,7 +56,9 @@ const UNIVERSAL = new Map(Object.entries({ Enter: 'edit', ' ': 'space', Escape: 'escape', - ContextMenu: 'menu' + ContextMenu: 'menu', + Delete: 'delete', + Backspace: 'delete' }) as Array<[string, DatabaseGridKeyAction]>) /** The grid's Vim keys: answered only with Vim mode on. */ diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index d9679be43..5d187a0d7 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -849,11 +849,12 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'database-grid', title: 'Database grid (Table view)', - description: 'Keys for a CSV database table while its grid has focus; the grid takes them before any global motion. The single-letter keys (h / j / k / l, H / L, 0 / ^ / $, g g / G, i, x, o, O, c, m, a, d d) and Ctrl-o / Ctrl-w belong to Vim mode, like every list in the app. With Vim mode off the arrows, Enter, Space, Esc and Shift+F10 (or the Context Menu key) stay live, and the rest is in the row and column menus, the header buttons and the command palette.', + description: 'Keys for a CSV database table while its grid has focus; the grid takes them before any global motion. The single-letter keys (h / j / k / l, H / L, 0 / ^ / $, g g / G, i, x, o, O, c, m, a, d d) and Ctrl-o / Ctrl-w belong to Vim mode, like every list in the app. With Vim mode off the arrows, Enter, Space, Esc, Delete / Backspace and Shift+F10 (or the Context Menu key) stay live, and the rest is in the row and column menus, the header buttons and the command palette.', items: [ { keys: '↑ / ↓ / ← / →', action: 'Move the cell cursor', detail: 'In either mode. ↑ from the first row reaches the column-header row, where Enter renames the field and Shift+F10 opens its column menu. In Vim mode h / j / k / l move too, k climbs onto the header the same way, and 0 / ^ jump to the first column, $ to the last.' }, { keys: 'Enter', action: 'Edit the cell', detail: 'In either mode; i does it too in Vim mode. On a checkbox cell this toggles it instead of opening an editor, and on the column header it renames the field.' }, { keys: 'Space', action: 'Select the row', detail: 'In either mode; x does it too in Vim mode. On a checkbox cell Space toggles the checkbox instead. Selected rows get the selection bar: Delete, or Show only these in this view, which makes the selection the view’s picked rows (also Show Only Selected Rows in the command palette). The view then says “Showing N picked rows” under the header, with Edit picks and Show all rows.' }, + { keys: 'Delete / Backspace', action: 'Delete the selected rows', detail: 'In either mode. Deletes the selected rows, or the row at the cursor when none is selected, after asking first, since a deleted row cannot be brought back; Cancel or Esc keeps them and the selection. When some of the rows have a linked page note, a second question asks whether to move those notes to Trash too or keep them as standalone notes.' }, { keys: 'Shift+F10', action: 'Open the row menu', detail: 'In either mode, or the Context Menu key; m does it too in Vim mode. On a cell it is the record’s right-click menu (Open page, Delete row, and the rest); with rows selected it offers Show only selected rows, and in a view showing picked rows, Remove from this view’s picks. On the column header it is the column menu, with Hide column and Choose columns….' }, { keys: 'Esc', action: 'Clear selection / leave the grid', detail: 'In either mode. Leaves Edit picks without changing them, else clears a multi-row selection, then blurs the grid.' }, { keys: '[[ (in a text cell)', action: 'Link a note from the cell', detail: 'While editing a text cell, `[[` opens the same note search as the editor; ↑/↓ or Ctrl+N/P move, Enter inserts the `[[wikilink]]`, Escape closes the list and keeps typing.' }, @@ -864,7 +865,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'o', action: 'Open the record page', detail: 'Vim mode. Open the row as a Markdown note in the per-database folder. In either mode, Open page is in the row menu and on the row itself.' }, { keys: 'O', action: 'Create pages for all records', detail: 'Vim mode. Give every record that has no page its note in the per-database folder, so each one can be linked with [[wikilinks]]. Records that already have a page keep it, a page file already in the folder under a record’s name is linked instead of copied, and rows with an empty title are left out. Asks first. In either mode it is also in the database’s ⋯ menu and the command palette.' }, { keys: 'a', action: 'Add a row', detail: 'Vim mode. Append a new empty record and move the cursor to it; in either mode New row under the table and + Row in the header do the same. In a view showing picked rows the new row joins the picks, so it stays in view.' }, - { keys: 'd d', action: 'Delete the row', detail: 'Vim mode. Remove the record at the cursor. In either mode Delete row is in the row menu, and Delete in the selection bar removes the selected rows.' }, + { keys: 'd d', action: 'Delete the row', detail: 'Vim mode. Remove the record at the cursor at once; a row with a linked page asks about its note first. In either mode Delete / Backspace ask before deleting, Delete row is in the row menu, and Delete in the selection bar removes the selected rows.' }, { keys: 'Ctrl-o', action: 'Jump back to the grid', detail: 'Vim mode. From a record page, return to the database grid, whether you opened it as a .csv file or via New Database.' }, { keys: 'Ctrl-w k / j / h / l', action: 'Move to tabs or panes', detail: 'Vim mode. Move between the grid, the tab strip, and split panes, exactly like from the editor.' } ] diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 40b16643b..2c7c2ec44 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3646,7 +3646,10 @@ interface Store { * row write only touches the CSV, so a stale UUID would otherwise linger in * schema.json). When a deleted row has a linked page note, prompt whether to * trash the note too or keep it as a standalone note. (#391) */ - deleteDatabaseRows: (csvPath: string, rowIds: string[]) => Promise + /** `confirm` asks before any row goes, plain ones included; a row has no + * undo, so a key press passes it. Rows with a linked page are asked about + * their notes either way. */ + deleteDatabaseRows: (csvPath: string, rowIds: string[], options?: { confirm?: boolean }) => Promise /** Optimistically replace a database's schema/views and debounce-persist sidecar + CSV. */ updateDatabaseSchema: (csvPath: string, next: DatabaseDoc) => void /** Re-read a database from disk after an external change (skips our own write echoes). */ @@ -6863,7 +6866,7 @@ export const useStore = create((set, get) => { scheduleDatabaseWrite(csvPath, 'rows', () => get().databases[csvPath]) remirrorOpenRecordPages(csvPath, get) }, - deleteDatabaseRows: async (csvPath, rowIds) => { + deleteDatabaseRows: async (csvPath, rowIds, options) => { if (workspaceWritesBlocked()) return const doc = get().databases[csvPath] const vault = get().vault @@ -6875,6 +6878,21 @@ export const useStore = create((set, get) => { const mappings = new Map(ids.map((id) => [id, doc.pages?.[id]])) const attached = [...mappings.values()].filter((path): path is string => !!path) let trashNotes = false + if (options?.confirm) { + const one = ids.length === 1 + pendingRowConfirmation = true + try { + const confirmed = await confirmApp({ + title: one ? 'Delete this row?' : `Delete ${ids.length} rows?`, + description: attached.length > 0 + ? 'This cannot be undone. You choose what happens to the linked page notes next.' + : 'This cannot be undone.', + confirmLabel: one ? 'Delete row' : `Delete ${ids.length} rows`, + danger: true + }) + if (!confirmed) return + } finally { pendingRowConfirmation = false } + } if (attached.length > 0) { const many = attached.length > 1 pendingRowConfirmation = true From cf8cb797fe1a65ec7bd4229ccdb6213d6a20ec12 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 15:29:43 -0500 Subject: [PATCH 05/53] Fix(editor): with Vim mode off on macOS, Shift+F10 opens the editor's menu With Vim mode off, Shift+F10 and the Context Menu key opened nothing in the editor on a Mac. VimNav's editor branch, which opens the text menu over a selection, lives in the listener installed only with Vim mode on, and the Vim-off listener answers the focused panels, not the editor. Off macOS that gap never showed, because Chromium turns either key into a contextmenu event at the caret and the editor's own handler opens its menu as for a right-click. Blink does that everywhere but macOS, so there the key reached the editor and nothing came of it. The editor's key handler now opens its right-click menu for either key when Vim mode is off on macOS (editorOpensKeyboardContextMenu). Off macOS it leaves the key to Chromium's event as before, so a menu never opens twice, and with Vim mode on VimNav still decides: the text menu over a selection, the tab menu without one. Seen on the built app over CDP, before: with Vim off neither key opened a menu and no contextmenu event fired, with or without a selection. After: both keys open the editor's menu (Cut, Copy, Paste, Turn into callout at the caret; the highlight items over a selection), the menu takes the keyboard and Escape closes it. With Vim on the results match the old build in all four cases. --- .../app-core/src/components/EditorPane.tsx | 12 ++++++- packages/app-core/src/lib/help.ts | 2 +- .../src/lib/keyboard-context-menu.test.ts | 32 +++++++++++++++++++ .../app-core/src/lib/keyboard-context-menu.ts | 23 +++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 packages/app-core/src/lib/keyboard-context-menu.test.ts diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 0b2676895..8b411ae35 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -339,7 +339,10 @@ import { type PaneModesByPath } from '../lib/pane-mode' import { resolveCommentAnchor, selectionToCommentAnchor } from '../lib/comments' -import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT } from '../lib/keyboard-context-menu' +import { + ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, + editorOpensKeyboardContextMenu +} from '../lib/keyboard-context-menu' import { calloutRefusalMessage, editorCalloutTarget, @@ -399,6 +402,7 @@ import { formatKeyToken, getKeymapBinding, getKeymapDisplay, + isMacPlatform, labelWithShortcut, type KeymapId, type KeymapOverrides @@ -2578,6 +2582,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }, keydown: (event, view) => { const state = useStore.getState() + if (editorOpensKeyboardContextMenu(event, { vimMode: state.vimMode, mac: isMacPlatform() })) { + event.preventDefault() + event.stopPropagation() + openEditorContextMenu() + return true + } if ( event.key === 'm' && !event.metaKey && diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 5d187a0d7..1eff0aadb 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -307,7 +307,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Context menus are part of the keyboard model', body: - 'ZenNotes treats context menus as keyboard-reachable UI, not mouse-only escape hatches. Use the configured context-menu binding on the selected sidebar or note-list row. In the editor, select text and press `m` to open the text menu with commenting first; `Shift+F10` / the system Context Menu key still opens the active tab menu when no text is selected. The command palette also exposes the same high-value tab actions directly.' + 'ZenNotes treats context menus as keyboard-reachable UI, not mouse-only escape hatches. Use the configured context-menu binding on the selected sidebar or note-list row. In the editor with Vim mode on, select text and press `m` (or `Shift+F10`) to open the text menu with commenting first; `Shift+F10` / the system Context Menu key still opens the active tab menu when no text is selected. With Vim mode off, `Shift+F10` or the Context Menu key opens the editor\'s right-click menu at the cursor, with or without a selection, on macOS as on Windows and Linux. The command palette also exposes the same high-value tab actions directly.' }, { title: 'Comments attach to selected text', diff --git a/packages/app-core/src/lib/keyboard-context-menu.test.ts b/packages/app-core/src/lib/keyboard-context-menu.test.ts new file mode 100644 index 000000000..5289f90a7 --- /dev/null +++ b/packages/app-core/src/lib/keyboard-context-menu.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { editorOpensKeyboardContextMenu, isKeyboardContextMenuKey } from './keyboard-context-menu' + +const key = (key: string, mods: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean } = {}) => ({ + key, + shiftKey: false, + metaKey: false, + ctrlKey: false, + altKey: false, + ...mods +}) + +describe('the keyboard right-click in the editor', () => { + it('is Shift+F10 or the Context Menu key, and no chord', () => { + expect(isKeyboardContextMenuKey(key('F10', { shiftKey: true }))).toBe(true) + expect(isKeyboardContextMenuKey(key('ContextMenu'))).toBe(true) + expect(isKeyboardContextMenuKey(key('F10'))).toBe(false) + expect(isKeyboardContextMenuKey(key('F10', { shiftKey: true, metaKey: true }))).toBe(false) + expect(isKeyboardContextMenuKey(key('ContextMenu', { ctrlKey: true }))).toBe(false) + expect(isKeyboardContextMenuKey(key('m'))).toBe(false) + }) + + it('opens the editor menu itself only with Vim mode off on macOS, which sends no contextmenu event', () => { + const shiftF10 = key('F10', { shiftKey: true }) + expect(editorOpensKeyboardContextMenu(shiftF10, { vimMode: false, mac: true })).toBe(true) + expect(editorOpensKeyboardContextMenu(key('ContextMenu'), { vimMode: false, mac: true })).toBe(true) + // Elsewhere Chromium's own contextmenu event reaches the editor's handler. + expect(editorOpensKeyboardContextMenu(shiftF10, { vimMode: false, mac: false })).toBe(false) + // With Vim mode on, VimNav keeps deciding. + expect(editorOpensKeyboardContextMenu(shiftF10, { vimMode: true, mac: true })).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/keyboard-context-menu.ts b/packages/app-core/src/lib/keyboard-context-menu.ts index d54d85f4c..e49d084cb 100644 --- a/packages/app-core/src/lib/keyboard-context-menu.ts +++ b/packages/app-core/src/lib/keyboard-context-menu.ts @@ -5,6 +5,29 @@ function cssEscape(value: string): string { export const ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT = 'zen:open-editor-context-menu' +type MenuKeyEvent = Pick + +/** Shift+F10 or the Context Menu key: the keyboard's right-click. */ +export function isKeyboardContextMenuKey(e: MenuKeyEvent): boolean { + if (e.metaKey || e.ctrlKey || e.altKey) return false + return e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') +} + +/** + * Whether the editor opens its own right-click menu for this key. Off macOS + * Chromium turns the key into a contextmenu event at the caret, which the + * editor's contextmenu handler answers like a right-click; on macOS it sends + * none, so with Vim mode off the keys did nothing in the editor there. With + * Vim mode on VimNav decides (the editor's menu over a selection, the tab's + * otherwise), on every platform. + */ +export function editorOpensKeyboardContextMenu( + e: MenuKeyEvent, + host: { vimMode: boolean; mac: boolean } +): boolean { + return !host.vimMode && host.mac && isKeyboardContextMenuKey(e) +} + /** * Where a keyboard-opened menu anchors on an element: just inside its left * edge, vertically centred, clamped to the viewport so a row at the screen's From 95f04bd54f61fadd4a9f89bfb1c816565d49ce62 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 15:32:47 -0500 Subject: [PATCH 06/53] Fix(cli): a row zn base add makes joins the picks of the view it opens on A table view can show only picked rows (pickedRowIds) since 2.79.0, and + Row in the grid adds the new row to the picks of the view on screen, or it would vanish the moment it was made. zn base add knew nothing of picks: the row landed in data.csv but not in the view's list, so a database that opens on a picks view did not show it. The CLI has no view on screen, so it uses the one the app would show: the database's activeViewId in schema.json. When that view has picks, the row joins them and schema.json is written with the row; when it has none, nothing about the views changes. Other views with picks keep them: a curated view the user is not looking at should not collect every row added elsewhere, the grid behaves the same way, and a view of every row already shows it. The text output says "added to the picked rows of the view", and --json carries pickedInView. addRowInView moved from app-core's database-cells to shared-domain's database-records, where the other record operations already live for the CLI (#556); app-core re-exports it, so the grid is unchanged. --- apps/desktop/src/cli/commands/base.test.ts | 61 +++++++++++++++++++ apps/desktop/src/cli/commands/base.ts | 25 +++++--- apps/desktop/src/cli/help.ts | 2 +- packages/app-core/src/lib/database-cells.ts | 23 +------ packages/app-core/src/lib/help.ts | 2 +- .../shared-domain/src/database-records.ts | 25 ++++++++ 6 files changed, 106 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/cli/commands/base.test.ts b/apps/desktop/src/cli/commands/base.test.ts index a0481a4e9..224e45bd1 100644 --- a/apps/desktop/src/cli/commands/base.test.ts +++ b/apps/desktop/src/cli/commands/base.test.ts @@ -141,6 +141,67 @@ describe('zn base', () => { expect(page).toContain('Summary of the retro.') }) + it('adds the row to the picks of the view the database opens on, and nowhere else', async () => { + const dir = path.join(root, 'inbox', 'Reading.base') + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(path.join(dir, 'data.csv'), 'id,Title\nr1,Dune\nr2,Emma\n') + const views = (picks: Record) => + Object.entries(picks).map(([id, pickedRowIds]) => ({ + id, + name: id === 'shelf' ? 'Shelf' : id === 'all' ? 'All' : 'Later', + type: 'table', + filters: [], + sorts: [], + ...(pickedRowIds ? { pickedRowIds } : {}) + })) + const writeSchema = (activeViewId: string) => + fsp.writeFile( + path.join(dir, 'schema.json'), + JSON.stringify({ + version: 1, + idFieldId: 'f_id', + fields: [ + { id: 'f_id', name: 'id', type: 'text', hidden: true }, + { id: 'f_title', name: 'Title', type: 'text' } + ], + views: views({ shelf: ['r1'], all: undefined, later: ['r2'] }), + activeViewId + }) + ) + const readViews = async () => + (JSON.parse(await fsp.readFile(path.join(dir, 'schema.json'), 'utf8')) as { + views: Array<{ id: string; pickedRowIds?: string[] }> + }).views + + // The database opens on Shelf, which shows only Dune: the new row joins it. + await writeSchema('shelf') + await cmdBaseAdd(backend(), makeArgs(['Reading'], [['set', 'Title=Ulysses'], ['json', 'true']])) + const added = lastJson() + expect(added.pickedInView).toBe('Shelf') + const after = await readViews() + expect(after.find((v) => v.id === 'shelf')?.pickedRowIds).toEqual(['r1', added.id]) + // A view with picks the user is not looking at keeps them. + expect(after.find((v) => v.id === 'later')?.pickedRowIds).toEqual(['r2']) + expect(after.find((v) => v.id === 'all')).not.toHaveProperty('pickedRowIds') + + // Opening on a view of every row, the add changes no view's picks. + await writeSchema('all') + out = [] + await cmdBaseAdd(backend(), makeArgs(['Reading'], [['set', 'Title=Middlemarch']])) + expect(out.join('')).not.toContain('picked rows') + const plain = await readViews() + expect(plain.find((v) => v.id === 'shelf')?.pickedRowIds).toEqual(['r1']) + expect(plain.find((v) => v.id === 'later')?.pickedRowIds).toEqual(['r2']) + const csv = await fsp.readFile(path.join(dir, 'data.csv'), 'utf8') + expect(csv).toContain('Middlemarch') + + // The text output says where the row went. + await writeSchema('later') + out = [] + await cmdBaseAdd(backend(), makeArgs(['Reading'], [['set', 'Title=Persuasion']])) + expect(out.join('')).toContain('added to the picked rows of the Later view') + }) + it('creates a database and rejects unknown fields helpfully', async () => { await cmdBaseCreate(backend(), makeArgs(['Logbook'], [['json', 'true']])) const created = lastJson() diff --git a/apps/desktop/src/cli/commands/base.ts b/apps/desktop/src/cli/commands/base.ts index caa47609f..0a0c85010 100644 --- a/apps/desktop/src/cli/commands/base.ts +++ b/apps/desktop/src/cli/commands/base.ts @@ -12,7 +12,7 @@ */ import { - addRow, + addRowInView, composePageBody, ensureSelectOption, recordTitle, @@ -239,12 +239,16 @@ export async function cmdBaseAdd(vault: VaultBackend, args: ParsedArgs): Promise const body = bodyFlag === '-' ? (await readStdin()).trim() || null : bodyFlag ?? null const wantPage = body != null || getBool(args, 'page') - let doc = await ops.openDatabase(csvPath) - doc = addRow(doc) - const row = doc.rows[doc.rows.length - 1] - const applied = applyAssignments(doc, row.id, assignments) - doc = applied.doc - let schemaChanged = applied.schemaChanged + // As + Row does in the grid: when the view the database opens on shows + // picked rows, the new row joins them, or it would be missing from that + // view the next time the database is opened. Other views keep their picks. + const opened = await ops.openDatabase(csvPath) + const added = addRowInView(opened, opened.activeViewId) + const pickedIn = added.picksChanged ? opened.views.find((v) => v.id === opened.activeViewId)?.name ?? null : null + const row = added.doc.rows.find((r) => r.id === added.rowId)! + const applied = applyAssignments(added.doc, row.id, assignments) + let doc = applied.doc + let schemaChanged = applied.schemaChanged || added.picksChanged let pagePath: string | null = null if (wantPage) { @@ -259,10 +263,15 @@ export async function cmdBaseAdd(vault: VaultBackend, args: ParsedArgs): Promise const finalRow = doc.rows.find((r) => r.id === row.id)! if (getBool(args, 'json')) { - emitJson({ ok: true, ...rowView({ ...doc, pages: pagePath ? { [row.id]: pagePath } : doc.pages }, finalRow) }) + emitJson({ + ok: true, + ...rowView({ ...doc, pages: pagePath ? { [row.id]: pagePath } : doc.pages }, finalRow), + ...(pickedIn !== null ? { pickedInView: pickedIn } : {}) + }) return } emitOk(`Added ${recordTitle(doc, finalRow)} (${row.id})`) + if (pickedIn !== null) emitLine(` added to the picked rows of the ${pickedIn} view`) if (pagePath) emitLine(` page: ${pagePath}`) } diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index a102c5a48..e2563db55 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -161,7 +161,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { name: 'base create ', description: 'Create an empty database', flags: '--folder <f/sub> --json' }, { name: 'base rows <base>', description: 'List a database’s rows (title or path picks the base)', flags: '--json' }, { name: 'base get <base> <row>', description: 'One row by id or title value', flags: '--json' }, - { name: 'base add <base>', description: 'Add a row; --body (or --body - for stdin) also creates its record page', flags: '--set Field=Value … --body <text|-> --page --json' }, + { name: 'base add <base>', description: 'Add a row; --body (or --body - for stdin) also creates its record page. When the view the database opens on shows picked rows, the row joins them', flags: '--set Field=Value … --body <text|-> --page --json' }, { name: 'base set <base> <row>', description: 'Set fields; select options mint, the page re-mirrors', flags: '--set Field=Value … --json' } ] }, diff --git a/packages/app-core/src/lib/database-cells.ts b/packages/app-core/src/lib/database-cells.ts index a07f30723..5b49ceb1e 100644 --- a/packages/app-core/src/lib/database-cells.ts +++ b/packages/app-core/src/lib/database-cells.ts @@ -26,6 +26,7 @@ export { splitMultiSelect, joinMultiSelect, splitNoteLinks, joinNoteLinks, isChe // Record-level pure ops moved to shared-domain so the zn CLI shares them (#556). export { addRow, + addRowInView, composePageBody, deleteRow, ensureSelectOption, @@ -36,7 +37,6 @@ export { setCell } from '@shared/database-records' export type { RecordPagePlan } from '@shared/database-records' -import { addRow as addRowDoc } from '@shared/database-records' const genId = defaultGenId @@ -400,24 +400,3 @@ export function removeFromViewPicks( const kept = new Set(view.pickedRowIds.filter((id) => !drop.has(id))) return setViewPicks(doc, viewId, kept) } - -/** - * Add an empty row while `viewId` is the view on screen. In a view with - * picks the new row joins them, or it would vanish the moment it was made; - * `picksChanged` then tells the caller the schema changed too (rows-only - * writes leave schema.json alone). - */ -export function addRowInView( - doc: DatabaseDoc, - viewId: string -): { doc: DatabaseDoc; rowId: string; picksChanged: boolean } { - const next = addRowDoc(doc) - const rowId = next.rows[next.rows.length - 1].id - const view = doc.views.find((v) => v.id === viewId) - if (!view || !view.pickedRowIds) return { doc: next, rowId, picksChanged: false } - return { - doc: updateView(next, viewId, { pickedRowIds: [...view.pickedRowIds, rowId] }), - rowId, - picksChanged: true - } -} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 1eff0aadb..6d32dde95 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -527,7 +527,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The CLI is the bridge to launchers', body: - 'The `zn` command-line tool can list, read, search, capture, edit, archive, trash, inspect tasks, work with databases (`zn base list / rows / get / add / set / create / pages`: add rows with record pages, give every record its page with `zn base pages`, set fields with the grid’s exact semantics, locally or against a self-hosted server; renaming a record page with `zn rename` keeps its row pointing at it), and start the MCP server without the app running. With no `--vault` or `--server` flag, `zn` works on the vault the app has open, a connected server included (it sends the token `zn connect` saved for that server, or `ZENNOTES_REMOTE_TOKEN` / `--token`). Raycast uses it for search, then uses `zennotes://open` and `zennotes://open-window` links to bring the selected note back into ZenNotes. On macOS, Settings → CLI can install the bundled Raycast extension locally so users do not need to wait for the Raycast Store version.' + 'The `zn` command-line tool can list, read, search, capture, edit, archive, trash, inspect tasks, work with databases (`zn base list / rows / get / add / set / create / pages`: add rows with record pages (a row added while the database opens on a view of picked rows joins that view\'s picks, as + Row does in the grid), give every record its page with `zn base pages`, set fields with the grid’s exact semantics, locally or against a self-hosted server; renaming a record page with `zn rename` keeps its row pointing at it), and start the MCP server without the app running. With no `--vault` or `--server` flag, `zn` works on the vault the app has open, a connected server included (it sends the token `zn connect` saved for that server, or `ZENNOTES_REMOTE_TOKEN` / `--token`). Raycast uses it for search, then uses `zennotes://open` and `zennotes://open-window` links to bring the selected note back into ZenNotes. On macOS, Settings → CLI can install the bundled Raycast extension locally so users do not need to wait for the Raycast Store version.' }, { title: 'Excalidraw drawings are first-class files', diff --git a/packages/shared-domain/src/database-records.ts b/packages/shared-domain/src/database-records.ts index 8db906a0c..b7f1744f3 100644 --- a/packages/shared-domain/src/database-records.ts +++ b/packages/shared-domain/src/database-records.ts @@ -159,6 +159,31 @@ export function addRow(doc: DatabaseDoc): DatabaseDoc { return { ...doc, rows: [...doc.rows, row] } } +/** + * Add an empty row while `viewId` is the view being looked at. In a view with + * picks the new row joins them, or it would vanish the moment it was made; + * `picksChanged` then tells the caller the schema changed too (rows-only + * writes leave schema.json alone). The grid passes the view on screen, and + * `zn base add` the database's active view. + */ +export function addRowInView( + doc: DatabaseDoc, + viewId: string +): { doc: DatabaseDoc; rowId: string; picksChanged: boolean } { + const next = addRow(doc) + const rowId = next.rows[next.rows.length - 1].id + const picked = doc.views.find((v) => v.id === viewId)?.pickedRowIds + if (!picked) return { doc: next, rowId, picksChanged: false } + return { + doc: { + ...next, + views: next.views.map((v) => (v.id === viewId ? { ...v, pickedRowIds: [...picked, rowId] } : v)) + }, + rowId, + picksChanged: true + } +} + export function deleteRow(doc: DatabaseDoc, rowId: string): DatabaseDoc { return { ...doc, rows: doc.rows.filter((r) => r.id !== rowId) } } From 1956a8a2d93767b113ec6112e0e0d6e7998bf332 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 15:40:41 -0500 Subject: [PATCH 07/53] Fix(focus): a phone keeps its keyboard down where core focused out of habit Core hands the editor the keyboard in a few places because a desktop wants it: the next key edits the note. On a host that opens notes to read (the phone layouts, the editor host's openNotesToRead) that focus is the soft keyboard over half the screen, and both phone shells guard each place with a focus refusal of their own (read-open.ts and focus-heal.ts). These go to core, so the shells can drop the guards: - A folder Gallery card opens its note with focusEditorAfterReadOpen, the hand-off of a search pick, instead of focusEditorNormalMode. - A pick in the Callout type menu, and Remove callout in the editor menu, leave the caret in the note without focusing it. - The database Columns menu, with Vim off, no longer focuses its find field as it opens; a tap into the field still does. - Closing the sidebar while it held the keyboard (#353) hands off with focusEditorAfterReadOpen. - The focus heal (#350) notes, as the app is left, whether the editor held the focus, and on such a host refocuses it on the way back only for someone who was typing (lib/focus-heal.ts); a fresh page counts as reading. This is the shells' FocusHealGate rule, moved here. Settings search results also carry data-settings-result-tab, the id of the sub-tab a setting lives on, next to data-settings-result-category, and each sub-tab's button carries the same id as data-settings-tab, so a shell that hides a sub-tab can hide the results that would open it instead of matching them by title. Desktop behavior does not change: every path above is the old call when openNotesToRead is absent. The same CDP script on the built app, run on this tree and on one without these changes, gives the same answers: a Gallery card opens its note with the editor focused, a callout pick and Remove callout keep the editor focused, Cmd+1 from the sidebar focuses the editor, a window focus after the editor lost the focus heals it, and the Columns menu focuses its find field. --- packages/app-core/README.md | 16 ++++ packages/app-core/src/App.tsx | 24 ++++- .../components/DatabaseColumnsMenu.test.ts | 89 +++++++++++++++++++ .../src/components/DatabaseColumnsMenu.tsx | 7 +- .../app-core/src/components/EditorPane.tsx | 6 +- .../app-core/src/components/GalleryView.tsx | 6 +- .../src/components/SettingsModal.test.ts | 18 ++++ .../app-core/src/components/SettingsModal.tsx | 18 ++-- packages/app-core/src/lib/focus-heal.test.ts | 32 +++++++ packages/app-core/src/lib/focus-heal.ts | 43 +++++++++ packages/app-core/src/lib/settings-search.ts | 14 +++ 11 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 packages/app-core/src/components/DatabaseColumnsMenu.test.ts create mode 100644 packages/app-core/src/lib/focus-heal.test.ts create mode 100644 packages/app-core/src/lib/focus-heal.ts diff --git a/packages/app-core/README.md b/packages/app-core/README.md index fdedddbdc..a3b4646dc 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -355,6 +355,22 @@ command (a toggle, a view, a theme), and closing any of those palettes or the template or drawing palette without a pick, leaves the focus where it was. Absent, false or throwing, the editor takes the focus as on desktop. +The same answer keeps the keyboard down in the places core hands the editor +the focus as a desktop habit rather than for typing: a card picked in a folder +Gallery opens its note to be read, a pick in the Callout type menu and Remove +callout leave the caret in the note without focusing it, the database Columns +menu opens without focusing its find field (a tap into the field still does), +and closing the sidebar while it held the keyboard does not focus the editor. +The focus heal, which refocuses the editor when the app comes back to the +front, runs on such a host only when the editor held the focus as the app was +left: someone typing gets the keyboard back, someone reading does not, and a +fresh page counts as reading. + +Settings search results carry `data-settings-result-category` (the section) +and, for a setting on a sub-tab, `data-settings-result-tab` (the sub-tab's id, +which the sub-tab's own button carries as `data-settings-tab`), so a host that +hides a section or a sub-tab can hide the results that would open it. + Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) only on lines the user is editing: once an editor has taken the focus since its note opened. A note opened to be read renders every line, the caret's too, with diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index c808cbe4a..e26d1dfc1 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -49,6 +49,8 @@ import { focusEditorAfterReadOpen, focusEditorNormalMode } from './lib/editor-focus' +import { hostOpensNotesToRead } from './lib/editor-host' +import { createFocusHealGate } from './lib/focus-heal' import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' import { @@ -462,8 +464,10 @@ function App(): JSX.Element { useEffect(() => { const wasOpen = prevSidebarOpenRef.current prevSidebarOpenRef.current = sidebarOpen + // A host that opens notes to read (a phone) keeps its keyboard down: the + // sidebar closing is no sign the user is about to type. if (wasOpen && !sidebarOpen && useStore.getState().focusedPanel === 'sidebar') { - focusEditorNormalMode({ attempts: 10, delayMs: 24 }) + focusEditorAfterReadOpen({ attempts: 10, delayMs: 24 }) } }, [sidebarOpen]) @@ -1164,9 +1168,20 @@ function App(): JSX.Element { // is re-established. When the window regains focus (or becomes visible again) // and the editor was the active surface with no overlay open, re-grab it so // the user doesn't have to open Settings + Escape to recover. Pairs with the - // main-process `backgroundThrottling: false`. (#350) + // main-process `backgroundThrottling: false`. (#350) A host that opens notes + // to read heals only for someone who was typing as the app was left + // (lib/focus-heal.ts), or every return raised its keyboard over a note + // being read. useEffect(() => { + const gate = createFocusHealGate() + const editorFocused = (): boolean => { + const view = useStore.getState().editorViewRef + const active = document.activeElement + return !!view && !!active && (active === view.dom || view.dom.contains(active)) + } + const leave = (): void => gate.left(editorFocused()) const heal = (): void => { + if (!gate.returned(hostOpensNotesToRead())) return const state = useStore.getState() if (state.focusedPanel !== 'editor') return if ( @@ -1192,11 +1207,16 @@ function App(): JSX.Element { } const onVisibility = (): void => { if (document.visibilityState === 'visible') heal() + else if (document.visibilityState === 'hidden') leave() } window.addEventListener('focus', heal) + window.addEventListener('blur', leave) + window.addEventListener('pagehide', leave) document.addEventListener('visibilitychange', onVisibility) return () => { window.removeEventListener('focus', heal) + window.removeEventListener('blur', leave) + window.removeEventListener('pagehide', leave) document.removeEventListener('visibilitychange', onVisibility) } }, []) diff --git a/packages/app-core/src/components/DatabaseColumnsMenu.test.ts b/packages/app-core/src/components/DatabaseColumnsMenu.test.ts new file mode 100644 index 000000000..ca67e67ad --- /dev/null +++ b/packages/app-core/src/components/DatabaseColumnsMenu.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DatabaseDoc } from '@shared/databases' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const opensToRead = vi.hoisted(() => ({ value: false })) +vi.mock('../lib/editor-host', async (load) => ({ + ...(await load<typeof import('../lib/editor-host')>()), + hostOpensNotesToRead: () => opensToRead.value +})) + +import { useStore } from '../store' +import { DatabaseColumnsMenu } from './DatabaseColumnsMenu' + +const doc: DatabaseDoc = { + version: 1, + path: 'Projects.base/data.csv', + title: 'Projects', + idFieldId: 'id', + activeViewId: 'table', + fields: [ + { id: 'id', name: 'id', type: 'text', hidden: true }, + { id: 'name', name: 'Name', type: 'text' }, + { id: 'status', name: 'Status', type: 'text' } + ], + views: [{ id: 'table', name: 'Table', type: 'table', filters: [], sorts: [] }], + rows: [] +} + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + Object.defineProperty(window, 'zen', { configurable: true, value: { getCapabilities: () => ({}) } }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + opensToRead.value = false +}) + +async function open(): Promise<void> { + await act(async () => + root.render( + createElement(DatabaseColumnsMenu, { + csvPath: doc.path, + doc, + view: doc.views[0], + anchor: new DOMRect(10, 10, 80, 20), + onClose: () => {} + }) + ) + ) +} + +const findField = (): HTMLInputElement | null => + document.querySelector<HTMLInputElement>('input[aria-label="Find a column"]') + +describe('the Columns menu as it opens', () => { + it('gives the find field the keyboard with Vim mode off', async () => { + useStore.setState({ vimMode: false }) + await open() + expect(findField()).toBeTruthy() + expect(document.activeElement).toBe(findField()) + }) + + it('leaves the find field alone on a host that opens notes to read (a phone keyboard)', async () => { + useStore.setState({ vimMode: false }) + opensToRead.value = true + await open() + expect(findField()).toBeTruthy() + expect(document.activeElement).not.toBe(findField()) + }) + + it('starts on the list with Vim mode on, on either host', async () => { + useStore.setState({ vimMode: true }) + opensToRead.value = true + await open() + expect(document.activeElement?.closest('[data-column-id]')).toBeTruthy() + }) +}) diff --git a/packages/app-core/src/components/DatabaseColumnsMenu.tsx b/packages/app-core/src/components/DatabaseColumnsMenu.tsx index e80a6e5e2..e2c176088 100644 --- a/packages/app-core/src/components/DatabaseColumnsMenu.tsx +++ b/packages/app-core/src/components/DatabaseColumnsMenu.tsx @@ -16,6 +16,7 @@ import { showAllColumns, viewColumnFields } from '../lib/database-cells' +import { hostOpensNotesToRead } from '../lib/editor-host' import { useStore } from '../store' import { Button } from './ui/Button' import { SearchIcon } from './icons' @@ -85,10 +86,12 @@ export function DatabaseColumnsMenu({ }, [onClose, ignoreRef]) // Vim mode starts on the list, where j / k work; otherwise the find field - // takes the typing, since letters are never shortcuts with Vim off. + // takes the typing, since letters are never shortcuts with Vim off. A host + // that opens notes to read (a phone) leaves the field alone: there its + // focus is the soft keyboard over the menu, and a tap still raises it. useEffect(() => { if (vimMode) focusItem(0) - else findRef.current?.focus() + else if (!hostOpensNotesToRead()) findRef.current?.focus() // Once, on open: a later Vim toggle should not yank focus around. // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 8b411ae35..2738de656 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -5117,7 +5117,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const { calloutRange, callout } = editorMenu if (entry.action === 'remove') { removeCalloutIn(view, calloutRange) - view.focus() + // A phone that opened the note to read keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() return } setCalloutMenu({ @@ -5152,7 +5153,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const view = viewRef.current if (!view) return setCalloutType(view, calloutMenu.range, choice.type) - view.focus() + // A phone that opened the note to read keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() } }) )} diff --git a/packages/app-core/src/components/GalleryView.tsx b/packages/app-core/src/components/GalleryView.tsx index 346b9fbf5..620c3a197 100644 --- a/packages/app-core/src/components/GalleryView.tsx +++ b/packages/app-core/src/components/GalleryView.tsx @@ -28,7 +28,7 @@ import { } from '../lib/folder-gallery' import { galleryCoverCache, runLimited } from '../lib/gallery-covers' import { resolveLocalAsset } from '../lib/local-assets' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterReadOpen } from '../lib/editor-focus' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { advanceSequence, getKeymapBinding, type KeymapId } from '../lib/keymaps' import { @@ -375,7 +375,9 @@ export function GalleryView({ tabPath, isActive }: { tabPath: string; isActive: filter }) await selectNote(note.path) - focusEditorNormalMode() + // A card opens its note to be read, like a search pick: desktop hands + // the editor the keyboard, a phone keeps its keyboard down. + focusEditorAfterReadOpen() }, [filter, items, selectNote, tabPath] ) diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 998909178..11fd51354 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -308,6 +308,24 @@ describe("SettingsModal date note directories", () => { expect(mocks.state.setShowStatusBar).toHaveBeenCalledWith(false); }); + it("names the sub-tab a search result opens, so a phone can hide the ones it hides", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "vault name")); + const results = [...host.querySelectorAll<HTMLButtonElement>("aside nav button[data-settings-result-category]")]; + const vaultName = results.find((button) => button.textContent?.includes("Vault name")); + expect(vaultName?.dataset.settingsResultCategory).toBe("vault"); + expect(vaultName?.dataset.settingsResultTab).toBe("location"); + // The sub-tab's own button carries the same id. + await act(async () => vaultName!.click()); + expect(host.querySelector('[role="tab"][data-settings-tab="location"]')).toBeTruthy(); + // A setting in a category without sub-tabs names none. + await act(async () => changeInput(search, "hyprland")); + const titleBar = host.querySelector<HTMLButtonElement>("aside nav button[data-settings-result-category]"); + expect(titleBar).toBeTruthy(); + expect(titleBar!.hasAttribute("data-settings-result-tab")).toBe(false); + }); + it("finds the new-file location rows by the words people search for", async () => { await act(async () => root.render(createElement(SettingsModal))); const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index d4c71a901..00f8f793a 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -145,6 +145,7 @@ import type { NoteTemplate } from "@bridge-contract/templates"; import { getSettingsSearchResults, inNavigationOrder, + settingsResultSubTab, type SettingsSearchCategory, } from "../lib/settings-search"; import { useAppUpdateState } from "../lib/app-update-state"; @@ -6183,9 +6184,7 @@ export function SettingsModal(): JSX.Element { if (result.type === "setting") { // If the target lives on a sub-tab, open that sub-tab first // so the element is mounted before we scroll to it. - const subTabId = result.category.subTabs?.find( - (tab) => tab.searchIds?.includes(result.targetId), - )?.id; + const subTabId = settingsResultSubTab(result); if (subTabId) { setActiveSubTabByCategory((prev) => ({ ...prev, @@ -6219,9 +6218,7 @@ export function SettingsModal(): JSX.Element { useEffect(() => { if (visibleSearchResult?.type !== "setting" || !visibleCategory?.subTabs) return; - const subTabId = visibleCategory.subTabs.find((tab) => - tab.searchIds?.includes(visibleSearchResult.targetId), - )?.id; + const subTabId = settingsResultSubTab(visibleSearchResult); if (!subTabId) return; const categoryId = visibleCategory.id; setActiveSubTabByCategory((prev) => @@ -6382,9 +6379,11 @@ export function SettingsModal(): JSX.Element { ref={selected ? selectedResultRef : undefined} type="button" // The phone shells hide whole sections (MCP, CLI, - // and Keymap on a phone); this lets them hide the - // search results that would open one. + // and Keymap on a phone) and some sub-tabs; these + // let them hide the search results that would open + // one. data-settings-result-category={result.category.id} + data-settings-result-tab={settingsResultSubTab(result) ?? undefined} onClick={() => openSearchResult(result)} className={[ "w-full rounded-xl px-3 py-2.5 text-left transition-colors", @@ -7132,6 +7131,9 @@ function CategorySubTabs({ key={tab.id} type="button" role="tab" + // The id a search result names (data-settings-result-tab), so a + // host that hides this sub-tab can hide its results too. + data-settings-tab={tab.id} aria-selected={selected} onClick={() => onSelect(tab.id)} className={[ diff --git a/packages/app-core/src/lib/focus-heal.test.ts b/packages/app-core/src/lib/focus-heal.test.ts new file mode 100644 index 000000000..94f4071c4 --- /dev/null +++ b/packages/app-core/src/lib/focus-heal.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { createFocusHealGate } from './focus-heal' + +describe('the focus heal gate', () => { + it('always heals on desktop, which does not open notes to read', () => { + const gate = createFocusHealGate() + expect(gate.returned(false)).toBe(true) + gate.left(false) + expect(gate.returned(false)).toBe(true) + }) + + it('on a host that opens notes to read, heals only for someone who was typing', () => { + const gate = createFocusHealGate() + // A fresh page left nothing: reading. + expect(gate.returned(true)).toBe(false) + // Reading a note, the app goes away (blur, then hidden) and comes back + // (visible, then focus): the keyboard stays down for both returns. + gate.left(false) + gate.left(false) + expect(gate.returned(true)).toBe(false) + expect(gate.returned(true)).toBe(false) + // Typing as the app was left: any of the leave events seeing the editor + // focused counts, and the heal brings the keyboard back. + gate.left(true) + gate.left(false) + expect(gate.returned(true)).toBe(true) + expect(gate.returned(true)).toBe(true) + // The next time away starts over. + gate.left(false) + expect(gate.returned(true)).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/focus-heal.ts b/packages/app-core/src/lib/focus-heal.ts new file mode 100644 index 000000000..28762ba54 --- /dev/null +++ b/packages/app-core/src/lib/focus-heal.ts @@ -0,0 +1,43 @@ +/** + * When the focus heal (App.tsx, #350) may re-grab the editor as the app + * comes back to the front. + * + * The heal answers the window regaining focus and the page becoming visible + * again: if the store says the editor was the last surface and the editor + * lacks the DOM focus, it focuses it, because Chromium can let the editor + * lose focus while idle and the keys then go nowhere. A host that opens + * notes to read (a phone) also leaves the store naming the editor after a + * note was opened to be read with the keyboard kept down, so the heal raised + * the soft keyboard over that note on every return to the app. + * + * So the app notes, as it is left (the window's blur, the page going hidden, + * pagehide), whether the editor held the focus. On the way back a host that + * opens notes to read heals only for someone who was typing; one who was + * reading keeps the keyboard down, and a fresh page, which left nothing, + * counts as reading. Desktop heals as before. + */ +export interface FocusHealGate { + /** One of the events that leave the app. Leaving fires several in a row, + * and any of them seeing the editor focused counts. */ + left(editorFocused: boolean): void + /** One of the events that come back: whether the heal may focus the editor. */ + returned(opensNotesToRead: boolean): boolean +} + +export function createFocusHealGate(): FocusHealGate { + let away = false + let typingWhenLeft = false + return { + left(editorFocused) { + if (!away) { + away = true + typingWhenLeft = false + } + typingWhenLeft = typingWhenLeft || editorFocused + }, + returned(opensNotesToRead) { + away = false + return !opensNotesToRead || typingWhenLeft + } + } +} diff --git a/packages/app-core/src/lib/settings-search.ts b/packages/app-core/src/lib/settings-search.ts index edbbc52b7..abc35ffba 100644 --- a/packages/app-core/src/lib/settings-search.ts +++ b/packages/app-core/src/lib/settings-search.ts @@ -85,6 +85,20 @@ function settingResult<TCategory extends SettingsSearchCategory>( } } +/** + * The sub-tab of a category that holds a setting result's control, or null + * for a category result, a category without sub-tabs, or a control on none + * of them. Search opens that sub-tab before it jumps to the control, and the + * result names it (`data-settings-result-tab`) so a host that hides a + * sub-tab can hide the results that would open it. + */ +export function settingsResultSubTab( + result: SettingsSearchResult<SettingsSearchCategory & { subTabs?: readonly { id: string; searchIds?: readonly string[] }[] }> +): string | null { + if (result.type !== 'setting') return null + return result.category.subTabs?.find((tab) => tab.searchIds?.includes(result.targetId))?.id ?? null +} + /** * Categories in the order the navigation shows them, which groups them by * section and so differs from the order they are defined in. The results From cdd3171b956b8409b8dbb52c38b70ba228618258 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 15:58:15 -0500 Subject: [PATCH 08/53] Test(cloud): Settings names a file Cloud refused and keeps Cloud's answer under Details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set-aside work for #1004 (e933d450) was covered in the coordinator, the transport and the attention helpers, but not where people read it. This renders Settings → Cloud, runs Sync now against a summary that carries refused_paths, and checks what shows: the "1 file stays on this device" heading, the calm sentence naming the file, one row with the vault path and "Sync offers it again once a day", Cloud's 422 line inside the row's Details, and no Open button for a file under .zennotes. It would fail without e933d450, which is what teaches the attention box the field. --- .../src/components/CloudSettings.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index 0b4218e2d..26516a084 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -938,6 +938,54 @@ describe("CloudSettings", () => { expect(host.textContent).not.toContain("conflicts need review"); }); + it("says calmly which file Cloud refused, with Cloud's answer under Details (#1004)", async () => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + const detail = "HTTP 422 · VALIDATION_FAILED · This path is not eligible for vault sync."; + mocks.syncCloudVault.mockResolvedValue({ + cursor: 7, + pulled: 0, + pushed: 12, + conflicts: [], + bootstrap_conflicts: [], + local_conflicts: [], + refused_paths: [{ path: ".zennotes/agent-edits/inbox/Plan.md.json", detail }], + }); + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + const sync = [...host.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Sync now", + ); + await act(async () => sync!.click()); + + expect(host.textContent).toContain("1 file stays on this device"); + expect(host.textContent).toContain( + "ZenNotes Cloud didn't accept “Plan.md.json”, so it stays on this device while the rest of the vault keeps syncing.", + ); + const list = host.querySelector('[aria-label="Files that need attention"]'); + const rows = [...list!.querySelectorAll("li")]; + expect(rows).toHaveLength(1); + expect(rows[0].textContent).toContain(".zennotes/agent-edits/inbox/Plan.md.json"); + expect(rows[0].textContent).toContain("Sync offers it again once a day."); + // Cloud's own answer sits under the row's Details, not in the sentence. + expect(rows[0].querySelector("details")?.textContent).toContain(detail); + // A file under .zennotes is not a note to open. + expect([...rows[0].querySelectorAll("button")].map((b) => b.textContent?.trim())).not.toContain("Open"); + }); + it("lists each file that needs attention with a reason and an Open action", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); From d177d847f1fa28203672e7b371ad6a3037921c82 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 16:09:42 -0500 Subject: [PATCH 09/53] Test(vim): the editor smoke's window no longer takes the keyboard from the machine "3j follows logical relative line numbers" failed once in the 2.79.0 gate run on a busy machine and then passed, with its detail lost. It is not a timing bug in the app. The suite shows its Electron window the usual way, so the window becomes the OS key window, and every key typed or posted on the machine during the run (a person typing in another app, another agent's synthetic keystrokes) lands in the test editor. Vim reads those keys between the suite's own. Reproduced with a traced copy of the suite (key events, the selection changes CodeMirror dispatched, document.hasFocus()), beside an app-core vitest and with the renderer throttled over CDP. The page held OS focus in every run. Failing runs showed keys the suite never sent: "8" then a stray "t" then "j", read as 8tj (till the 8th j), cursor still on line 1; a stray "1" before "8j", read as 18j, line 19; one before "0j", 10j, line 11 in the "I inserts" check; and earlier an 8j that landed on line 82 (81j). The suite's own keys carry keyCode 0, so they were told apart. The failures came in bursts and none at the higher throttle, which fits outside typing rather than CPU starvation. tooling/scripts/test-window-inactive.cjs is preloaded into the test's main process (NODE_OPTIONS=--require): windows show inactive and focus() is a no-op, so the keyboard stays where it was. The suite drives the page over CDP, which needs no OS focus, and turns on CDP focus emulation so the editor's focus rules still see a focused page. With it the page reports document.hasFocus() false before emulation and true after, and the suite passed 3 quiet runs and 4 beside a full app-core vitest. A failing check now prints its detail to stdout, in order with the passes, instead of to stderr, which the gate's tail dropped. sidebar-vim and editor-improvements show their windows the same way and are left as they were. --- tooling/scripts/test-window-inactive.cjs | 24 ++++++++++++++++++++++++ tooling/scripts/vim-editor-smoke.mjs | 15 +++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tooling/scripts/test-window-inactive.cjs diff --git a/tooling/scripts/test-window-inactive.cjs b/tooling/scripts/test-window-inactive.cjs new file mode 100644 index 000000000..edd3dabbc --- /dev/null +++ b/tooling/scripts/test-window-inactive.cjs @@ -0,0 +1,24 @@ +/** + * Preloaded into a smoke test's Electron main process (`NODE_OPTIONS=--require`) + * so the test window never takes the keyboard. + * + * Shown the usual way, the window becomes the OS key window, and every key + * typed or posted on the machine during the run lands in the test editor: a + * person typing in another app, or another agent's synthetic keystrokes. Vim + * then reads a stray `1` or `t` between the suite's own keys, `8j` becomes + * `18j` or `8tj`, and a motion check fails on a busy machine and passes on a + * quiet one. Shown inactive and never focused, the window leaves the keyboard + * where it was; the suite drives it over CDP, which needs no OS focus, and + * turns on CDP focus emulation so the page still behaves as focused. + * + * `electron` is not resolvable yet while preloads run, so the patch waits a + * tick. Child processes inherit NODE_OPTIONS too; only the main process + * patches. + */ +setImmediate(() => { + if (process.type !== 'browser') return + const { BrowserWindow, app } = require('electron') + BrowserWindow.prototype.show = BrowserWindow.prototype.showInactive + BrowserWindow.prototype.focus = function focus() {} + app.focus = () => {} +}) diff --git a/tooling/scripts/vim-editor-smoke.mjs b/tooling/scripts/vim-editor-smoke.mjs index e40ad5647..34d7d984f 100644 --- a/tooling/scripts/vim-editor-smoke.mjs +++ b/tooling/scripts/vim-editor-smoke.mjs @@ -29,6 +29,9 @@ const repoRoot = resolve(scriptDir, '..', '..') const requireDesktop = createRequire(resolve(repoRoot, 'apps/desktop/package.json')) const electronPath = requireDesktop('electron') const desktopOutMain = resolve(repoRoot, 'apps/desktop/out/main/index.js') +// Keeps the test window from taking the keyboard: stray keys from the machine +// were the cause of the counted-motion failures on busy runs (see the file). +const inactiveWindowPreload = resolve(scriptDir, 'test-window-inactive.cjs') const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' const skipBuild = process.env.ZEN_VIM_EDITOR_SKIP_BUILD === '1' const notePath = 'inbox/Vim Motion Runtime.md' @@ -432,7 +435,10 @@ async function main() { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: '1', ZENNOTES_USER_DATA_PATH: userDataRoot, - ZENNOTES_CONFIG_DIR: configRoot + ZENNOTES_CONFIG_DIR: configRoot, + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require ${JSON.stringify(inactiveWindowPreload)}`] + .filter(Boolean) + .join(' ') }, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -450,7 +456,9 @@ async function main() { if (ok) console.log(` PASS ${name}`) else { failures.push(name) - console.error(` FAIL ${name}${detail ? `: ${detail}` : ''}`) + // stdout, in order with the passes: a gate that keeps only stdout, or + // its tail, used to lose the one line that said what went wrong. + console.log(` FAIL ${name}${detail ? `: ${detail}` : ''}`) } } @@ -463,6 +471,9 @@ async function main() { } }) await Promise.all([client.send('Page.enable'), client.send('Runtime.enable')]) + // The window is shown inactive (test-window-inactive.cjs) and never holds + // OS focus; the editor's focus rules still need a focused page. + await client.send('Emulation.setFocusEmulationEnabled', { enabled: true }) console.log('\nVim editor motion smoke test\n') const noteReady = await until( From 5587a42d7f238a4af1b93c7cc248989bb85c415e Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:34:40 -0500 Subject: [PATCH 10/53] Refactor(cloud): the refused-path loop no longer checks for a refused delete cloudSyncPathRefusal answers only when every position Cloud named is an upsert or a move, so the coordinator's "skip a refused delete" branch (e933d450) could never run. It is gone, and the refused writes are typed as the upserts and moves they are, with the reason beside the cast, instead of being guarded at each use. No behavior changes: the refusal tests pass unchanged. --- .../shared-domain/src/cloud-sync-coordinator.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index cbcf29201..822b72b8a 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -11,6 +11,7 @@ import type { CloudSyncLocalConflict, CloudSyncManifestItem, CloudSyncManifestResponse, + CloudSyncMoveMutation, CloudSyncMutation, CloudSyncMutationRequest, CloudSyncMutationResponse, @@ -18,7 +19,8 @@ import type { CloudSyncPendingConflictDetails, CloudSyncPendingConflictResolution, CloudSyncRefusedPath, - CloudSyncRevisionResponse + CloudSyncRevisionResponse, + CloudSyncUpsertMutation } from '@zennotes/bridge-contract/cloud-sync' import { cloudSyncLegacyConflictOriginalPath, @@ -579,7 +581,7 @@ export class CloudSyncCoordinator { let batch = planned while (batch.mutations.length > 0) { let response: CloudSyncMutationResponse - let refused: CloudSyncMutation[] = [] + let refused: Array<CloudSyncUpsertMutation | CloudSyncMoveMutation> = [] let refusalDetail = '' try { response = await this.remote.mutate(this.vaultId, batch) @@ -590,7 +592,9 @@ export class CloudSyncCoordinator { const refusal = cloudSyncPathRefusal(error, batch) if (!refusal) throw error response = refusal.applied - refused = refusal.indices.map((index) => batch.mutations[index]) + // A refusal names only upsert and move positions; anything else + // stopped the run above. + refused = refusal.indices.map((index) => batch.mutations[index] as CloudSyncUpsertMutation | CloudSyncMoveMutation) refusalDetail = refusal.detail } const before = state @@ -632,7 +636,6 @@ export class CloudSyncCoordinator { const now = Date.now() const next = { ...state.refused_paths } for (const mutation of refused) { - if (mutation.type === 'delete') continue next[cloudSyncPathKey(mutation.path)] = { path: mutation.path, refused_at: now, detail: refusalDetail } } state = withRefusedPaths(state, next) @@ -644,7 +647,7 @@ export class CloudSyncCoordinator { ) batch = { mutations: batch.mutations.filter( - (mutation) => !refused.includes(mutation) && !answered.has(mutation.operation_id) + (mutation) => !refused.some((item) => item === mutation) && !answered.has(mutation.operation_id) ) } } From c8493d039c7457cfcefec106c4ada49583678136 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:35:09 -0500 Subject: [PATCH 11/53] Fix(cloud): a file set aside while the clock ran ahead is offered again on time A path Cloud refused waits a day before sync offers it again (#1004), measured as now minus refused_at. A refusal stamped while this device's clock ran ahead, then set back, came out negative, which is always less than a day: the file stayed set aside for the whole gap as well, a month for a clock a month fast, with Settings still promising "once a day". A refused_at later than now now counts as passed, so the path goes to Cloud on the next run and is stamped again from the corrected clock if Cloud still refuses it. The test seeds a refusal stamped thirty days ahead; on the old code the next run did not offer the file at all. --- .../shared-domain/src/cloud-sync-coordinator.ts | 10 +++++++--- .../shared-domain/src/cloud-sync-refusal.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index 822b72b8a..aecd5e3e9 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -2255,8 +2255,11 @@ function conflictPath( /** * The refused paths still set aside at `now`: one whose retry window has * passed is offered to Cloud again, and one no longer on this device has - * nothing left to wait for. State written by an older client, or damaged, - * reads as none. Returns the state's own record when nothing changed. + * nothing left to wait for. A refusal stamped later than `now` was taken + * while the clock ran ahead; it counts as passed, or setting the clock back + * would hold the file for the whole gap as well as its day. State written by + * an older client, or damaged, reads as none. Returns the state's own record + * when nothing changed. */ function currentRefusedPaths( state: CloudSyncState, @@ -2275,7 +2278,8 @@ function currentRefusedPaths( typeof entry.path === 'string' && typeof entry.refused_at === 'number' && typeof entry.detail === 'string' - if (valid && present.has(key) && now - entry.refused_at < CLOUD_SYNC_REFUSED_PATH_RETRY_MS) kept[key] = entry + const waiting = valid && entry.refused_at <= now && now - entry.refused_at < CLOUD_SYNC_REFUSED_PATH_RETRY_MS + if (waiting && present.has(key)) kept[key] = entry else changed = true } return changed ? kept : stored diff --git a/packages/shared-domain/src/cloud-sync-refusal.test.ts b/packages/shared-domain/src/cloud-sync-refusal.test.ts index 11976ba92..ca513a04f 100644 --- a/packages/shared-domain/src/cloud-sync-refusal.test.ts +++ b/packages/shared-domain/src/cloud-sync-refusal.test.ts @@ -151,6 +151,21 @@ describe('a path Cloud refuses (#1004)', () => { expect(h.states.current()!).not.toHaveProperty('refused_paths') }) + it('is offered again when it was set aside while the clock ran ahead', async () => { + // A refusal stamped by a clock that was later set back would otherwise + // wait out the whole gap before its day even began. + const h = harness([text(NOTE), text(RECORD)], { + refused_paths: { + [RECORD.toLowerCase()]: { path: RECORD, refused_at: Date.now() + 30 * 24 * 60 * 60 * 1000, detail: 'old' } + } + }) + + await h.coordinator.sync() + + expect(h.requests.map(h.paths)).toEqual([[RECORD, NOTE], [NOTE]]) + expect(h.states.current()!.refused_paths?.[RECORD.toLowerCase()].refused_at).toBe(Date.now()) + }) + it('stops waiting once the file is gone from this device', async () => { const h = harness([text(NOTE), text(RECORD)]) await h.coordinator.sync() From 66af53e9b5024ad996b4831eb103ed8f26061e94 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:39:51 -0500 Subject: [PATCH 12/53] Fix(cloud): a note moved to a path Cloud refuses stays in Cloud until the path is taken Setting a refused path aside (e933d450) held back the upload or move that carried the path, but let deletes through. Two ways that lost a note in Cloud and on every other device: - A synced note moved to a path Cloud refuses: the move is held, and the note stays where Cloud has it. Then the note is edited. Its content no longer matches, so the next plan is "delete the old item, upload a new file"; the upload was held and the delete went out. - A note renamed and edited before one sync plans the same pair. Cloud refused the request whole, and the resend without the refused upload was the delete alone, which Cloud took. A held path now records what it keeps in Cloud (kept_items, item id to the path Cloud has it at), and those items' deletes wait while the path is held. A refused move, or an upload of a note Cloud has, keeps that note. A refused new file keeps every note deleted in the same run. That rule is deliberately wide: with the old bytes gone from disk, nothing says which delete a renamed and edited note came from, and a guess that missed is the loss above. An unrelated delete in the same run waits too, which costs a delay, never a note. Later runs hold only what was kept, so a note deleted tomorrow still leaves Cloud tomorrow. The record also keeps the held file's hash: a file whose content changed since may now be another note renamed onto the path, so that run's deletes are kept as well. When the retry window passes and Cloud refuses again, the record carries over; when Cloud takes the path, the kept deletes go in the same run and the rename completes. For that to hold when one run spans several requests (a binary upload goes alone, desktop sends large files as direct uploads, batches cap at 100), deletes now go last: after every upload and move of the run has been answered. Cloud counts capacity one write at a time, and deletes used to go ahead of new files, so a full Cloud freed the old copy of a renamed note before counting the new one. An upload Cloud turned away for room is now sent once more after the run's deletes are acknowledged, under a new operation id, so a full vault behaves as before. Tests, in cloud-sync-refusal.test.ts: both cases above (on the old code the delete reached Cloud in each), the rename completing in the run Cloud takes the path, a second refusal a day later still holding, a later unrelated delete going through, a changed held file holding that run's delete, a refused binary upload that used to follow its delete's request, and the room retry. Two coordinator tests changed only in the order requests carry deletes. The scratch repro from the review now prints no delete in either case. --- .../src/cloud-sync-coordinator.test.ts | 9 +- .../src/cloud-sync-coordinator.ts | 177 +++++++++++++++--- .../shared-domain/src/cloud-sync-engine.ts | 11 ++ .../src/cloud-sync-refusal.test.ts | 156 ++++++++++++++- 4 files changed, 321 insertions(+), 32 deletions(-) diff --git a/packages/shared-domain/src/cloud-sync-coordinator.test.ts b/packages/shared-domain/src/cloud-sync-coordinator.test.ts index ecaccf284..b5df75a3b 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.test.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.test.ts @@ -2524,9 +2524,10 @@ describe('CloudSyncCoordinator: rejected mutations name their file', () => { ids() ).sync() + // Deletes go after every other write of the run. expect(result.conflicts.map((c) => [c.item_id, c.code, c.path])).toEqual([ - ['gone', 'ITEM_DELETED', 'inbox/Gone.md'], - ['kept', 'REVISION_CONFLICT', 'inbox/Kept.md'] + ['kept', 'REVISION_CONFLICT', 'inbox/Kept.md'], + ['gone', 'ITEM_DELETED', 'inbox/Gone.md'] ]) }) }) @@ -2969,9 +2970,9 @@ describe('CloudSyncCoordinator: a cursor older than the change log (CURSOR_EXPIR 'Fresh.md': 'fresh' }) expect(server.mutations.flatMap((request) => request.mutations)).toEqual([ - expect.objectContaining({ type: 'delete', item_id: 'gone', base_revision: 1 }), expect.objectContaining({ type: 'upsert', item_id: 'plan', base_revision: 1, path: 'Plan.md', content: realContent('mine') }), - expect.objectContaining({ type: 'upsert', base_revision: null, path: 'Fresh.md', content: realContent('fresh') }) + expect.objectContaining({ type: 'upsert', base_revision: null, path: 'Fresh.md', content: realContent('fresh') }), + expect.objectContaining({ type: 'delete', item_id: 'gone', base_revision: 1 }) ]) expect(result.localConflicts).toEqual([]) expect(result.pendingConflicts).toEqual([ diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index aecd5e3e9..d1db24cd9 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -542,8 +542,8 @@ export class CloudSyncCoordinator { localItems = await this.repository.scan() } const refusals = currentRefusedPaths(state, localItems, Date.now()) - if (refusals !== state.refused_paths) { - state = withRefusedPaths(state, refusals) + if (refusals.standing !== state.refused_paths) { + state = withRefusedPaths(state, refusals.standing) await this.states.save(state) } const pendingPathKeys = new Set([ @@ -567,18 +567,53 @@ export class CloudSyncCoordinator { // A path Cloud refused waits out its retry window. Only the write that // carries it waits: tracked state is untouched, so a move held back here // leaves the item where Cloud has it rather than turning into a delete. + // A delete is the half that can lose a note: one renamed and edited + // before a sync plans as a delete of the old item plus a new file, and + // if the file is refused the delete must wait with it (heldPathRecord). + // So deletes go last, after every write of the run has been answered, + // and any a held path keeps in Cloud are left out of each request. const plan = planCloudSyncMutations(mutationState, mutationItems, this.ids) - plan.mutations = plan.mutations.filter( - (mutation) => mutation.type === 'delete' || !refusals?.[cloudSyncPathKey(mutation.path)] + const runDeletes = new Map<string, string>() + const writes: CloudSyncMutation[] = [] + const deletes: CloudSyncMutation[] = [] + for (const mutation of plan.mutations) { + if (mutation.type !== 'delete') continue + runDeletes.set(mutation.item_id, mutationState.items[mutation.item_id].path) + deletes.push(mutation) + } + const refusedPaths: Record<string, CloudSyncStoredRefusedPath> = { ...state.refused_paths } + for (const mutation of plan.mutations) { + if (mutation.type === 'delete') continue + const key = cloudSyncPathKey(mutation.path) + const entry = refusedPaths[key] + if (!entry) { + writes.push(mutation) + continue + } + refusedPaths[key] = heldPathRecord(mutation, entry, entry.refused_at, entry.detail, mutationState, runDeletes) + } + const keptItems = new Set( + Object.values(refusedPaths).flatMap((entry) => Object.keys(entry.kept_items ?? {})) ) + if (JSON.stringify(refusedPaths) !== JSON.stringify(state.refused_paths ?? {})) { + state = withRefusedPaths(state, refusedPaths) + await this.states.save(state) + } + const withoutKeptDeletes = (mutations: CloudSyncMutation[]): CloudSyncMutationRequest => ({ + mutations: mutations.filter((mutation) => mutation.type !== 'delete' || !keptItems.has(mutation.item_id)) + }) const conflicts: CloudSyncConflict[] = [] const pausedPathsByKey = new Map<string, Set<string>>() const acknowledgedSequences = new Set<number>() let mutationCursor = state.cursor let pushed = 0 + // Writes a full Cloud answered before this run's deletes made room. + const waitingForRoom: CloudSyncUpsertMutation[] = [] + let deleted = false - for (const planned of mutationBatches(plan.mutations)) { - let batch = planned + const batches = mutationBatches([...writes, ...deletes]) + for (let index = 0; index < batches.length; index += 1) { + let batch = withoutKeptDeletes(batches[index].mutations) while (batch.mutations.length > 0) { let response: CloudSyncMutationResponse let refused: Array<CloudSyncUpsertMutation | CloudSyncMoveMutation> = [] @@ -613,8 +648,9 @@ export class CloudSyncCoordinator { })) conflicts.push(...annotatedConflicts) for (const conflict of annotatedConflicts) { - if (conflict.code !== 'PATH_CONFLICT') continue const mutation = byOperation.get(conflict.operation_id) + if (mutation?.type === 'upsert' && waitsForRoom(conflict)) waitingForRoom.push(mutation) + if (conflict.code !== 'PATH_CONFLICT') continue const paths = [ conflict.path, conflict.current_path, @@ -631,25 +667,40 @@ export class CloudSyncCoordinator { mutationCursor = Math.max(mutationCursor, response.cursor) for (const acknowledgement of response.acknowledged) { acknowledgedSequences.add(acknowledgement.sequence) + if (byOperation.get(acknowledgement.operation_id)?.type === 'delete') deleted = true } if (refused.length > 0) { const now = Date.now() - const next = { ...state.refused_paths } for (const mutation of refused) { - next[cloudSyncPathKey(mutation.path)] = { path: mutation.path, refused_at: now, detail: refusalDetail } + const key = cloudSyncPathKey(mutation.path) + const entry = heldPathRecord(mutation, refusals.due[key], now, refusalDetail, mutationState, runDeletes) + refusedPaths[key] = entry + for (const itemId of Object.keys(entry.kept_items ?? {})) keptItems.add(itemId) } - state = withRefusedPaths(state, next) + state = withRefusedPaths(state, refusedPaths) } await this.states.save(state) if (refused.length === 0) break const answered = new Set( [...response.acknowledged, ...response.conflicts].map((answer) => answer.operation_id) ) - batch = { - mutations: batch.mutations.filter( + batch = withoutKeptDeletes( + batch.mutations.filter( (mutation) => !refused.some((item) => item === mutation) && !answered.has(mutation.operation_id) ) - } + ) + } + // Cloud counts room one write at a time, in order. Deletes used to go + // ahead of new files, so a full Cloud freed the old copy of a renamed + // note before counting the new one; sent last, they free it after the + // new file was turned away. That file goes once more, under a new + // operation id, since Cloud answers a repeated id with its first answer. + if (index === batches.length - 1 && deleted && waitingForRoom.length > 0) { + const retried = new Set(waitingForRoom.map((mutation) => mutation.operation_id)) + const again = waitingForRoom.splice(0).map((mutation) => ({ ...mutation, operation_id: this.ids.operationId() })) + conflicts.splice(0, conflicts.length, ...conflicts.filter((conflict) => !retried.has(conflict.operation_id))) + batches.push(...mutationBatches(again)) + deleted = false } } @@ -2253,21 +2304,26 @@ function conflictPath( } /** - * The refused paths still set aside at `now`: one whose retry window has - * passed is offered to Cloud again, and one no longer on this device has - * nothing left to wait for. A refusal stamped later than `now` was taken - * while the clock ran ahead; it counts as passed, or setting the clock back - * would hold the file for the whole gap as well as its day. State written by - * an older client, or damaged, reads as none. Returns the state's own record - * when nothing changed. + * The refused paths still set aside at `now` (`standing`): one whose retry + * window has passed is offered to Cloud again (`due`, which keeps its record + * for this run, so a second refusal keeps what the first one kept), and one + * no longer on this device has nothing left to wait for. A refusal stamped + * later than `now` was taken while the clock ran ahead; it counts as passed, + * or setting the clock back would hold the file for the whole gap as well as + * its day. State written by an older client, or damaged, reads as none. + * `standing` is the state's own record when nothing changed. */ function currentRefusedPaths( state: CloudSyncState, localItems: readonly CloudSyncLocalItem[], now: number -): Record<string, CloudSyncStoredRefusedPath> | undefined { +): { + standing: Record<string, CloudSyncStoredRefusedPath> | undefined + due: Record<string, CloudSyncStoredRefusedPath> +} { const stored = state.refused_paths - if (stored === undefined) return undefined + const due: Record<string, CloudSyncStoredRefusedPath> = {} + if (stored === undefined) return { standing: undefined, due } const present = new Set(localItems.map((item) => cloudSyncPathKey(item.path))) const kept: Record<string, CloudSyncStoredRefusedPath> = {} let changed = typeof stored !== 'object' || stored === null || Array.isArray(stored) @@ -2277,12 +2333,79 @@ function currentRefusedPaths( typeof entry === 'object' && typeof entry.path === 'string' && typeof entry.refused_at === 'number' && - typeof entry.detail === 'string' - const waiting = valid && entry.refused_at <= now && now - entry.refused_at < CLOUD_SYNC_REFUSED_PATH_RETRY_MS - if (waiting && present.has(key)) kept[key] = entry - else changed = true + typeof entry.detail === 'string' && + (entry.sha256 === undefined || typeof entry.sha256 === 'string') && + (entry.kept_items === undefined || isStringRecord(entry.kept_items)) + if (!valid || !present.has(key)) { + changed = true + continue + } + if (entry.refused_at <= now && now - entry.refused_at < CLOUD_SYNC_REFUSED_PATH_RETRY_MS) { + kept[key] = entry + } else { + due[key] = entry + changed = true + } } - return changed ? kept : stored + return { standing: changed ? kept : stored, due } +} + +function isStringRecord(value: unknown): value is Record<string, string> { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value).every((item) => typeof item === 'string') + ) +} + +/** + * The record for `write`'s path once it is held: refused by Cloud now, or + * still set aside from an earlier run (`prior` is the path's last record). + * + * A held path keeps notes in Cloud, so their deletes wait with it. A move, + * or an upload of a note Cloud has, keeps that note. A new file keeps every + * note deleted in the same run: a note renamed and edited before one sync + * plans as exactly that, a delete and a new file, and with the old bytes gone + * from disk nothing says which delete is its other half. A rule that guessed + * and missed would delete the note in Cloud and on every other device, so + * this one does not guess; a delete that had nothing to do with it waits + * too, which costs a delay, never a note. A held file whose content has not + * changed since keeps what it kept. One whose content has changed may now + * be another note renamed onto the path, so it also keeps that run's deletes. + */ +function heldPathRecord( + write: CloudSyncUpsertMutation | CloudSyncMoveMutation, + prior: CloudSyncStoredRefusedPath | undefined, + refusedAt: number, + detail: string, + state: CloudSyncState, + runDeletes: ReadonlyMap<string, string> +): CloudSyncStoredRefusedPath { + const sha256 = write.type === 'upsert' ? write.content.sha256 : write.content_metadata?.sha256 + const kept: Record<string, string> = { ...prior?.kept_items } + const tracked = state.items[write.item_id] + if (tracked) { + kept[write.item_id] = tracked.path + } else if (prior?.sha256 === undefined || prior.sha256 !== sha256) { + for (const [itemId, path] of runDeletes) kept[itemId] = path + } + return { + path: write.path, + refused_at: refusedAt, + detail, + ...(sha256 === undefined ? {} : { sha256 }), + ...(Object.keys(kept).length > 0 ? { kept_items: kept } : {}) + } +} + +/** A capacity answer that room elsewhere in the vault can clear; a file + * over the per-file limit stays too large whatever else is deleted. */ +function waitsForRoom(conflict: CloudSyncConflict): boolean { + return ( + (conflict.code === 'QUOTA_EXCEEDED' || conflict.code === 'CAPACITY_EXCEEDED') && + conflict.capacity?.dimension !== 'sync_max_file_bytes' + ) } function withRefusedPaths( diff --git a/packages/shared-domain/src/cloud-sync-engine.ts b/packages/shared-domain/src/cloud-sync-engine.ts index e1f591162..9eda37e7c 100644 --- a/packages/shared-domain/src/cloud-sync-engine.ts +++ b/packages/shared-domain/src/cloud-sync-engine.ts @@ -85,6 +85,17 @@ export interface CloudSyncStoredRefusedPath { refused_at: number /** Cloud's answer, for Details. */ detail: string + /** The content of the file at the path when its holds were last weighed, + * so a later run can tell whether it is still that file. */ + sha256?: string + /** + * Notes Cloud keeps while this path waits, by item id, with the path Cloud + * has each at: the note a refused move or upload carried, and for a + * refused new file the notes deleted alongside it, any of which it may + * have been renamed from. Their deletes wait with the path, so a refusal + * never leaves Cloud with neither copy. + */ + kept_items?: Record<string, string> } export interface CloudSyncIdSource { diff --git a/packages/shared-domain/src/cloud-sync-refusal.test.ts b/packages/shared-domain/src/cloud-sync-refusal.test.ts index ca513a04f..34bd674cd 100644 --- a/packages/shared-domain/src/cloud-sync-refusal.test.ts +++ b/packages/shared-domain/src/cloud-sync-refusal.test.ts @@ -118,7 +118,8 @@ describe('a path Cloud refuses (#1004)', () => { [RECORD.toLowerCase()]: { path: RECORD, refused_at: Date.parse('2026-10-29T12:00:00Z'), - detail: `HTTP 422 · VALIDATION_FAILED · ${REFUSED}` + detail: `HTTP 422 · VALIDATION_FAILED · ${REFUSED}`, + sha256: `hash:${RECORD}` } }) }) @@ -230,6 +231,159 @@ describe('a path Cloud refuses (#1004)', () => { }) }) +describe('a note on its way to a path Cloud refuses', () => { + const tracked = (id: string, path: string, data: string) => ({ + item_id: id, path, kind: 'text' as const, revision: 1, + sha256: `hash:${data}`, byte_length: data.length, media_type: 'text/markdown' + }) + const moved = '.zennotes/agent-edits/moved.json' + const kinds = (request: CloudSyncMutationRequest) => + request.mutations.map((mutation) => (mutation.type === 'delete' ? `delete ${mutation.item_id}` : `${mutation.type} ${mutation.path}`)) + + it('stays in Cloud when it is edited after its move was refused', async () => { + const h = harness([text(moved, 'body')], { items: { note: tracked('note', NOTE, 'body') } }) + await h.coordinator.sync() + expect(h.requests.map(kinds)).toEqual([[`move ${moved}`]]) + h.requests.length = 0 + + // Edited at the refused path: the plan is now "delete the old item, + // upload a new file", and the upload is held. + h.local.items = [text(moved, 'body edited')] + const result = await h.coordinator.sync() + + expect(h.requests).toEqual([]) + expect(h.states.current()!.items.note.path).toBe(NOTE) + expect(result.refusedPaths.map((row) => row.path)).toEqual([moved]) + }) + + it('stays in Cloud when it is renamed and edited before one sync', async () => { + const h = harness([text(moved, 'body edited')], { items: { note: tracked('note', NOTE, 'body') } }) + + const result = await h.coordinator.sync() + + // Refused whole; the delete is the other half of the refused file and + // is not sent again on its own. + expect(h.requests.map(kinds)).toEqual([[`upsert ${moved}`, 'delete note']]) + expect(h.states.current()!.items.note.path).toBe(NOTE) + expect(result.refusedPaths.map((row) => row.path)).toEqual([moved]) + + h.requests.length = 0 + await h.coordinator.sync() + expect(h.requests).toEqual([]) + }) + + it('leaves Cloud in the same run that Cloud takes the new path', async () => { + const h = harness([text(moved, 'body edited')], { items: { note: tracked('note', NOTE, 'body') } }) + await h.coordinator.sync() + h.cloud.refuses = () => false + h.requests.length = 0 + + vi.advanceTimersByTime(CLOUD_SYNC_REFUSED_PATH_RETRY_MS) + const result = await h.coordinator.sync() + + expect(h.requests.map(kinds)).toEqual([[`upsert ${moved}`, 'delete note']]) + expect(result.refusedPaths).toEqual([]) + expect(Object.values(h.states.current()!.items).map((row) => row.path)).toEqual([moved]) + }) + + it('is still held when Cloud refuses its new path again a day later', async () => { + const h = harness([text(moved, 'body edited')], { items: { note: tracked('note', NOTE, 'body') } }) + await h.coordinator.sync() + h.requests.length = 0 + + vi.advanceTimersByTime(CLOUD_SYNC_REFUSED_PATH_RETRY_MS) + await h.coordinator.sync() + + expect(h.requests.map(kinds)).toEqual([[`upsert ${moved}`, 'delete note']]) + expect(h.states.current()!.items.note.path).toBe(NOTE) + }) + + it('does not hold a later delete of another note', async () => { + const other = 'inbox/Other.md' + const h = harness([text(moved, 'body edited'), text(other, 'other')], { + items: { note: tracked('note', NOTE, 'body'), other: tracked('other', other, 'other') } + }) + await h.coordinator.sync() + h.requests.length = 0 + + h.local.items = [text(moved, 'body edited')] + await h.coordinator.sync() + + expect(h.requests.map(kinds)).toEqual([['delete other']]) + expect(Object.keys(h.states.current()!.items)).toEqual(['note']) + }) + + it('holds a delete that comes with a change to the held file, which may be another note renamed onto it', async () => { + const second = 'inbox/Second.md' + const h = harness([text(moved, 'record'), text(second, 'second')], { + items: { second: tracked('second', second, 'second') } + }) + await h.coordinator.sync() + expect(h.states.current()!.refused_paths).toBeDefined() + h.requests.length = 0 + + // Second.md renamed onto the held path and edited, in one go. + h.local.items = [text(moved, 'second edited')] + await h.coordinator.sync() + + expect(h.requests).toEqual([]) + expect(h.states.current()!.items.second.path).toBe(second) + }) + + it('never lets a delete go ahead of a refused file sent in a later request', async () => { + // A binary upload goes in a request of its own; it used to follow the + // request that carried the delete. + const image = (path: string, data: string): CloudSyncLocalItem => ({ + path, kind: 'binary', + content: { encoding: 'base64', data, sha256: `hash:${data}`, byte_length: data.length, media_type: 'image/png' } + }) + const h = harness([image('Refused/a.png', 'cropped')], { + items: { + a: { item_id: 'a', path: 'Photos/a.png', kind: 'binary', revision: 1, sha256: 'hash:original', byte_length: 8, media_type: 'image/png' } + } + }) + h.cloud.refuses = (path) => path.startsWith('Refused/') + + await h.coordinator.sync() + + expect(h.requests.map(kinds)).toEqual([['upsert Refused/a.png']]) + expect(h.states.current()!.items.a.path).toBe('Photos/a.png') + }) + + it('sends a new file again once the deletes sent after it make room in a full Cloud', async () => { + const other = 'inbox/Other.md' + const h = harness([text('inbox/New.md', 'new')], { + items: { other: tracked('other', other, 'other') } + }) + // One file left on the plan: the new file fits only once Other.md is gone. + let items = 1 + h.server.mutate = async (_vaultId, body) => { + h.requests.push(structuredClone(body)) + const response = h.answer({ mutations: [] }) + for (const mutation of body.mutations) { + if (mutation.type === 'delete') items -= 1 + if (mutation.type === 'upsert' && mutation.base_revision === null && items >= 1) { + response.conflicts.push({ + operation_id: mutation.operation_id, item_id: mutation.item_id, code: 'CAPACITY_EXCEEDED', + current_revision: null, current_path: null, + capacity: { dimension: 'sync_active_items', used: 1, reserved: 0, limit: 1, projected: 2, can_retry_after_reduction: true } + }) + continue + } + if (mutation.type === 'upsert' && mutation.base_revision === null) items += 1 + response.acknowledged.push(...h.answer({ mutations: [mutation] }).acknowledged) + } + return response + } + + const result = await h.coordinator.sync() + + expect(h.requests.map(kinds)).toEqual([['upsert inbox/New.md', 'delete other'], ['upsert inbox/New.md']]) + expect(result.conflicts).toEqual([]) + expect(Object.values(h.states.current()!.items).map((row) => row.path)).toEqual(['inbox/New.md']) + }) +}) + describe('cloudSyncPathRefusal', () => { const batch: CloudSyncMutationRequest = { mutations: [ From eaab18c4be3e8826e28f8f4e67a9200b178cca21 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:41:57 -0500 Subject: [PATCH 13/53] Fix(cloud): a file set aside no longer leaves sync stuck on "needs attention" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run whose only news was a file Cloud refused (#1004) set the status to attention, like a run that stopped. Everything else had synced, and the file is offered again on its own, but the status never went back: lastSyncedAt only advances on a run with nothing to report, so it froze at the last run before the refusal, every later run kept the file set aside and the status in attention, cloudSyncNeedsUser forced a status bar the person had turned off back on, and the tray showed attention for as long as the file existed, which for an assistant edit record on an older server is for good. Such a run now counts as synced: the status reads ready and lastSyncedAt moves with every run, so nothing forces the status bar back and the tray stays idle. Settings → Cloud still lists each held file with Cloud's answer under Details, read from the run summary as before, and the status bar's tooltip adds "1 file stays on this device: Settings → Cloud lists it." Anything else in the same run (a conflict, a capacity stop) still needs the person exactly as it did. Answering the vault settings question falls back to the same rule. The in-app manual says the status bar reads Synced, with the count in its tooltip. Tests: two runs that only hold a file read ready, advance lastSyncedAt each time, do not need the user and report an idle tray, while Settings' rows still list the file, and a conflict in the same run still needs the user (on the old code the first run read attention); the status bar's tone and tooltip after such a run. --- .../app-core/src/components/StatusBar.test.ts | 28 +++++++++++ .../app-core/src/components/StatusBar.tsx | 10 +++- .../app-core/src/lib/cloud-auto-sync.test.ts | 50 +++++++++++++++++-- packages/app-core/src/lib/cloud-auto-sync.ts | 35 ++++++++++--- packages/app-core/src/lib/help.ts | 2 +- 5 files changed, 112 insertions(+), 13 deletions(-) diff --git a/packages/app-core/src/components/StatusBar.test.ts b/packages/app-core/src/components/StatusBar.test.ts index 52b5f01ec..c782616c5 100644 --- a/packages/app-core/src/components/StatusBar.test.ts +++ b/packages/app-core/src/components/StatusBar.test.ts @@ -135,6 +135,34 @@ describe("cloud sync status time", () => { host.remove(); }); + it("reads synced after a run that only set a file aside, and says so on hover (#1004)", () => { + useCloudSyncStatusStore.setState({ + phase: "ready", + vaultName: "Notes", + lastSyncedAt: Date.now(), + error: null, + lastSummary: { + cursor: 1, pulled: 0, pushed: 2, bootstrap_conflicts: [], local_conflicts: [], conflicts: [], + refused_paths: [{ path: ".zennotes/agent-edits/inbox/Note.md.json", detail: "HTTP 422" }], + }, + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + act(() => root.render(createElement(StatusBar, { note: null }))); + + const status = host.querySelector<HTMLElement>("[data-cloud-sync-status]"); + expect(status?.textContent).toContain("Synced"); + expect(status?.className).toContain("text-success"); + expect(status?.title).toBe( + "Notes is connected. 1 file stays on this device: Settings → Cloud lists it. Click to sync now.", + ); + + act(() => root.unmount()); + host.remove(); + }); + it("shows conflicted syncs as incomplete and offers a review action", () => { useCloudSyncStatusStore.setState({ phase: "attention", diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index 722adca8f..c8d6ea52e 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -20,6 +20,7 @@ import { useCloudSyncStatusStore, } from "../lib/cloud-auto-sync"; import { requestSettingsTarget } from "../lib/settings-navigation"; +import { cloudSyncRefusedPathsLabel } from "@zennotes/shared-domain/cloud-error-copy"; /** * Footer strip showing quick stats for the active note: backlinks, @@ -176,6 +177,13 @@ function CloudSyncStatus({ : lastSyncedAt === null ? "Cloud ready" : `Synced ${formatRelativeSyncTime(lastSyncedAt, now)}`; + // Files Cloud refused leave a synced run synced (#1004); the hover still + // says they wait, for anyone who looks. + const held = lastSummary?.refused_paths?.length ?? 0; + const heldNote = + held > 0 + ? ` ${cloudSyncRefusedPathsLabel(held)}: Settings → Cloud lists ${held === 1 ? "it" : "them"}.` + : ""; const title = removal ? cloudVaultRemovalMessage(removal) : phase === "disconnected" @@ -190,7 +198,7 @@ function CloudSyncStatus({ ? `${error ?? "Sync didn't finish."} Click to retry.` : phase === "syncing" ? `Syncing ${vaultName ?? "this vault"}` - : `${vaultName ?? "Cloud vault"} is connected. Click to sync now.`; + : `${vaultName ?? "Cloud vault"} is connected.${heldNote} Click to sync now.`; const statusTone = shownPhase === "error" ? "text-danger" diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index 6e6f711e2..be84dd78b 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -39,6 +39,7 @@ import { useCloudSyncStatusStore, } from "./cloud-auto-sync"; import { useToastStore } from "./toast"; +import { traySyncStatusFor } from "./tray-runtime"; function setup( initialStatus: CloudAccountStatus = { @@ -845,10 +846,53 @@ describe("cloudSyncAttentionItems (Discord: name the file, not the count)", () = ).toBeNull(); clearCloudSyncStatus(); - await syncCloudVaultWithStatus({ syncCloudVault: async () => summary }, "Notes"); - expect(useCloudSyncStatusStore.getState().phase).toBe("attention"); - expect(useCloudSyncStatusStore.getState().error).toBe(cloudSyncAttentionMessage(summary)); + }); + + it("counts a run whose only news is a file set aside as synced, and keeps it in Settings", async () => { + const record = ".zennotes/agent-edits/inbox/Note.md.json"; + const summary: CloudSyncRunSummary = { + ...base, + pushed: 3, + refused_paths: [{ path: record, detail: "HTTP 422 · VALIDATION_FAILED · refused" }], + }; clearCloudSyncStatus(); + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-10-29T12:00:00Z")); + await syncCloudVaultWithStatus({ syncCloudVault: async () => summary }, "Notes"); + const first = useCloudSyncStatusStore.getState(); + expect(first.phase).toBe("ready"); + expect(first.error).toBeNull(); + expect(first.lastSyncedAt).toBe(Date.parse("2026-10-29T12:00:00Z")); + // Not forced back into a status bar that was turned off, and the tray + // stays idle. + expect(cloudSyncNeedsUser()).toBe(false); + expect(traySyncStatusFor(first)?.state).toBe("idle"); + // Settings still reads the file from the summary. + expect(first.lastSummary?.refused_paths?.map((row) => row.path)).toEqual([record]); + expect(cloudSyncAttentionItems(first.lastSummary!).map((item) => item.kind)).toEqual(["refused"]); + + // Every later run that still holds it keeps the clock moving. + vi.setSystemTime(new Date("2026-10-29T12:05:00Z")); + await syncCloudVaultWithStatus({ syncCloudVault: async () => summary }, "Notes"); + expect(useCloudSyncStatusStore.getState().lastSyncedAt).toBe(Date.parse("2026-10-29T12:05:00Z")); + + // Anything else in the same run still needs the person. + await syncCloudVaultWithStatus( + { + syncCloudVault: async () => ({ + ...summary, + conflicts: [{ operation_id: "op", item_id: "i", code: "REVISION_CONFLICT", current_revision: 2, current_path: null }], + }), + }, + "Notes", + ); + expect(useCloudSyncStatusStore.getState().phase).toBe("attention"); + expect(cloudSyncNeedsUser()).toBe(true); + } finally { + clearCloudSyncStatus(); + vi.useRealTimers(); + } }); it("remembers the last run summary so Review can show it without another sync", async () => { diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index d7b54c249..3c356b67c 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -460,10 +460,22 @@ function attentionMessageWithoutSettings( summary: CloudSyncRunSummary | null, ): string | null { if (summary === null) return null; - const attention = cloudSyncAttentionMessage(summary); + const attention = runAttentionMessage(summary); return attention === SETTINGS_ATTENTION_MESSAGE ? null : attention; } +/** + * What a run leaves on the status: null when nothing in it needs the person. + * Files Cloud refused are set aside and offered again on their own (#1004), + * so a run whose only news is those has synced everything it could: the + * status reads synced, the tray stays idle and a status bar turned off stays + * off. Settings → Cloud still lists each file with Cloud's answer, from the + * summary (cloudSyncAttentionMessage keeps the sentence for it). + */ +function runAttentionMessage(summary: CloudSyncRunSummary): string | null { + return cloudSyncAttentionIsRefusedOnly(summary) ? null : cloudSyncAttentionMessage(summary); +} + /** True while sync waits for an answer about the vault settings. */ export function hasPendingCloudSettingsConflict(): boolean { return useCloudSyncStatusStore.getState().settingsConflict !== null; @@ -741,7 +753,7 @@ function applyCloudSyncSummary(summary: CloudSyncRunSummary, vaultName?: string // A run that reports nothing new has still not synced the vault settings // while the question from an earlier run is open. const attention = - cloudSyncAttentionMessage(summary) ?? + runAttentionMessage(summary) ?? (current.settingsConflict !== null ? SETTINGS_ATTENTION_MESSAGE : null); useCloudSyncStatusStore.setState({ phase: attention === null ? "ready" : "attention", @@ -1107,12 +1119,9 @@ export function cloudSyncAttentionLabel( const capacityConflict = firstCapacityConflict(summary); if (!capacityConflict) { // A run whose only trouble is a file Cloud refused names that, calmly. - const refused = summary.refused_paths ?? []; - const onlyRefused = - refused.length > 0 && - cloudSyncAttentionMessage(summary) === - cloudSyncRefusedPathsMessage(refused.map((row) => row.path)); - return onlyRefused ? cloudSyncRefusedPathsLabel(refused.length) : null; + return cloudSyncAttentionIsRefusedOnly(summary) + ? cloudSyncRefusedPathsLabel(summary.refused_paths?.length ?? 0) + : null; } const dimension = capacityConflict.capacity?.dimension; if (dimension === "sync_max_file_bytes" || capacityConflict.code === "FILE_SIZE_LIMIT_EXCEEDED") { @@ -1124,6 +1133,16 @@ export function cloudSyncAttentionLabel( return "Cloud capacity reached"; } +/** True when the only thing a run reports is files Cloud refused. */ +export function cloudSyncAttentionIsRefusedOnly(summary: CloudSyncRunSummary): boolean { + const refused = summary.refused_paths ?? []; + return ( + refused.length > 0 && + cloudSyncAttentionMessage(summary) === + cloudSyncRefusedPathsMessage(refused.map((row) => row.path)) + ); +} + export function cloudSyncAttentionMessage( summary: CloudSyncRunSummary, ): string | null { diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 6d32dde95..f7a2bebbc 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -249,7 +249,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'When Cloud reports a problem', body: - 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.\n\nWhen Cloud refuses one file, usually one ZenNotes keeps for itself under `.zennotes`, the rest of the vault keeps syncing: the status bar says **1 file stays on this device**, and Settings → Cloud lists the file with Cloud\'s answer under Details. Nothing is lost, and there is nothing to change on your side. Sync offers the file to Cloud again once a day, so it goes up by itself once Cloud takes it; if it keeps coming back, copy the details and choose Report an Issue.' + 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.\n\nWhen Cloud refuses one file, usually one ZenNotes keeps for itself under `.zennotes`, the rest of the vault keeps syncing, and the run counts as synced: the status bar reads **Synced** as usual (its tooltip adds **1 file stays on this device**), and Settings → Cloud lists the file with Cloud\'s answer under Details. Nothing is lost, and there is nothing to change on your side. Sync offers the file to Cloud again once a day, so it goes up by itself once Cloud takes it; if it keeps coming back, copy the details and choose Report an Issue.' }, { title: 'Customize the look: themes vs. overrides', From 1b49dcdaa421146f60f66165d8a359bbbaaeb5f7 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:45:06 -0500 Subject: [PATCH 14/53] Fix(cloud): a note Cloud won't take says to rename it, and a too-long path is held before it is sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The words for a file Cloud refused (#1004) were written for an assistant edit record under .zennotes: "nothing to change on your side", sync offers it again once a day. For one of the person's own notes that was wrong. Cloud refusing a note's path means its name or location, and in practice its length, and waiting a day changes nothing: the note needs a new name. The sentence and each row in Settings → Cloud now say whose move it is. A file ZenNotes keeps for itself still reads calm, nothing to change, offered again once a day. A path longer than Cloud takes says so and asks for a shorter name or a folder nearer the top of the vault. Any other refused note or attachment asks for a new name or folder, with Cloud's answer under Details. Several files with one of the person's among them point at the list, which says what to do with each. The in-app manual covers both, and says that a note moved or renamed to such a path stays in Cloud under its old name until then. Cloud caps a path at 1,024 characters in its request rules (max:1024 on a mutation's path and on a direct upload's), counted as code points. The cap now lives beside shouldSyncVaultPath as cloudSyncPathFitsCloud, not inside it: shouldSyncVaultPath decides what the scan sees, and a note renamed to a path the scan does not see is planned as a delete, which would take it out of Cloud and off every other device, the loss the previous commit closed. Instead the coordinator holds a too-long upload or move before sending it, exactly as if Cloud had refused it, so its old copy stays in Cloud, its delete waits, and Details says "Not sent: this path has 1,087 characters, and ZenNotes Cloud takes paths of up to 1,024." A shorter name syncs at once. The shared-domain mirror dataset gains three length rows (1,024 and 1,025 characters, and 1,024 characters outside the BMP, 2,045 UTF-16 units). They are this side's only: the website's dataset pins VaultPathService::shouldSync, which has no cap; the cap is in its request rules. Tests: the dataset rows, the copy for each case, a note moved to a 1,087-character path held without a request through an edit and synced once renamed (on the old coordinator the move was sent), and the Settings rows. --- .../src/components/CloudSettings.test.ts | 4 +- .../app-core/src/lib/cloud-auto-sync.test.ts | 9 +++- packages/app-core/src/lib/cloud-auto-sync.ts | 4 +- packages/app-core/src/lib/help.ts | 2 +- packages/bridge-contract/src/cloud-sync.ts | 6 ++- .../src/cloud-error-copy.test.ts | 31 +++++++++++-- .../shared-domain/src/cloud-error-copy.ts | 44 +++++++++++++++---- .../src/cloud-sync-coordinator.ts | 20 +++++++-- .../src/cloud-sync-refusal.test.ts | 25 +++++++++++ packages/shared-domain/src/cloud-sync.test.ts | 14 +++++- packages/shared-domain/src/cloud-sync.ts | 24 ++++++++++ 11 files changed, 157 insertions(+), 26 deletions(-) diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index 26516a084..faadb84cb 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -973,13 +973,13 @@ describe("CloudSettings", () => { expect(host.textContent).toContain("1 file stays on this device"); expect(host.textContent).toContain( - "ZenNotes Cloud didn't accept “Plan.md.json”, so it stays on this device while the rest of the vault keeps syncing.", + "ZenNotes Cloud didn't accept “Plan.md.json”, a file ZenNotes keeps for itself, so it stays on this device while the rest of the vault keeps syncing.", ); const list = host.querySelector('[aria-label="Files that need attention"]'); const rows = [...list!.querySelectorAll("li")]; expect(rows).toHaveLength(1); expect(rows[0].textContent).toContain(".zennotes/agent-edits/inbox/Plan.md.json"); - expect(rows[0].textContent).toContain("Sync offers it again once a day."); + expect(rows[0].textContent).toContain("sync offers it again once a day. There is nothing to change."); // Cloud's own answer sits under the row's Details, not in the sentence. expect(rows[0].querySelector("details")?.textContent).toContain(detail); // A file under .zennotes is not a note to open. diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index be84dd78b..0ec66cad8 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -828,14 +828,19 @@ describe("cloudSyncAttentionItems (Discord: name the file, not the count)", () = kind: "refused", path: record, detail: - "ZenNotes Cloud didn't accept this file, so it stays on this device. Sync offers it again once a day.", + "A file ZenNotes keeps for itself. Cloud didn't accept it, so it stays on this device, and sync offers it again once a day. There is nothing to change.", conflictCopyPath: null, technical: detail, }, ]); expect(cloudSyncAttentionMessage(summary)).toMatch( - /^ZenNotes Cloud didn't accept “Note\.md\.json”, so it stays on this device while the rest of the vault keeps syncing\./, + /^ZenNotes Cloud didn't accept “Note\.md\.json”, a file ZenNotes keeps for itself, so it stays on this device while the rest of the vault keeps syncing\./, ); + // A note of the person's is theirs to rename, and its row says how. + const note = `${"Projects/".repeat(120)}Plan.md`; + const noteRows = cloudSyncAttentionItems({ ...summary, refused_paths: [{ path: note, detail: "" }] }); + expect(noteRows[0].detail).toMatch(/^Its path is longer than the 1,024 characters Cloud takes/); + expect(noteRows[0].technical).toBeNull(); expect(cloudSyncAttentionLabel(summary)).toBe("1 file stays on this device"); // Anything else wrong in the same run keeps its own words. expect( diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 3c356b67c..b251ab2e4 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -17,7 +17,7 @@ import { } from "@zennotes/shared-domain/cloud-auto-sync"; import { formatCloudBytes } from "@zennotes/shared-domain/cloud-bytes"; import { - CLOUD_SYNC_REFUSED_PATH_ROW, + cloudSyncRefusedPathRow, cloudSyncRefusedPathsLabel, cloudSyncRefusedPathsMessage, describeCloudError, @@ -1327,7 +1327,7 @@ export function cloudSyncAttentionItems( items.push({ kind: "refused", path: refused.path, - detail: CLOUD_SYNC_REFUSED_PATH_ROW, + detail: cloudSyncRefusedPathRow(refused.path), conflictCopyPath: null, technical: refused.detail || null, }); diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index f7a2bebbc..23f929369 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -249,7 +249,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'When Cloud reports a problem', body: - 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.\n\nWhen Cloud refuses one file, usually one ZenNotes keeps for itself under `.zennotes`, the rest of the vault keeps syncing, and the run counts as synced: the status bar reads **Synced** as usual (its tooltip adds **1 file stays on this device**), and Settings → Cloud lists the file with Cloud\'s answer under Details. Nothing is lost, and there is nothing to change on your side. Sync offers the file to Cloud again once a day, so it goes up by itself once Cloud takes it; if it keeps coming back, copy the details and choose Report an Issue.' + 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.\n\nWhen Cloud refuses one file, the rest of the vault keeps syncing, and the run counts as synced: the status bar reads **Synced** as usual (its tooltip adds **1 file stays on this device**), and Settings → Cloud lists the file, what to do about it, and Cloud\'s answer under Details. Nothing is lost. Usually it is a file ZenNotes keeps for itself under `.zennotes`, and there is nothing to change on your side: sync offers it to Cloud again once a day, so it goes up by itself once Cloud takes it; if it keeps coming back, copy the details and choose Report an Issue. If it is one of your notes or attachments, Cloud did not accept its name or location, most often because the path is longer than the 1,024 characters Cloud takes: give it a shorter name or move it to a folder nearer the top of the vault, and it syncs under the new path. A note you moved or renamed there stays in Cloud under its old name until then, so your other devices keep it.' }, { title: 'Customize the look: themes vs. overrides', diff --git a/packages/bridge-contract/src/cloud-sync.ts b/packages/bridge-contract/src/cloud-sync.ts index fa41e927d..6ed530e69 100644 --- a/packages/bridge-contract/src/cloud-sync.ts +++ b/packages/bridge-contract/src/cloud-sync.ts @@ -657,8 +657,10 @@ export interface CloudSyncLocalConflict { } /** - * A file Cloud refused for its path (#1004). It stays on this device and the - * rest of the vault keeps syncing; sync offers it to Cloud again once a day. + * A file Cloud refused for its path (#1004), or one whose path is longer than + * Cloud takes. It stays on this device and the rest of the vault keeps + * syncing; sync offers it to Cloud again once a day, and at once under a new + * name. */ export interface CloudSyncRefusedPath { path: string; diff --git a/packages/shared-domain/src/cloud-error-copy.test.ts b/packages/shared-domain/src/cloud-error-copy.test.ts index 7a13e35ea..a51b14c28 100644 --- a/packages/shared-domain/src/cloud-error-copy.test.ts +++ b/packages/shared-domain/src/cloud-error-copy.test.ts @@ -11,6 +11,7 @@ import { CloudIssueError, cloudProtocolError, cloudSignInExchangeError, + cloudSyncRefusedPathRow, cloudSyncRefusedPathsLabel, cloudSyncRefusedPathsMessage, describeCloudError, @@ -198,11 +199,33 @@ describe('describeCloudError', () => { expect(copy.detail).toBe('HTTP 422 · VALIDATION_FAILED · This path is not eligible for vault sync.') }) - it('names a refused file that sync set aside, calmly', () => { - expect(cloudSyncRefusedPathsMessage(['.zennotes/agent-edits/inbox/Note.md.json'])).toBe( - "ZenNotes Cloud didn't accept “Note.md.json”, so it stays on this device while the rest of the vault keeps syncing. Sync offers it to Cloud again once a day. If it keeps happening, copy the details and choose Report an Issue." + it('names a refused file that sync set aside, calmly, and says when it is the person\'s to rename', () => { + const record = '.zennotes/agent-edits/inbox/Note.md.json' + const long = `${'Projects/'.repeat(120)}Plan.md` + expect(cloudSyncRefusedPathsMessage([record])).toBe( + "ZenNotes Cloud didn't accept “Note.md.json”, a file ZenNotes keeps for itself, so it stays on this device while the rest of the vault keeps syncing. There is nothing to change on your side: sync offers it to Cloud again once a day. If it keeps happening, copy the details and choose Report an Issue." + ) + expect(cloudSyncRefusedPathsMessage([long])).toBe( + "“Plan.md” stays on this device because its path is longer than the 1,024 characters ZenNotes Cloud takes. Give it a shorter name or move it to a folder nearer the top of the vault, and it syncs under its new path. The rest of the vault keeps syncing." + ) + expect(cloudSyncRefusedPathsMessage(['Inbox/Plan.md'])).toBe( + "ZenNotes Cloud didn't accept the name or location of “Plan.md”, so it stays on this device while the rest of the vault keeps syncing. Rename it or move it to another folder, and it syncs under its new name. If it keeps happening, copy the details and choose Report an Issue." + ) + expect(cloudSyncRefusedPathsMessage([record, '.zennotes/comments/a.json'])).toMatch( + /^ZenNotes Cloud didn't accept 2 files ZenNotes keeps for itself, so they stay on this device while the rest of the vault keeps syncing\. There is nothing to change on your side/ + ) + expect(cloudSyncRefusedPathsMessage([record, 'Inbox/Plan.md'])).toBe( + "ZenNotes Cloud didn't accept 2 files, so they stay on this device while the rest of the vault keeps syncing. The list below says what to do with each." + ) + expect(cloudSyncRefusedPathRow(record)).toBe( + "A file ZenNotes keeps for itself. Cloud didn't accept it, so it stays on this device, and sync offers it again once a day. There is nothing to change." + ) + expect(cloudSyncRefusedPathRow(long)).toBe( + 'Its path is longer than the 1,024 characters Cloud takes, so it stays on this device. Give it a shorter name or move it to a folder nearer the top of the vault.' + ) + expect(cloudSyncRefusedPathRow('Inbox/Plan.md')).toBe( + "Cloud didn't accept this name or location, so the file stays on this device. Rename it or move it to another folder; Details has Cloud's answer." ) - expect(cloudSyncRefusedPathsMessage(['a.json', 'b.json'])).toMatch(/^ZenNotes Cloud didn't accept 2 files, so they stay on this device/) expect(cloudSyncRefusedPathsLabel(1)).toBe('1 file stays on this device') expect(cloudSyncRefusedPathsLabel(3)).toBe('3 files stay on this device') }) diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts index 65c97266a..e81f0d654 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -11,6 +11,7 @@ * stay reachable as `detail` for a support conversation, never as the message. */ import { CLOUD_VAULT_SUPERSEDED_MESSAGE } from './cloud-vault-availability' +import { CLOUD_SYNC_MAX_PATH_CHARACTERS, cloudSyncPathFitsCloud } from './cloud-sync' export type CloudErrorKind = /** Cloud could not be reached, or took too long: the status row says so. */ @@ -297,16 +298,32 @@ export function describeCloudError(error: unknown, fallback = CLOUD_UNEXPECTED_M return { message: raw.text, detail: null, kind: cloudErrorKindOf(raw.text) } } +const MAX_PATH = new Intl.NumberFormat('en-US').format(CLOUD_SYNC_MAX_PATH_CHARACTERS) + /** * Files Cloud refused for their path, set aside so the rest of the vault - * syncs (#1004). Nothing failed that the person can fix, and nothing is - * lost, so the words are calm; the file names come first because a refused - * file is usually one the app keeps under `.zennotes`, not a note. + * syncs (#1004). Nothing is lost, so the words are calm, and they say whose + * move it is. A file ZenNotes keeps for itself under `.zennotes` is not the + * person's to fix: Cloud learns to take it, and sync offers it again. A note + * or attachment of theirs is: a path longer than Cloud takes needs a shorter + * one, and any other refusal needs a new name or folder. */ export function cloudSyncRefusedPathsMessage(paths: readonly string[]): string { - const subject = paths.length === 1 ? `“${fileName(paths[0])}”` : `${paths.length} files` - const stays = paths.length === 1 ? 'it stays' : 'they stay' - return `ZenNotes Cloud didn't accept ${subject}, so ${stays} on this device while the rest of the vault keeps syncing. Sync offers ${paths.length === 1 ? 'it' : 'them'} to Cloud again once a day. ${REPORT}` + if (paths.length === 1) { + const [path] = paths + const name = `“${fileName(path)}”` + if (isInternalVaultPath(path)) { + return `ZenNotes Cloud didn't accept ${name}, a file ZenNotes keeps for itself, so it stays on this device while the rest of the vault keeps syncing. There is nothing to change on your side: sync offers it to Cloud again once a day. ${REPORT}` + } + if (!cloudSyncPathFitsCloud(path)) { + return `${name} stays on this device because its path is longer than the ${MAX_PATH} characters ZenNotes Cloud takes. Give it a shorter name or move it to a folder nearer the top of the vault, and it syncs under its new path. The rest of the vault keeps syncing.` + } + return `ZenNotes Cloud didn't accept the name or location of ${name}, so it stays on this device while the rest of the vault keeps syncing. Rename it or move it to another folder, and it syncs under its new name. ${REPORT}` + } + if (paths.every(isInternalVaultPath)) { + return `ZenNotes Cloud didn't accept ${paths.length} files ZenNotes keeps for itself, so they stay on this device while the rest of the vault keeps syncing. There is nothing to change on your side: sync offers them to Cloud again once a day. ${REPORT}` + } + return `ZenNotes Cloud didn't accept ${paths.length} files, so they stay on this device while the rest of the vault keeps syncing. The list below says what to do with each.` } /** The few words for the status row: a phone has no hover for the sentence. */ @@ -315,8 +332,19 @@ export function cloudSyncRefusedPathsLabel(count: number): string { } /** One refused file's row in Settings → Cloud, above its Details. */ -export const CLOUD_SYNC_REFUSED_PATH_ROW = - "ZenNotes Cloud didn't accept this file, so it stays on this device. Sync offers it again once a day." +export function cloudSyncRefusedPathRow(path: string): string { + if (isInternalVaultPath(path)) { + return "A file ZenNotes keeps for itself. Cloud didn't accept it, so it stays on this device, and sync offers it again once a day. There is nothing to change." + } + if (!cloudSyncPathFitsCloud(path)) { + return `Its path is longer than the ${MAX_PATH} characters Cloud takes, so it stays on this device. Give it a shorter name or move it to a folder nearer the top of the vault.` + } + return "Cloud didn't accept this name or location, so the file stays on this device. Rename it or move it to another folder; Details has Cloud's answer." +} + +function isInternalVaultPath(path: string): boolean { + return path.toLowerCase().startsWith('.zennotes/') +} /** * A failed sign-in exchange, in sign-in words. Sign-in never retries by itself diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index d1db24cd9..893993923 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -23,7 +23,9 @@ import type { CloudSyncUpsertMutation } from '@zennotes/bridge-contract/cloud-sync' import { + CLOUD_SYNC_MAX_PATH_CHARACTERS, cloudSyncLegacyConflictOriginalPath, + cloudSyncPathFitsCloud, cloudSyncPathKey, isCloudSyncVaultSettingsPath, normalizeCloudSyncPath, @@ -586,11 +588,16 @@ export class CloudSyncCoordinator { if (mutation.type === 'delete') continue const key = cloudSyncPathKey(mutation.path) const entry = refusedPaths[key] - if (!entry) { + if (entry) { + refusedPaths[key] = heldPathRecord(mutation, entry, entry.refused_at, entry.detail, mutationState, runDeletes) + } else if (!cloudSyncPathFitsCloud(mutation.path)) { + // Cloud refuses a path this long; it is held here as if Cloud had. + refusedPaths[key] = heldPathRecord( + mutation, refusals.due[key], Date.now(), cloudSyncPathTooLongDetail(mutation.path), mutationState, runDeletes + ) + } else { writes.push(mutation) - continue } - refusedPaths[key] = heldPathRecord(mutation, entry, entry.refused_at, entry.detail, mutationState, runDeletes) } const keptItems = new Set( Object.values(refusedPaths).flatMap((entry) => Object.keys(entry.kept_items ?? {})) @@ -2399,6 +2406,13 @@ function heldPathRecord( } } +/** Details for a path held because it is longer than Cloud takes. */ +function cloudSyncPathTooLongDetail(path: string): string { + const count = new Intl.NumberFormat('en-US').format(Array.from(path).length) + const limit = new Intl.NumberFormat('en-US').format(CLOUD_SYNC_MAX_PATH_CHARACTERS) + return `Not sent: this path has ${count} characters, and ZenNotes Cloud takes paths of up to ${limit}.` +} + /** A capacity answer that room elsewhere in the vault can clear; a file * over the per-file limit stays too large whatever else is deleted. */ function waitsForRoom(conflict: CloudSyncConflict): boolean { diff --git a/packages/shared-domain/src/cloud-sync-refusal.test.ts b/packages/shared-domain/src/cloud-sync-refusal.test.ts index 34bd674cd..bdaec0bb2 100644 --- a/packages/shared-domain/src/cloud-sync-refusal.test.ts +++ b/packages/shared-domain/src/cloud-sync-refusal.test.ts @@ -330,6 +330,31 @@ describe('a note on its way to a path Cloud refuses', () => { expect(h.states.current()!.items.second.path).toBe(second) }) + it('holds a note moved to a path longer than Cloud takes, without sending it', async () => { + const long = `${'Projects/'.repeat(120)}Plan.md` + const h = harness([text(long, 'body')], { items: { note: tracked('note', NOTE, 'body') } }) + + const first = await h.coordinator.sync() + + expect(h.requests).toEqual([]) + expect(first.refusedPaths).toEqual([ + { path: long, detail: 'Not sent: this path has 1,087 characters, and ZenNotes Cloud takes paths of up to 1,024.' } + ]) + expect(h.states.current()!.items.note.path).toBe(NOTE) + + // Edited there, it is still held, and so is the old copy's delete. + h.local.items = [text(long, 'body edited')] + await h.coordinator.sync() + expect(h.requests).toEqual([]) + expect(h.states.current()!.items.note.path).toBe(NOTE) + + // Renamed to a shorter path, it syncs, and the old copy leaves Cloud. + h.local.items = [text('Projects/Plan.md', 'body edited')] + await h.coordinator.sync() + expect(h.requests.map(kinds)).toEqual([['upsert Projects/Plan.md', 'delete note']]) + expect(h.states.current()!).not.toHaveProperty('refused_paths') + }) + it('never lets a delete go ahead of a refused file sent in a later request', async () => { // A binary upload goes in a request of its own; it used to follow the // request that carried the delete. diff --git a/packages/shared-domain/src/cloud-sync.test.ts b/packages/shared-domain/src/cloud-sync.test.ts index eff8ec11b..4fd265a83 100644 --- a/packages/shared-domain/src/cloud-sync.test.ts +++ b/packages/shared-domain/src/cloud-sync.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { cloudSyncConflictCopyPath, cloudSyncLegacyConflictOriginalPath, + cloudSyncPathFitsCloud, cloudSyncPathKey, isCloudSyncVaultSettingsPath, normalizeCloudSyncPath, @@ -118,6 +119,11 @@ describe('shouldSyncVaultPath', () => { // changed here is added or changed there, in the same words, and the other // way round. Since 2.80 sync sets a refused path aside instead of stopping, // but the file still never reaches Cloud. + // + // The length rows are this side's only: Cloud caps a path in its request + // rules (max:1024 on mutations.*.path and on a direct upload's path), not + // in shouldSync, and this client holds such a path itself rather than + // leave it out of the scan (cloudSyncPathFitsCloud). it.each([ ['vault settings', '.zennotes/vault.json', true], ['comments', '.zennotes/comments/inbox/Note.md.comments.json', true], @@ -126,9 +132,13 @@ describe('shouldSyncVaultPath', () => { ['templates', '.zennotes/templates/Meeting.md', true], ['workflows', '.zennotes/workflows/Daily.workflow', true], ['an assistant edit that is not json', '.zennotes/agent-edits/inbox/Note.md.tmp', false], - ['sync state', '.zennotes/sync/cursor.json', false] + ['sync state', '.zennotes/sync/cursor.json', false], + ['a path of 1,024 characters', `${'abcdefghi/'.repeat(102)}a.md`, true], + ['a path of 1,025 characters', `${'abcdefghi/'.repeat(102)}ab.md`, false], + // Cloud counts characters, not UTF-16 units: 2,045 units here. + ['a path of 1,024 characters outside the BMP', `${'😀'.repeat(1021)}.md`, true] ] as const)('agrees with Cloud about %s', (_name, path, synced) => { - expect(shouldSyncVaultPath(path)).toBe(synced) + expect(shouldSyncVaultPath(path) && cloudSyncPathFitsCloud(path)).toBe(synced) }) it('prunes repository metadata and dependency directories during traversal', () => { diff --git a/packages/shared-domain/src/cloud-sync.ts b/packages/shared-domain/src/cloud-sync.ts index 2b7e46c58..2d2f2ebcd 100644 --- a/packages/shared-domain/src/cloud-sync.ts +++ b/packages/shared-domain/src/cloud-sync.ts @@ -92,6 +92,30 @@ export function shouldSyncVaultPath(path: string): boolean { ) } +/** + * The longest path ZenNotes Cloud takes, in characters as its request rules + * count them (max:1024 on a mutation's path and on a direct upload's). + */ +export const CLOUD_SYNC_MAX_PATH_CHARACTERS = 1024 + +/** + * Whether Cloud takes a path of this length. Cloud counts characters (code + * points), so a path outside the BMP may run past 1,024 UTF-16 units. + * + * This is not part of shouldSyncVaultPath on purpose. A path too long for + * Cloud belongs in sync as much as any other, and leaving it out of the scan + * would make a note renamed to one look deleted: Cloud and every other device + * would lose it. The coordinator holds it the way it holds a path Cloud + * refused, without the request Cloud would refuse, and Settings asks for a + * shorter path. + */ +export function cloudSyncPathFitsCloud(path: string): boolean { + return ( + path.length <= CLOUD_SYNC_MAX_PATH_CHARACTERS || + Array.from(path).length <= CLOUD_SYNC_MAX_PATH_CHARACTERS + ) +} + /** The one file under `.zennotes` that carries user-authored vault settings. */ export const CLOUD_SYNC_VAULT_SETTINGS_PATH = '.zennotes/vault.json' From 228a1d9e1d2a220f2401d90d04613ce0ac50746e Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:44:54 -0500 Subject: [PATCH 15/53] Fix(mcp): assistants stay inside the note folders The MCP server's note and folder tools only checked that a path stayed inside the vault, and only lexically. In a root-mode vault the inbox is the vault root, so .zennotes, the attachements folder and the other buckets were all "subfolders of the inbox", and the sources of moves and deletes were not classified at all. An assistant could move .zennotes/vault.json into a note folder and read it, plant notes in .zennotes, delete the attachments folder or the archive, move .zennotes into the notes, archive, trash or delete vault.json, and with delete_folder on "x/.." delete the whole vault. A symbolic link inside the vault was followed out of it, so read_note returned files from elsewhere on the disk, which zn has always refused. Path resolution now walks the existing components and refuses a link whose target is outside, as zn's SafeJoin does. Every create, move, rename and delete of a note or folder resolves both ends the way the listings classify them, through systemFolderPaths: no dot directory at any depth, and in root mode no internal dir, attachments dir or other bucket reached through a subpath (a case variant counts when it is the same directory on disk). The folder tools refuse a subpath that resolves to the bucket root. read_note reads Markdown notes only, and toggle_task checks the note before writing, as the other edits do. The zn CLI shares this code and gets the same rules. rename_folder now rewrites the vault.json settings that name the folder, the Tasks folder lists and the folder icons and colors, inside the same all-or-nothing move as its comments and creation dates, as desktop main does. Renaming a folder that is not there is an error instead of a silent success. zn mcp gets the same rules (ZenNotes/tui). Symlinks that stay inside the vault, case-variant note paths and absolute paths in errors are left for scoped access (ZenNotes/tui#21). --- apps/desktop/src/mcp/server.test.ts | 84 ++++- .../src/mcp/vault-ops-path-safety.test.ts | 282 +++++++++++++++ apps/desktop/src/mcp/vault-ops.ts | 335 ++++++++++++++---- 3 files changed, 630 insertions(+), 71 deletions(-) create mode 100644 apps/desktop/src/mcp/vault-ops-path-safety.test.ts diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index a5d8a0fdf..1946a374a 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -6,7 +6,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { parse } from '../cli/args' -import type { VaultBackend } from '../cli/backend' +import { createBackend, type VaultBackend } from '../cli/backend' import { resolveTarget, type VaultTarget } from '../cli/vault-target' import { RemoteRequestError } from '../main/remote/connection' import { @@ -178,6 +178,88 @@ describe('list_tasks', () => { }) }) +// The ZenNotes/tui#21 probe cases, through the real tools on a root-mode +// vault, where the inbox is the vault root and the internal dir, the +// attachments dir and the other buckets are all one subpath away. Mirrors +// TestMCPToolsStayInsideTheNoteFolders in ZenNotes/tui's internal/mcp. +describe('tools stay inside the note folders (tui#21 Phase 0)', () => { + let base: string + let root: string + + beforeEach(async () => { + base = await fsp.mkdtemp(path.join(os.tmpdir(), 'zennotes-mcp-tools-safety-')) + root = path.join(base, 'vault') + vi.stubEnv('ZENNOTES_CONFIG_DIR', path.join(base, 'config')) + const files: Record<string, string> = { + '.zennotes/vault.json': '{"primaryNotesLocation":"root"}', + 'Projects/Plan.md': '# Plan\n', + 'Projects/notes.txt': 'plain text\n', + 'attachements/img.png': 'PNG', + 'archive/Old.md': '# Old\n', + '../outside/secret.md': '# OUTSIDE SECRET\n' + } + for (const [rel, body] of Object.entries(files)) { + await fsp.mkdir(path.dirname(path.join(root, rel)), { recursive: true }) + await fsp.writeFile(path.join(root, rel), body) + } + await fsp.symlink(path.join(base, 'outside'), path.join(root, 'Projects', 'link')) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await fsp.rm(base, { recursive: true, force: true }) + }) + + it('refuses what reaches past them and keeps the vault whole', async () => { + const local = createBackend({ kind: 'local', root }) + const refused = async (name: string, args: Record<string, unknown>, text: string) => + await expect(callTool(name, args, local)).rejects.toThrow(text) + await refused( + 'move_note', + { path: '.zennotes/vault.json', targetFolder: 'inbox', targetSubpath: 'Projects' }, + 'Note not in a known folder' + ) + await refused('read_note', { path: 'Projects/vault.json' }, 'Not a Markdown note: Projects/vault.json') + await refused( + 'create_note', + { folder: 'inbox', subpath: '.zennotes', title: 'planted' }, + 'Not a note folder: .zennotes' + ) + await refused( + 'delete_folder', + { folder: 'inbox', subpath: 'attachements', confirm: true }, + 'Not a note folder: attachements' + ) + await refused( + 'delete_folder', + { folder: 'inbox', subpath: 'archive', confirm: true }, + 'Not a note folder: archive' + ) + await refused( + 'delete_folder', + { folder: 'inbox', subpath: 'x/..', confirm: true }, + 'Cannot delete the top-level folder' + ) + await refused( + 'rename_folder', + { folder: 'inbox', oldSubpath: '.zennotes', newSubpath: 'Projects/internals' }, + 'Not a note folder: .zennotes' + ) + await refused('delete_note', { path: '.zennotes/vault.json', confirm: true }, 'Note not in a known folder') + await refused('archive_note', { path: '.zennotes/vault.json' }, 'Note not in a known folder') + await refused('read_note', { path: 'Projects/link/secret.md' }, 'Path escapes vault') + await refused('read_note', { path: 'Projects/notes.txt' }, 'Not a Markdown note: Projects/notes.txt') + + for (const rel of ['.zennotes/vault.json', 'Projects/Plan.md', 'attachements/img.png', 'archive/Old.md']) { + await expect(fsp.stat(path.join(root, rel))).resolves.toBeTruthy() + } + await expect(fsp.stat(path.join(root, '.zennotes', 'planted.md'))).rejects.toThrow(/ENOENT/) + expect(await callTool('read_note', { path: 'Projects/Plan.md' }, local)).toMatchObject({ + body: '# Plan\n' + }) + }) +}) + describe('describeToolError', () => { const rejected = () => new RemoteRequestError('The ZenNotes server rejected the connection.', 401) const server = (authToken: string | null): VaultTarget => ({ diff --git a/apps/desktop/src/mcp/vault-ops-path-safety.test.ts b/apps/desktop/src/mcp/vault-ops-path-safety.test.ts new file mode 100644 index 000000000..bf7d079fb --- /dev/null +++ b/apps/desktop/src/mcp/vault-ops-path-safety.test.ts @@ -0,0 +1,282 @@ +import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + appendToNote, + archiveNote, + createFolder, + createNote, + deleteFolder, + deleteNote, + duplicateNote, + moveNote, + moveToTrash, + readNote, + renameFolder, + renameNote, + restoreFromTrash, + toggleTask, + writeNote +} from './vault-ops' + +// The probe cases behind ZenNotes/tui#21's Phase 0: every tool that creates, +// moves, renames or deletes a note or folder stays inside the note folders. +// Run on a root-mode vault, where the inbox is the vault root and the internal +// dir, the attachments dir and the other buckets are all one subpath away. +// Mirrors internal/vault/path_safety_test.go in ZenNotes/tui. + +let base: string +let root: string +let configDir: string +const previousConfigDir = process.env.ZENNOTES_CONFIG_DIR + +const seed = async (files: Record<string, string>): Promise<void> => { + for (const [rel, body] of Object.entries(files)) { + await mkdir(path.dirname(path.join(root, rel)), { recursive: true }) + await writeFile(path.join(root, rel), body) + } +} + +const exists = (rel: string): Promise<boolean> => + lstat(path.join(root, rel)).then( + () => true, + () => false + ) + +const vaultJson = async (): Promise<Record<string, unknown>> => + JSON.parse(await readFile(path.join(root, '.zennotes', 'vault.json'), 'utf8')) + +beforeEach(async () => { + base = await mkdtemp(path.join(os.tmpdir(), 'zennotes-mcp-path-safety-')) + root = path.join(base, 'vault') + configDir = path.join(base, 'config') + await mkdir(configDir, { recursive: true }) + process.env.ZENNOTES_CONFIG_DIR = configDir + await seed({ + '.zennotes/vault.json': JSON.stringify({ + primaryNotesLocation: 'root', + folderIcons: { + 'inbox:Projects': 'star', + 'inbox:Projects/Old': 'box', + 'inbox:ProjectsX': 'dot', + 'quick:Projects': 'bolt' + }, + folderColors: { 'inbox:Projects': 'red' }, + tasks: { excludedFolders: ['Projects/Old'] }, + favorites: ['Projects/Plan.md'] + }), + '.zennotes/templates/Daily.md': '- [ ] stand up\n', + 'Projects/Plan.md': '# Plan\n', + 'Projects/Old/Before.md': '# Before\n', + 'Projects/notes.txt': 'plain text\n', + 'Journal/Diary.md': '# Diary\n', + 'attachements/img.png': 'PNG', + 'archive/Old.md': '# Old\n', + 'quick/Q.md': '# Q\n' + }) +}) + +afterEach(async () => { + if (previousConfigDir === undefined) delete process.env.ZENNOTES_CONFIG_DIR + else process.env.ZENNOTES_CONFIG_DIR = previousConfigDir + await rm(base, { recursive: true, force: true }) +}) + +describe('MCP note tools stay inside the note folders (tui#21 Phase 0)', () => { + // G1 and the rest: the source must be a note. Unchecked, move_note carried + // vault.json into a note folder for read_note to read, and archive_note, + // move_to_trash and delete_note took it away. + it('refuses a file outside the note folders as the source of every note tool', async () => { + const internal = '.zennotes/vault.json' + const refused = `Note not in a known folder: ${internal}` + await expect(moveNote(root, internal, 'inbox', 'Projects')).rejects.toThrow(refused) + await expect(archiveNote(root, internal)).rejects.toThrow(refused) + await expect(moveToTrash(root, internal)).rejects.toThrow(refused) + await expect(restoreFromTrash(root, internal)).rejects.toThrow(refused) + await expect(renameNote(root, internal, 'settings')).rejects.toThrow(refused) + await expect(duplicateNote(root, internal)).rejects.toThrow(refused) + await expect(appendToNote(root, internal, 'x')).rejects.toThrow(refused) + await expect(readNote(root, internal)).rejects.toThrow(refused) + await expect(deleteNote(root, internal)).rejects.toThrow(refused) + expect(await exists(internal)).toBe(true) + expect(await exists('Projects/vault.json')).toBe(false) + expect(await exists('archive/vault.md')).toBe(false) + expect(await exists('trash/vault.md')).toBe(false) + + // toggle_task used to write first and check after. + const template = '.zennotes/templates/Daily.md' + await expect(toggleTask(root, `${template}#0`)).rejects.toThrow( + `Note not in a known folder: ${template}` + ) + expect(await readFile(path.join(root, template), 'utf8')).toBe('- [ ] stand up\n') + + // A dot directory below the top is no note folder either: the listings + // never descend into one. + await expect(writeNote(root, 'Projects/.hidden/x.md', 'x')).rejects.toThrow( + 'Note not in a known folder: Projects/.hidden/x.md' + ) + expect(await exists('Projects/.hidden')).toBe(false) + }) + + // G2 and friends: what a create or a move writes lands in a note folder of + // the bucket it names. + it('lands creates and moves only in note folders', async () => { + for (const sub of [ + '.zennotes', + 'attachements', + 'archive/Projects', + 'trash', + 'Projects/.hidden', + 'Projects/../.zennotes' + ]) { + const refused = `Not a note folder: ${sub}` + await expect(createNote(root, 'inbox', 'planted', sub)).rejects.toThrow(refused) + await expect(createFolder(root, 'inbox', sub)).rejects.toThrow(refused) + await expect(moveNote(root, 'Projects/Plan.md', 'inbox', sub)).rejects.toThrow(refused) + } + expect(await exists('.zennotes/planted.md')).toBe(false) + expect(await exists('attachements/planted.md')).toBe(false) + expect(await exists('trash/planted.md')).toBe(false) + expect(await exists('Projects/Plan.md')).toBe(true) + await expect(createFolder(root, 'inbox', 'x/..')).rejects.toThrow('Folder name is required') + + // Ordinary subfolders of either bucket still take notes. + expect((await createNote(root, 'inbox', 'Fine', 'Projects/Sub')).path).toBe( + 'Projects/Sub/Fine.md' + ) + expect((await createNote(root, 'quick', 'Fine', 'Work')).path).toBe('quick/Work/Fine.md') + }) + + // G3, G4 and the bucket root: `x/..` resolved to the root, and in root mode + // delete_folder then removed the whole vault. + it('refuses folder operations on system folders and the bucket root', async () => { + await expect(deleteFolder(root, 'inbox', 'attachements')).rejects.toThrow( + 'Not a note folder: attachements' + ) + await expect(deleteFolder(root, 'inbox', 'archive')).rejects.toThrow( + 'Not a note folder: archive' + ) + await expect(deleteFolder(root, 'inbox', '.zennotes')).rejects.toThrow( + 'Not a note folder: .zennotes' + ) + await expect(deleteFolder(root, 'inbox', 'x/..')).rejects.toThrow( + 'Cannot delete the top-level folder' + ) + await expect(deleteFolder(root, 'archive', 'x/..')).rejects.toThrow( + 'Cannot delete the top-level folder' + ) + await expect(renameFolder(root, 'inbox', '.zennotes', 'Projects/internals')).rejects.toThrow( + 'Not a note folder: .zennotes' + ) + await expect(renameFolder(root, 'inbox', 'Projects', '.zennotes/x')).rejects.toThrow( + 'Not a note folder: .zennotes/x' + ) + await expect(renameFolder(root, 'inbox', 'Projects', 'x/..')).rejects.toThrow( + 'Both old and new folder paths are required' + ) + for (const rel of ['.zennotes/vault.json', 'attachements/img.png', 'archive/Old.md', 'Projects/Plan.md']) { + expect(await exists(rel)).toBe(true) + } + expect(await exists('Projects/internals')).toBe(false) + + // A case variant of the attachments dir is that same directory on macOS + // and Windows, and is refused there; elsewhere it is a different folder. + if (await exists('Attachements')) { + await expect(deleteFolder(root, 'inbox', 'Attachements')).rejects.toThrow( + 'Not a note folder: Attachements' + ) + expect(await exists('attachements/img.png')).toBe(true) + } + + await deleteFolder(root, 'inbox', 'Journal') + expect(await exists('Journal')).toBe(false) + }) + + // Classification follows the vault's systemFolderPaths, not literal names. + it('follows remapped system folders', async () => { + await seed({ + '.zennotes/vault.json': JSON.stringify({ + primaryNotesLocation: 'root', + systemFolderPaths: { archive: '99 - Old' } + }), + '99 - Old/Gone.md': '# Gone\n' + }) + await expect(deleteFolder(root, 'inbox', '99 - Old')).rejects.toThrow( + 'Not a note folder: 99 - Old' + ) + expect(await exists('99 - Old/Gone.md')).toBe(true) + const created = await createNote(root, 'inbox', 'Note', 'archive') + expect(created).toMatchObject({ path: 'archive/Note.md', folder: 'inbox' }) + await deleteFolder(root, 'inbox', 'archive') + expect(await exists('archive')).toBe(false) + }) + + it('holds the same rules one level down in inbox mode', async () => { + await seed({ + '.zennotes/vault.json': JSON.stringify({ primaryNotesLocation: 'inbox' }), + 'inbox/Work/Plan.md': '# Plan\n' + }) + await expect(createNote(root, 'inbox', 'planted', '.zennotes')).rejects.toThrow( + 'Not a note folder: .zennotes' + ) + // zn clamps a leading `..` at the bucket root and then refuses the dot + // directory; this engine refuses the escape itself. Either way nothing lands. + await expect(createNote(root, 'quick', 'planted', '../.zennotes')).rejects.toThrow( + 'Path escapes vault: ../.zennotes' + ) + await expect(deleteFolder(root, 'inbox', '..')).rejects.toThrow('Path escapes vault: ..') + expect(await exists('inbox/Work/Plan.md')).toBe(true) + expect((await moveNote(root, 'inbox/Work/Plan.md', 'archive', 'Work')).path).toBe( + 'archive/Work/Plan.md' + ) + }) +}) + +describe('read_note reads only Markdown notes inside the vault', () => { + it('refuses a file that is not a note', async () => { + await expect(readNote(root, 'Projects/notes.txt')).rejects.toThrow( + 'Not a Markdown note: Projects/notes.txt' + ) + expect((await readNote(root, 'Projects/Plan.md')).body).toBe('# Plan\n') + }) + + // G5: path resolution was lexical, so a link inside the vault pointing out + // of it was followed. zn's SafeJoin resolves links and refuses. + it('refuses to follow a symbolic link out of the vault', async () => { + const outside = path.join(base, 'outside') + await mkdir(outside, { recursive: true }) + await writeFile(path.join(outside, 'secret.md'), '# OUTSIDE SECRET\n') + await symlink(outside, path.join(root, 'Projects', 'link')) + await expect(readNote(root, 'Projects/link/secret.md')).rejects.toThrow( + 'Path escapes vault: Projects/link/secret.md' + ) + await expect(createNote(root, 'inbox', 'planted', 'Projects/link')).rejects.toThrow( + 'Path escapes vault: Projects/link' + ) + expect(await exists('../outside/planted.md')).toBe(false) + }) +}) + +describe('rename_folder carries the vault.json settings that name the folder', () => { + it('rewrites the folder icons, colors and Tasks lists, keeping every other key', async () => { + expect(await renameFolder(root, 'inbox', 'Projects', 'Work')).toBe('Work') + const settings = await vaultJson() + expect(settings.folderIcons).toEqual({ + 'inbox:Work': 'star', + 'inbox:Work/Old': 'box', + 'inbox:ProjectsX': 'dot', + 'quick:Projects': 'bolt' + }) + expect(settings.folderColors).toEqual({ 'inbox:Work': 'red' }) + expect(settings.tasks).toEqual({ excludedFolders: ['Work/Old'] }) + expect(settings.favorites).toEqual(['Projects/Plan.md']) + expect(settings.primaryNotesLocation).toBe('root') + }) + + it('refuses a folder that is not there and leaves vault.json alone', async () => { + const before = await readFile(path.join(root, '.zennotes', 'vault.json'), 'utf8') + await expect(renameFolder(root, 'inbox', 'Nope', 'Work')).rejects.toThrow(/ENOENT/) + expect(await readFile(path.join(root, '.zennotes', 'vault.json'), 'utf8')).toBe(before) + }) +}) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 07473f4dd..55957c024 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -2,6 +2,7 @@ import { readNoteCreatedAt, prepareNoteCreation, removeNoteCreation, noteMetadat 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 { atomicWriteTarget, renameWithRetry } from '../main/atomic-write' import { readSavedServerToken as readZnSavedServerToken } from '../main/remote/zn-credentials' /** * Vault operations used by the MCP server. Mirrors the filesystem @@ -25,7 +26,8 @@ import { inlineTaskField, noteTasksMode, type NoteTasksMode } from '@shared/task import { isPathExcludedFromTasks, normalizeTasksExcludedFolders, - normalizeTasksIncludedFolders + normalizeTasksIncludedFolders, + rewriteTasksFoldersForRename } from '@shared/tasks-excluded-folders' import { DEFAULT_TYPST_PREAMBLE_FOLDER, @@ -614,29 +616,127 @@ function toPosix(p: string): string { return p.split(path.sep).join('/') } -function resolveSafe(root: string, rel: string): string { - const abs = path.resolve(root, rel) +/** + * Resolve a vault-relative path under `root`, refusing anything that lands + * outside it: through `..`, or through a symbolic link on the way whose target + * is outside. Components that do not exist yet are left alone (they cannot be + * links until they are created). This used to be lexical only, so read_note + * through `Projects/link -> /elsewhere` returned a file from outside the + * vault, which zn's SafeJoin (internal/vault/paths.go in ZenNotes/tui) has + * always refused; the walk below mirrors it. + */ +async function resolveSafe(root: string, rel: string): Promise<string> { const rootAbs = path.resolve(root) + const abs = path.resolve(rootAbs, rel) if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) { throw new Error(`Path escapes vault: ${rel}`) } + if (abs === rootAbs) return abs + let rootReal: string + try { + rootReal = await fs.realpath(rootAbs) + } catch { + return abs + } + let walk = rootAbs + for (const part of path.relative(rootAbs, abs).split(path.sep)) { + walk = path.join(walk, part) + let info + try { + info = await fs.lstat(walk) + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR') return abs + throw err + } + if (!info.isSymbolicLink()) continue + const target = await fs.realpath(walk) + const back = path.relative(rootReal, target) + if (back === '..' || back.startsWith(`..${path.sep}`) || path.isAbsolute(back)) { + throw new Error(`Path escapes vault: ${rel}`) + } + walk = target + } return abs } +/** Whether a top-level name is one the root walk hides: the internal dir, an + * attachments dir or another bucket's directory. A different spelling still + * is one when it names the same directory on disk, as a case variant does on + * macOS and Windows. Mirrors isHiddenTopName in ZenNotes/tui. */ +async function isHiddenTopName( + root: string, + name: string, + paths: SystemFolderPathsMap +): Promise<boolean> { + const hidden = hiddenRootNamesWith(paths) + if (hidden.has(name)) return true + const variants = [...hidden].filter((h) => h.toLowerCase() === name.toLowerCase()) + if (variants.length === 0) return false + const identity = (p: string) => fs.lstat(p, { bigint: true }).catch(() => null) + const self = await identity(path.join(root, name)) + if (!self) return false + for (const variant of variants) { + const other = await identity(path.join(root, variant)) + if (other && isSameFile(self, other)) return true + } + return false +} + +/** The bucket a note (`isDir` false) or folder (`isDir` true) at `abs` + * belongs to as the listings see it, or null. They never descend into a dot + * directory at any depth, nor show the internal dir, an attachments dir or + * another bucket's directory as part of the inbox, so neither does this. */ +async function classifyPath(root: string, abs: string, isDir: boolean): Promise<NoteFolder | null> { + const rel = toPosix(path.relative(root, abs)) + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null + const segments = rel.split('/') + const dirs = isDir ? segments : segments.slice(0, -1) + if (dirs.some((segment) => segment.startsWith('.'))) return null + const folder = await folderOf(root, abs) + if (folder !== 'inbox') return folder + return (await isHiddenTopName(root, segments[0], await readSystemFolderPaths(root))) + ? null + : folder +} + /** - * Resolve a note an edit is about to write, and refuse anything outside the - * note folders BEFORE a byte lands. The edits used to write first and check - * after, so `write_note` on `.zennotes/...` created the file and only then - * reported an error: an assistant could plant comments, creation dates or - * agent edit records, the files that say what a note used to be. + * Resolve a note a tool reads, edits, moves or deletes, and refuse anything + * outside the note folders BEFORE it is touched. The edits used to write first + * and check after, so `write_note` on `.zennotes/...` created the file and + * only then reported an error; the moves and delete_note never checked, so + * move_note carried `.zennotes/vault.json` into a note folder for read_note to + * read, and archive_note, move_to_trash and delete_note took it away. */ -async function noteForWrite(root: string, rel: string): Promise<{ abs: string; folder: NoteFolder }> { - const abs = resolveSafe(root, rel) - const folder = await folderOf(root, abs) +async function resolveNote(root: string, rel: string): Promise<{ abs: string; folder: NoteFolder }> { + const abs = await resolveSafe(root, rel) + const folder = await classifyPath(root, abs, false) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return { abs, folder } } +/** + * The directory a create, move, rename or delete names by bucket and subpath. + * It must be a folder the listings show in that bucket: a subpath through a + * dot directory, or one that reaches the internal dir, an attachments dir or + * another bucket (in root mode the inbox IS the vault root, so all of them sit + * one level down) is refused. A subpath that resolves to nothing, `x/..` + * included, is the bucket root itself, which callers that need a subfolder + * refuse: delete_folder on `x/..` once removed the whole vault in root mode. + * Mirrors bucketDir in ZenNotes/tui. + */ +async function bucketDir(root: string, folder: NoteFolder, subpath: string): Promise<string> { + const base = path.resolve(await folderRoot(root, folder)) + const clean = subpath.replace(/^\/+|\/+$/g, '') + if (!clean) return base + const abs = await resolveSafe(base, clean) + if (abs === base) return base + if ((await classifyPath(root, abs, true)) !== folder) { + throw new Error(`Not a note folder: ${clean}`) + } + return abs +} + async function folderOf(root: string, abs: string): Promise<NoteFolder | null> { const rel = toPosix(path.relative(root, abs)) if (!rel || rel.startsWith('..')) return null @@ -919,16 +1019,17 @@ export async function listAssets(root: string): Promise< /* ---------- Read / write / create ------------------------------------ */ export async function readNote(root: string, rel: string): Promise<NoteContent> { - const abs = resolveSafe(root, rel) - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) + const { abs, folder } = await resolveNote(root, rel) + // A note is a Markdown file. Anything else in a note folder (a root-mode + // vault that is also a code checkout holds plenty) is not one to read. + if (path.extname(abs).toLowerCase() !== '.md') throw new Error(`Not a Markdown note: ${rel}`) const body = await fs.readFile(abs, 'utf8') const meta = await readMeta(root, abs, folder) return { ...meta, body } } export async function writeNote(root: string, rel: string, body: string): Promise<NoteMeta> { - const { abs, folder } = await noteForWrite(root, rel) + const { abs, folder } = await resolveNote(root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, body, 'utf8') @@ -941,7 +1042,7 @@ export async function writeNote(root: string, rel: string, body: string): Promis * Absence must be null and every other failure must throw: the database * composition reads null as "no schema yet" and infers one over it. */ export async function readVaultFileTextOrNull(root: string, rel: string): Promise<string | null> { - const abs = resolveSafe(root, rel) + const abs = await resolveSafe(root, rel) try { return await fs.readFile(abs, 'utf8') } catch (err) { @@ -953,7 +1054,7 @@ export async function readVaultFileTextOrNull(root: string, rel: string): Promis /** Write any vault file's text, creating parent directories. */ export async function writeVaultFileText(root: string, rel: string, text: string): Promise<void> { - const abs = resolveSafe(root, rel) + const abs = await resolveSafe(root, rel) await fs.mkdir(path.dirname(abs), { recursive: true }) await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, text, 'utf8') @@ -1007,11 +1108,7 @@ export async function createNote( ): Promise<NoteMeta> { if (folder === 'trash') throw new Error('Refusing to create a note directly in trash/') const base = sanitizeTitle(title ?? 'Untitled') - const clean = subpath.replace(/^\/+|\/+$/g, '') - const folderAbs = await folderRoot(root, folder) - const dir = clean - ? resolveSafe(folderAbs, clean) - : folderAbs + const dir = await bucketDir(root, folder, subpath) await fs.mkdir(dir, { recursive: true }) const finalTitle = await uniqueTitle(dir, base) const abs = path.join(dir, `${finalTitle}.md`) @@ -1024,9 +1121,7 @@ export async function createNote( } export async function renameNote(root: string, rel: string, nextTitle: string): Promise<NoteMeta> { - const abs = resolveSafe(root, rel) - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) + const { abs, folder } = await resolveNote(root, rel) const dir = path.dirname(abs) const trimmed = sanitizeTitle(nextTitle) const target = path.join(dir, `${trimmed}.md`) @@ -1094,11 +1189,9 @@ async function moveBetweenFolders( rel: string, target: NoteFolder ): Promise<NoteMeta> { - const abs = resolveSafe(root, rel) + const { abs } = await resolveNote(root, rel) const filename = path.basename(abs) - const subpath = await folderSubpathOf(root, abs) - const targetRoot = await folderRoot(root, target) - const destDir = subpath ? resolveSafe(targetRoot, subpath) : targetRoot + const destDir = await bucketDir(root, target, await folderSubpathOf(root, abs)) await fs.mkdir(destDir, { recursive: true }) const baseTitle = path.basename(filename, path.extname(filename)) const finalTitle = await uniqueTitle(destDir, baseTitle) @@ -1120,16 +1213,10 @@ export async function moveNote( targetFolder: NoteFolder, targetSubpath: string ): Promise<NoteMeta> { - const oldAbs = resolveSafe(root, oldRel) + const { abs: oldAbs, folder } = await resolveNote(root, oldRel) const filename = path.basename(oldAbs) - const cleanSub = targetSubpath.replace(/^\/+|\/+$/g, '') - const folderAbs = await folderRoot(root, targetFolder) - const destDir = cleanSub ? resolveSafe(folderAbs, cleanSub) : folderAbs - if (path.dirname(oldAbs) === destDir) { - const folder = await folderOf(root, oldAbs) - if (!folder) throw new Error(`Note not in a known folder: ${oldRel}`) - return await readMeta(root, oldAbs, folder) - } + const destDir = await bucketDir(root, targetFolder, targetSubpath) + if (path.dirname(oldAbs) === destDir) return await readMeta(root, oldAbs, folder) await fs.mkdir(destDir, { recursive: true }) const ext = path.extname(filename) const baseTitle = path.basename(filename, ext) @@ -1140,9 +1227,7 @@ export async function moveNote( } export async function duplicateNote(root: string, rel: string): Promise<NoteMeta> { - const abs = resolveSafe(root, rel) - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) + const { abs, folder } = await resolveNote(root, rel) const dir = path.dirname(abs) const ext = path.extname(abs) const baseTitle = path.basename(abs, ext) @@ -1155,7 +1240,7 @@ export async function duplicateNote(root: string, rel: string): Promise<NoteMeta } export async function deleteNote(root: string, rel: string): Promise<void> { - const abs = resolveSafe(root, rel) + const { abs } = await resolveNote(root, rel) const notePath = toPosix(path.relative(root, abs)) await fs.rm(abs, { force: true }) // A note's discussion goes with it, as on the desktop: left behind, it would @@ -1171,7 +1256,7 @@ export async function emptyTrash(root: string): Promise<void> { const entries = await fs.readdir(trashDir) await Promise.all(entries.map((e) => fs.rm(path.join(trashDir, e), { recursive: true, force: true }))) const trashRel = toPosix(path.relative(root, trashDir)) - await fs.rm(resolveSafe(noteCommentsRoot(root), trashRel), { recursive: true, force: true }) + await fs.rm(await resolveSafe(noteCommentsRoot(root), trashRel), { recursive: true, force: true }) await removeNoteCreation(root, trashRel, true) await removeAgentEditRecord(root, trashRel, true) } catch { @@ -1184,13 +1269,103 @@ export async function createFolder( topFolder: NoteFolder, subpath: string ): Promise<void> { - const clean = subpath.replace(/^\/+|\/+$/g, '') - if (!clean) throw new Error('Folder name is required') - const folderAbs = await folderRoot(root, topFolder) - const abs = resolveSafe(folderAbs, clean) + const abs = await bucketDir(root, topFolder, subpath) + if (abs === path.resolve(await folderRoot(root, topFolder))) { + throw new Error('Folder name is required') + } await fs.mkdir(abs, { recursive: true }) } +/** Repoint a `<bucket>:<subpath>` keyed map (the folder icons and colors) at + * a renamed folder and everything inside it, or null when nothing moves. + * Mirrors rewriteFolderIconsForRename in main/vault.ts; an entry that moves + * wins over a stale one already at its new key. */ +function rewriteFolderKeyedMap( + map: Record<string, unknown>, + folder: NoteFolder, + oldSub: string, + newSub: string +): Record<string, unknown> | null { + const exact = `${folder}:${oldSub}` + const kept: Record<string, unknown> = {} + const moved: Record<string, unknown> = {} + for (const [key, value] of Object.entries(map)) { + if (key === exact || key.startsWith(`${exact}/`)) { + moved[`${folder}:${newSub}${key.slice(exact.length)}`] = value + } else { + kept[key] = value + } + } + return Object.keys(moved).length > 0 ? { ...kept, ...moved } : null +} + +const isRecord = (value: unknown): value is Record<string, unknown> => + !!value && typeof value === 'object' && !Array.isArray(value) + +/** + * Keep vault.json's path-keyed settings naming the same folders after a + * rename or move, as desktop main, the server and zn do: the Tasks folder + * lists, which hold vault-relative paths (a renamed Task folder left behind + * would empty Tasks, #962), and the folder icons and colors, keyed by bucket + * and subpath. Every other key is written back as read, and vault.json is + * written only when an entry moves. + */ +async function rewriteSettingsForFolderRename( + root: string, + folder: NoteFolder, + oldSub: string, + newSub: string, + oldRel: string, + newRel: string +): Promise<void> { + const settingsPath = path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE) + let raw: unknown + try { + raw = JSON.parse(await fs.readFile(settingsPath, 'utf8')) + } catch { + return + } + if (!isRecord(raw)) return + let changed = false + const tasks = raw['tasks'] + if (isRecord(tasks)) { + const lists = { + ...tasks, + ...('excludedFolders' in tasks + ? { excludedFolders: normalizeTasksExcludedFolders(tasks['excludedFolders']) } + : {}), + ...('includedFolders' in tasks + ? { includedFolders: normalizeTasksIncludedFolders(tasks['includedFolders']) } + : {}) + } + const inside = (list: string[] | undefined): boolean => + (list ?? []).some((entry) => entry === oldRel || entry.startsWith(`${oldRel}/`)) + if (inside(lists.excludedFolders) || inside(lists.includedFolders)) { + raw['tasks'] = rewriteTasksFoldersForRename(lists, oldRel, newRel) + changed = true + } + } + for (const key of ['folderIcons', 'folderColors']) { + const map = raw[key] + const next = isRecord(map) ? rewriteFolderKeyedMap(map, folder, oldSub, newSub) : null + if (next) { + raw[key] = next + changed = true + } + } + if (!changed) return + // Through a temporary file, so the app never reads a half-written vault.json. + const target = await atomicWriteTarget(settingsPath) + const tmp = `${target}.${process.pid}.${Date.now()}.tmp` + await fs.writeFile(tmp, JSON.stringify(raw, null, 2), { encoding: 'utf8', flag: 'wx' }) + try { + await renameWithRetry(tmp, target) + } catch (err) { + await fs.rm(tmp, { force: true }) + throw err + } +} + export async function renameFolder( root: string, topFolder: NoteFolder, @@ -1200,26 +1375,46 @@ export async function renameFolder( const oldClean = oldSubpath.replace(/^\/+|\/+$/g, '') const newClean = newSubpath.replace(/^\/+|\/+$/g, '') if (!oldClean || !newClean) throw new Error('Both old and new folder paths are required') - const folderAbs = await folderRoot(root, topFolder) - const oldAbs = resolveSafe(folderAbs, oldClean) - const newAbs = resolveSafe(folderAbs, newClean) + // Both ends are note folders of this bucket and neither is its root: the + // internal dir, an attachments dir or another bucket must not be moved into + // notes, nor a note folder into them. + const base = path.resolve(await folderRoot(root, topFolder)) + const oldAbs = await bucketDir(root, topFolder, oldClean) + const newAbs = await bucketDir(root, topFolder, newClean) + if (oldAbs === base || newAbs === base) { + throw new Error('Both old and new folder paths are required') + } if (newAbs === oldAbs) return newClean if ((newAbs + path.sep).startsWith(oldAbs + path.sep)) { throw new Error('Cannot move a folder into itself') } + // A missing folder is an error, as it is in the app and in zn, not a rename + // of nothing that still rewrites vault.json. + await fs.stat(oldAbs) const oldRel = toPosix(path.relative(root, oldAbs)) const newRel = toPosix(path.relative(root, newAbs)) // The folder's comments, creation dates and agent edit records move as one - // with it, the way the desktop's renameFolderTrees does; a failure puts all - // of them back. + // with it, the way the desktop's renameFolderTrees does, and so do the + // vault.json settings that name it; a failure puts all of them back. await relocateFolderTrees( [ [oldAbs, newAbs], - [resolveSafe(noteCommentsRoot(root), oldRel), resolveSafe(noteCommentsRoot(root), newRel)], + [ + await resolveSafe(noteCommentsRoot(root), oldRel), + await resolveSafe(noteCommentsRoot(root), newRel) + ], [await noteMetadataPath(root, oldRel, true), await noteMetadataPath(root, newRel, true)], [agentEditFolderPath(root, oldRel), agentEditFolderPath(root, newRel)] ], - async () => {} + () => + rewriteSettingsForFolderRename( + root, + topFolder, + toPosix(path.relative(base, oldAbs)), + toPosix(path.relative(base, newAbs)), + oldRel, + newRel + ) ) return newClean } @@ -1229,13 +1424,15 @@ export async function deleteFolder( topFolder: NoteFolder, subpath: string ): Promise<void> { - const clean = subpath.replace(/^\/+|\/+$/g, '') - if (!clean) throw new Error('Cannot delete the top-level folder') - const folderAbs = await folderRoot(root, topFolder) - const abs = resolveSafe(folderAbs, clean) + // `x/..` resolves to the bucket root, which in root mode is the whole vault: + // checked on the resolved directory, not the string. + const abs = await bucketDir(root, topFolder, subpath) + if (abs === path.resolve(await folderRoot(root, topFolder))) { + throw new Error('Cannot delete the top-level folder') + } const rel = toPosix(path.relative(root, abs)) await fs.rm(abs, { recursive: true, force: true }) - await fs.rm(resolveSafe(noteCommentsRoot(root), rel), { recursive: true, force: true }) + await fs.rm(await resolveSafe(noteCommentsRoot(root), rel), { recursive: true, force: true }) await removeNoteCreation(root, rel, true) await removeAgentEditRecord(root, rel, true) } @@ -1752,10 +1949,8 @@ export async function toggleTask(root: string, taskId: string): Promise<VaultTas // File task: metadata lives in frontmatter, not a `- [ ]` checkbox line. if (indexStr === 'task') { - const abs = resolveSafe(root, rel) + const { abs, folder } = await resolveNote(root, rel) const body = await fs.readFile(abs, 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const ctx = { path: toPosix(path.relative(root, abs)), title: path.basename(abs, path.extname(abs)), @@ -1772,14 +1967,14 @@ export async function toggleTask(root: string, taskId: string): Promise<VaultTas } const targetIndex = parseTaskIndex(taskId, indexStr) - const abs = resolveSafe(root, rel) + // Classified before the write, like the other edits: checked after, a task + // line in a `.zennotes` file (a template) was toggled before the refusal. + const { abs, folder } = await resolveNote(root, rel) const body = await fs.readFile(abs, 'utf8') const newBody = toggleTaskInBody(body, targetIndex) if (newBody == null) return null await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, newBody, 'utf8') - const folder = await folderOf(root, abs) - if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const parsed = parseTasksFromBody( newBody, { @@ -1903,7 +2098,7 @@ export function appendToBody(body: string, text: string): string { } export async function appendToNote(root: string, rel: string, text: string): Promise<NoteMeta> { - const { abs, folder } = await noteForWrite(root, rel) + const { abs, folder } = await resolveNote(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, appendToBody(body, text), 'utf8') @@ -1920,7 +2115,7 @@ export function prependToBody(body: string, text: string): string { } export async function prependToNote(root: string, rel: string, text: string): Promise<NoteMeta> { - const { abs, folder } = await noteForWrite(root, rel) + const { abs, folder } = await resolveNote(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, prependToBody(body, text), 'utf8') @@ -1961,7 +2156,7 @@ export async function replaceInNote( replace: string, occurrence: 'first' | 'all' = 'first' ): Promise<{ meta: NoteMeta; replacements: number }> { - const { abs, folder } = await noteForWrite(root, rel) + const { abs, folder } = await resolveNote(root, rel) const body = await fs.readFile(abs, 'utf8') const { body: next, replacements } = replaceInBody(body, find, replace, occurrence) if (replacements === 0) return { meta: await readMeta(root, abs, folder), replacements: 0 } @@ -1976,7 +2171,7 @@ export async function insertAtLine( lineNumber: number, text: string ): Promise<NoteMeta> { - const { abs, folder } = await noteForWrite(root, rel) + const { abs, folder } = await resolveNote(root, rel) const body = await fs.readFile(abs, 'utf8') await prepareNoteCreation(root, toPosix(path.relative(root, abs))) await fs.writeFile(abs, insertAtLineInBody(body, lineNumber, text), 'utf8') @@ -2019,7 +2214,7 @@ export async function writeNoteComments( } export async function backlinks(root: string, rel: string): Promise<NoteMeta[]> { - const abs = resolveSafe(root, rel) + const abs = await resolveSafe(root, rel) const all = await listNotes(root) return backlinksIn(all, toPosix(path.relative(root, abs))) } From 0ad42e5da4df5da2118857acbe5562bc7306df5a Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:47:41 -0500 Subject: [PATCH 16/53] Fix(database): Backspace while editing a view's picks no longer offers to delete them Delete and Backspace in the database grid ask to delete the selected rows (1cc2c690). While a view's picks are being edited, though, the selection is the picks: every row shows, the picked ones checked, and the bar offers Update picks or Cancel. Backspace there asked "Delete 2 rows?" about the rows someone was choosing to show, one Enter away from deleting them from the database. While picks are edited, Delete and Backspace now do nothing, as before 2.80; the picks are changed with Space and the checkboxes, and rows are deleted from the normal view. The keys also bail while a menu or a dialog is open, as the grid's Columns key does, so they never act behind one. The first test for the grid as a component (jsdom): Delete on the cursor row still asks; Backspace and Delete in Edit picks call nothing and leave "Editing picks: 2 selected" as it was; Delete behind an aria-modal dialog calls nothing. The last two failed before this. --- .../src/components/DatabaseTableView.test.ts | 106 ++++++++++++++++++ .../src/components/DatabaseTableView.tsx | 5 +- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/app-core/src/components/DatabaseTableView.test.ts diff --git a/packages/app-core/src/components/DatabaseTableView.test.ts b/packages/app-core/src/components/DatabaseTableView.test.ts new file mode 100644 index 000000000..403500e56 --- /dev/null +++ b/packages/app-core/src/components/DatabaseTableView.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DatabaseDoc } from '@shared/databases' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +import { useStore } from '../store' +import { DatabaseTableView } from './DatabaseTableView' + +const CSV = 'Projects.base/data.csv' + +function database(picked?: string[]): DatabaseDoc { + return { + version: 1, + path: CSV, + title: 'Projects', + idFieldId: 'id', + activeViewId: 'table', + fields: [ + { id: 'id', name: 'id', type: 'text', hidden: true }, + { id: 'name', name: 'Name', type: 'text' } + ], + views: [{ id: 'table', name: 'Table', type: 'table', filters: [], sorts: [], ...(picked ? { pickedRowIds: picked } : {}) }], + rows: [ + { id: 'a', cells: { id: 'a', name: 'Alpha' } }, + { id: 'b', cells: { id: 'b', name: 'Beta' } }, + { id: 'c', cells: { id: 'c', name: 'Gamma' } } + ] + } +} + +let host: HTMLDivElement +let root: Root +const deleteDatabaseRows = vi.fn<(csvPath: string, rowIds: string[], options?: { confirm?: boolean }) => Promise<void>>() + +beforeEach(() => { + Object.defineProperty(window, 'zen', { configurable: true, value: { getCapabilities: () => ({}) } }) + // jsdom lays nothing out. + Element.prototype.scrollIntoView = () => {} + deleteDatabaseRows.mockReset() + deleteDatabaseRows.mockResolvedValue(undefined) + useStore.setState({ deleteDatabaseRows, vimMode: false, focusedPanel: 'editor' }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + document.querySelectorAll('[aria-modal]').forEach((node) => node.remove()) +}) + +async function render(doc: DatabaseDoc): Promise<HTMLElement> { + await act(async () => + root.render(createElement(DatabaseTableView, { csvPath: CSV, doc, view: doc.views[0], isActive: true })) + ) + const grid = host.querySelector<HTMLElement>('[data-zen-db-grid]')! + // Let the grid's own focus claim on opening run first, so a later focus + // can only come from the key being tested. + await act(async () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))) + act(() => grid.focus()) + return grid +} + +async function press(grid: HTMLElement, key: string): Promise<void> { + await act(async () => { + grid.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) +} + +describe('the database grid, Delete and Backspace', () => { + it('ask to delete the row at the cursor', async () => { + const grid = await render(database()) + await press(grid, 'Delete') + expect(deleteDatabaseRows).toHaveBeenCalledWith(CSV, ['a'], { confirm: true }) + }) + + it('do nothing while the picks are being edited, where the selection is the picks', async () => { + const grid = await render(database(['a', 'b'])) + const edit = [...host.querySelectorAll('button')].find((button) => button.textContent === 'Edit picks')! + await act(async () => edit.click()) + expect(host.textContent).toContain('Editing picks: 2 selected') + act(() => grid.focus()) + + await press(grid, 'Backspace') + await press(grid, 'Delete') + + expect(deleteDatabaseRows).not.toHaveBeenCalled() + expect(host.textContent).toContain('Editing picks: 2 selected') + }) + + it('do nothing behind a dialog', async () => { + const grid = await render(database()) + const dialog = document.createElement('div') + dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + + await press(grid, 'Delete') + + expect(deleteDatabaseRows).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index 594aa0ed1..3516e2b8e 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -467,7 +467,10 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns case 'delete': { // The selected rows, else the one at the cursor, and only after the // confirm: unlike dd, these keys work with Vim mode off and sit next - // to text editing. A cancelled confirm keeps the selection. + // to text editing. A cancelled confirm keeps the selection. Never + // behind a menu or a dialog, and never while editing picks, where the + // selection is the view's picks and not rows to delete. + if (editingPicks || isOverlayOrDialogOpen()) return const c = cell() const ids = selected.size > 0 ? [...selected] : c ? [c.row.id] : [] if (ids.length === 0) return From e34ba1f4286cc6941485377495a449455e50d414 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:47:55 -0500 Subject: [PATCH 17/53] Fix(database): after dd deletes a row, the next key still moves in the grid With Vim mode on, dd in a database grid deleted the row and left the keyboard on nothing. The view is inert while rows are deleted, which drops the grid's focus to the body, so j, k or a second dd after it went nowhere until a click. 1cc2c690 handed focus back after Delete and Backspace; dd, the older path, never did. Both paths now finish through one step: rows that went leave the selection, and if the focus fell to the body, the grid takes it back on the next frame. A focus anywhere else (a dialog, another pane the person moved to) is left alone, as before. Test: dd on a row whose delete blurs the grid, as the inert view does, ends with the grid focused again; on the old code it stayed on body. --- .../src/components/DatabaseTableView.test.ts | 17 +++++++++++++ .../src/components/DatabaseTableView.tsx | 24 +++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/app-core/src/components/DatabaseTableView.test.ts b/packages/app-core/src/components/DatabaseTableView.test.ts index 403500e56..1b8c8e976 100644 --- a/packages/app-core/src/components/DatabaseTableView.test.ts +++ b/packages/app-core/src/components/DatabaseTableView.test.ts @@ -104,3 +104,20 @@ describe('the database grid, Delete and Backspace', () => { expect(deleteDatabaseRows).not.toHaveBeenCalled() }) }) + +describe('the database grid, dd with Vim mode on', () => { + it('hands the keyboard back to the grid once the row is gone', async () => { + useStore.setState({ vimMode: true }) + // The view is inert while rows are deleted, which drops focus to <body>. + deleteDatabaseRows.mockImplementation(async () => { + ;(document.activeElement as HTMLElement | null)?.blur() + }) + const grid = await render(database()) + + await press(grid, 'd') + await press(grid, 'd') + + expect(deleteDatabaseRows).toHaveBeenCalledWith(CSV, ['a']) + await vi.waitFor(() => expect(document.activeElement).toBe(grid)) + }) +}) diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index 3516e2b8e..e1b887048 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -306,6 +306,18 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns } }, [isActive, csvPath, focusedPanel]) + /** After a delete from the keyboard (dd, Delete, Backspace): drop rows that + * went from the selection, and give the grid the keyboard back. The view + * is inert while rows are deleted, which drops the grid's focus to the + * body, and the next key would otherwise go nowhere. */ + const afterRowsDeleted = (): void => { + const live = new Set(useStore.getState().databases[csvPath]?.rows.map((row) => row.id)) + setSelected((prev) => new Set([...prev].filter((id) => live.has(id)))) + requestAnimationFrame(() => { + if (document.activeElement === document.body) gridRef.current?.focus({ preventScroll: true }) + }) + } + const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => { if (editing) return // the cell's own input owns keys while editing // A focused text field inside the grid — the column-name editor, or any @@ -387,7 +399,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns } dPending.current = false const c = cell() - if (c) void deleteDatabaseRows(csvPath, [c.row.id]) + if (c) void deleteDatabaseRows(csvPath, [c.row.id]).then(afterRowsDeleted) return } case 'edit': { @@ -475,15 +487,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns const ids = selected.size > 0 ? [...selected] : c ? [c.row.id] : [] if (ids.length === 0) return e.preventDefault() - void deleteDatabaseRows(csvPath, ids, { confirm: true }).then(() => { - const live = new Set(useStore.getState().databases[csvPath]?.rows.map((row) => row.id)) - setSelected((prev) => new Set([...prev].filter((id) => live.has(id)))) - // The view is inert while rows are deleted, which drops the grid's - // focus to the body; the keys that deleted them keep working after. - requestAnimationFrame(() => { - if (document.activeElement === document.body) gridRef.current?.focus({ preventScroll: true }) - }) - }) + void deleteDatabaseRows(csvPath, ids, { confirm: true }).then(afterRowsDeleted) return } case 'escape': From fb5c74d70c9263a37a91a927dee7a89db5f6d66c Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:48:47 -0500 Subject: [PATCH 18/53] Fix(database): a row deleted while its confirm was open no longer blames the linked pages After the confirm, deleteDatabaseRows checks the rows again before it writes: a linked page that changed while the question was open makes the answer about the notes stale, so it stops with "The linked pages changed. Review the rows before deleting them." The same check also fired when a plain row (no linked page) had gone meanwhile, deleted in another window or by a sync, and said the same thing about pages that did not exist, then deleted none of the others. A row without a linked page that went meanwhile is now taken as already deleted, and the rest of the rows the person confirmed still go. When none are left it says "That row was already deleted." (or "Those rows..."). A row with a linked page that went, or any linked page that changed, still stops with the same toast, since the notes question was about those pages. A database closed meanwhile now returns quietly instead of blaming the pages. Tests: two plain rows confirmed, one gone meanwhile: no toast and the other deleted (on the old code, the pages toast and neither deleted); the only row gone: "That row was already deleted."; a linked page changed meanwhile still stops with the pages toast. --- .../app-core/src/database-row-actions.test.ts | 35 +++++++++++++++++++ packages/app-core/src/store.ts | 14 ++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/app-core/src/database-row-actions.test.ts b/packages/app-core/src/database-row-actions.test.ts index 3b35cc0ad..39b240a5d 100644 --- a/packages/app-core/src/database-row-actions.test.ts +++ b/packages/app-core/src/database-row-actions.test.ts @@ -175,6 +175,41 @@ describe('database row lifecycle', () => { await direct expect(s.disk().rows.map(row => row.id)).toEqual(['two']) }) + it('deletes what is left when a plain row went while the confirm was open, without blaming the linked pages', async () => { + const s = await setup() + const { useToastStore } = await import('./lib/toast') + const plain = { ...s.doc, pages: {}, pageHasContent: {} } + s.useStore.setState({ databases: { [CSV]: plain } }) + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one', 'two'], { confirm: true }) + // Row one goes elsewhere (another window, a sync) while the question is open. + s.useStore.setState({ databases: { [CSV]: { ...plain, rows: plain.rows.filter((row) => row.id !== 'one') } } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await pending + expect(useToastStore.getState().toasts.map((toast) => toast.message)).toEqual([]) + expect(s.disk().rows).toEqual([]) + + // All of them gone: nothing to delete, and it says so. + s.useStore.setState({ databases: { [CSV]: plain } }) + const gone = s.useStore.getState().deleteDatabaseRows(CSV, ['one'], { confirm: true }) + s.useStore.setState({ databases: { [CSV]: { ...plain, rows: plain.rows.filter((row) => row.id !== 'one') } } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await gone + expect(useToastStore.getState().toasts.map((toast) => toast.message)).toEqual(['That row was already deleted.']) + }) + it('still stops when a linked page changed while the confirm was open', async () => { + const s = await setup() + const { useToastStore } = await import('./lib/toast') + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one'], { confirm: true }) + s.useStore.setState({ databases: { [CSV]: { ...s.doc, pages: { ...s.doc.pages, one: 'Projects.base/pages/Other.md' } } } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await vi.waitFor(() => expect(s.prompts.getConfirmRequest()).not.toBeNull()) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, false) + await pending + expect(useToastStore.getState().toasts.map((toast) => toast.message)).toEqual([ + 'The linked pages changed. Review the rows before deleting them.' + ]) + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + }) it('asks about the rows first, then what happens to their notes', async () => { const s = await setup() const declined = s.useStore.getState().deleteDatabaseRows(CSV, ['one', 'two'], { confirm: true }) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 2c7c2ec44..6dc0bb12d 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -6908,15 +6908,25 @@ export const useStore = create<Store>((set, get) => { } if (!isCurrent()) return const latest = get().databases[csvPath] - if (!latest || ids.some((id) => !latest.rows.some(row => row.id === id) || latest.pages?.[id] !== mappings.get(id))) { + if (!latest) return + // The questions were about these rows and their notes. A linked page + // that changed meanwhile, or a row with one that went, makes the answer + // about the notes stale. A row without one that went meanwhile (another + // window, a sync) is simply already deleted, and the rest still go. + const present = ids.filter((id) => latest.rows.some((row) => row.id === id)) + if (ids.some((id) => latest.pages?.[id] !== mappings.get(id) || (!present.includes(id) && mappings.get(id)))) { useToastStore.getState().addToast('The linked pages changed. Review the rows before deleting them.', 'info') return } + if (present.length === 0) { + useToastStore.getState().addToast(ids.length === 1 ? 'That row was already deleted.' : 'Those rows were already deleted.', 'info') + return + } if (databaseRowActions.size || folderMutations.size || databaseCreations.size || inFlightNoteWrites.size || inFlightTaskMutations.size || taskMutationQueues.size || [...uncertainFolderMutations.values()].includes(vault)) { useToastStore.getState().addToast('Wait for pending file and task changes before deleting rows.', 'info') return } - const removeSet = new Set(ids) + const removeSet = new Set(present) const nextPages = { ...(latest.pages ?? {}) } const nextFlags = { ...(latest.pageHasContent ?? {}) } for (const id of ids) { From 14e3885faf6e757515a1c3b740feae0940ccddd6 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:49:57 -0500 Subject: [PATCH 19/53] Fix(focus): closing the sidebar on a phone leaves its keys with the editor Closing the sidebar while it held the keyboard (#353) hands the keys to the editor. On desktop that went through focusEditorNormalMode, which also sets focusedPanel to 'editor'. 679dc998 moved the hand-off to focusEditorAfterReadOpen so a phone keeps its soft keyboard down, and on such a host that hand-off focuses nothing and claims no panel. So focusedPanel stayed 'sidebar' after the sidebar was gone, and a phone with a hardware keyboard and Vim mode on drove the hidden sidebar: j and k moved an unseen cursor, Enter opened whatever it landed on. The hand-off is now focusEditorAfterSidebarClose (editor-focus.ts): it sets focusedPanel to 'editor', then hands off as before. A phone keeps its keyboard down with the panel where the reader is; desktop is identical, since focusEditorNormalMode claims the same panel right after. Tests: on a host that opens notes to read, closing the sidebar claims the editor panel and leaves the editor unfocused (the panel claim is what fails without this); on desktop it focuses the editor as before. --- packages/app-core/README.md | 4 +++- packages/app-core/src/App.tsx | 9 +++++---- packages/app-core/src/lib/editor-focus.test.ts | 18 ++++++++++++++++++ packages/app-core/src/lib/editor-focus.ts | 13 +++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/app-core/README.md b/packages/app-core/README.md index a3b4646dc..0c37a903d 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -360,7 +360,9 @@ the focus as a desktop habit rather than for typing: a card picked in a folder Gallery opens its note to be read, a pick in the Callout type menu and Remove callout leave the caret in the note without focusing it, the database Columns menu opens without focusing its find field (a tap into the field still does), -and closing the sidebar while it held the keyboard does not focus the editor. +and closing the sidebar while it held the keyboard does not focus the editor +(the editor's panel still takes the keys, so a hardware keyboard does not +drive the hidden sidebar). The focus heal, which refocuses the editor when the app comes back to the front, runs on such a host only when the editor held the focus as the app was left: someone typing gets the keyboard back, someone reading does not, and a diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index e26d1dfc1..0c0ed840d 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -47,6 +47,7 @@ import { recordRendererPerf } from './lib/perf' import { focusEditorAfterPaletteClose, focusEditorAfterReadOpen, + focusEditorAfterSidebarClose, focusEditorNormalMode } from './lib/editor-focus' import { hostOpensNotesToRead } from './lib/editor-host' @@ -459,15 +460,15 @@ function App(): JSX.Element { // Closing the sidebar while it holds keyboard focus (⌘1, Leader E, etc.) used // to strand focus on the now-hidden pane; hand it back to the editor. Only // fires on the open→closed transition when the sidebar was the focused panel, - // so hiding it mid-edit never steals focus from the editor. (#353) + // so hiding it mid-edit never steals focus from the editor. (#353) A host + // that opens notes to read (a phone) keeps its keyboard down; the editor's + // panel takes the keys there too (focusEditorAfterSidebarClose). const prevSidebarOpenRef = useRef(sidebarOpen) useEffect(() => { const wasOpen = prevSidebarOpenRef.current prevSidebarOpenRef.current = sidebarOpen - // A host that opens notes to read (a phone) keeps its keyboard down: the - // sidebar closing is no sign the user is about to type. if (wasOpen && !sidebarOpen && useStore.getState().focusedPanel === 'sidebar') { - focusEditorAfterReadOpen({ attempts: 10, delayMs: 24 }) + focusEditorAfterSidebarClose() } }, [sidebarOpen]) diff --git a/packages/app-core/src/lib/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index a6750332f..e8fb06ba9 100644 --- a/packages/app-core/src/lib/editor-focus.test.ts +++ b/packages/app-core/src/lib/editor-focus.test.ts @@ -29,6 +29,7 @@ import { caretIntoNewNoteBody, focusEditorAfterReadOpen, focusEditorAfterPaletteClose, + focusEditorAfterSidebarClose, focusEditorNormalMode, newNoteBodyCaret } from './editor-focus' @@ -163,6 +164,23 @@ describe('search hand-offs', () => { expect(revealedSelectionRanges(view!.state)).toEqual([]) }) + it('closing the sidebar that held the keys gives them to the editor, keyboard down on a phone', async () => { + host = installNoteEditorHost({ openNotesToRead: () => true }) + focusEditorAfterSidebarClose() + await vi.advanceTimersByTimeAsync(50) + // The editor panel owns the keys, so a hardware keyboard's Vim keys no + // longer drive the hidden sidebar; the soft keyboard stays down. + expect(storeState.current.setFocusedPanel).toHaveBeenCalledWith('editor') + expect(view!.hasFocus).toBe(false) + }) + + it('closing the sidebar that held the keys focuses the editor on desktop, as before', async () => { + focusEditorAfterSidebarClose() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + expect(storeState.current.setFocusedPanel).toHaveBeenCalledWith('editor') + }) + it('undoes the focus a closing palette handed back to the editor that opened it', async () => { host = installNoteEditorHost({ openNotesToRead: () => true }) view!.focus() diff --git a/packages/app-core/src/lib/editor-focus.ts b/packages/app-core/src/lib/editor-focus.ts index 617ad11d7..928c44ae2 100644 --- a/packages/app-core/src/lib/editor-focus.ts +++ b/packages/app-core/src/lib/editor-focus.ts @@ -83,6 +83,19 @@ export function focusEditorAfterReadOpen( }) } +/** + * Closing the sidebar while it held the keyboard (⌘1, Leader E; #353). The + * editor's panel takes the keys on every host: left on the hidden sidebar, + * they drove it unseen from a phone's hardware keyboard with Vim on. Desktop + * also focuses the editor, as it always has (focusEditorNormalMode claims the + * same panel); a host that opens notes to read keeps its keyboard down, since + * the sidebar closing is no sign the user is about to type. + */ +export function focusEditorAfterSidebarClose(): void { + useStore.getState().setFocusedPanel('editor') + focusEditorAfterReadOpen({ attempts: 10, delayMs: 24 }) +} + /** * Closing a palette without a pick: the searches above, the command palette, * the template palette, the drawing palette. Desktop gives the editor the From 695a923780162ee56fd5a523d4cc078cfde805c7 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:53:40 -0500 Subject: [PATCH 20/53] Fix(dialogs): Enter in a confirmation presses the button that has the focus ConfirmModal took Enter in the capture phase and confirmed, whatever had the focus. Every question opens with the focus on its first button, Cancel, so the highlighted button said Cancel while Enter deleted. With Delete and Backspace now asking before a database row goes (1cc2c690), and comments, Trash and assets asking the same way, Delete then a reflexive Enter was a delete with one key more, not a question. Enter now presses the focused button. A danger question (delete permanently, empty Trash, discard changes, remove a theme, every confirm that passes danger: true) opens on Cancel as before, so Enter cancels; Tab moves to the button that goes ahead and Enter confirms. Any other question now opens on its confirm button, so Enter confirms as it always did (Move to Trash, Archive, Create daily note, Unlink this device, the zn install messages). Escape still cancels, and Enter still stops at the dialog in the capture phase, so nothing behind it sees it. That changes, on purpose, every flow that pressed Enter to go ahead with a danger question: deleting database rows from the keyboard, deleting a comment, deleting from Trash, a workflow run with irreversible changes. Each now takes Tab first. The two danger-styled notices (zn Install failed, Uninstall failed) close either way. None of the smoke suites or the app-core browser test press Enter on a confirmation; the browser test clicks its buttons. The in-app manual gains a shortcut row for answering a confirmation, and the database grid's Delete / Backspace row says the question opens on Cancel; that row also says the keys do nothing while a view's picks are edited (ace48af3). Tests (jsdom): a danger question opens on Cancel and Enter cancels (on the old code, Enter confirmed); Enter on the focused confirm button confirms; a plain question opens on its confirm button and Enter confirms (old code: the focus was on Cancel); Enter does not reach a listener behind the dialog. --- .../src/components/ConfirmModal.test.ts | 87 +++++++++++++++++++ .../app-core/src/components/ConfirmModal.tsx | 29 ++++--- packages/app-core/src/lib/help.ts | 3 +- 3 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 packages/app-core/src/components/ConfirmModal.test.ts diff --git a/packages/app-core/src/components/ConfirmModal.test.ts b/packages/app-core/src/components/ConfirmModal.test.ts new file mode 100644 index 000000000..6c9f7913d --- /dev/null +++ b/packages/app-core/src/components/ConfirmModal.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +import { ConfirmModal, type ConfirmOptions } from './ConfirmModal' + +let host: HTMLDivElement +let root: Root +const onConfirm = vi.fn() +const onCancel = vi.fn() + +beforeEach(() => { + onConfirm.mockReset() + onCancel.mockReset() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() +}) + +function open(options: ConfirmOptions): void { + act(() => root.render(createElement(ConfirmModal, { options, onConfirm, onCancel }))) +} + +function button(label: string): HTMLButtonElement { + return [...document.querySelectorAll<HTMLButtonElement>('[data-confirm-modal] button')].find( + (candidate) => candidate.textContent === label + )! +} + +function enter(): void { + act(() => { + document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) + }) +} + +describe('ConfirmModal and Enter', () => { + it('opens a question that deletes on Cancel, so Enter keeps everything', () => { + open({ title: 'Delete this row?', confirmLabel: 'Delete row', danger: true }) + expect(document.activeElement).toBe(button('Cancel')) + + enter() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('presses the confirm button once it has the focus (Tab, then Enter)', () => { + open({ title: 'Delete this row?', confirmLabel: 'Delete row', danger: true }) + act(() => button('Delete row').focus()) + + enter() + + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onCancel).not.toHaveBeenCalled() + }) + + it('opens any other question on its confirm button, so Enter confirms as it always did', () => { + open({ title: 'Move “Plan” to Trash?', confirmLabel: 'Move to Trash' }) + expect(document.activeElement).toBe(button('Move to Trash')) + + enter() + + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onCancel).not.toHaveBeenCalled() + }) + + it('keeps Enter from the page underneath while it is open', () => { + const behind = vi.fn() + window.addEventListener('keydown', behind) + try { + open({ title: 'Delete this row?', confirmLabel: 'Delete row', danger: true }) + enter() + expect(behind).not.toHaveBeenCalled() + } finally { + window.removeEventListener('keydown', behind) + } + }) +}) diff --git a/packages/app-core/src/components/ConfirmModal.tsx b/packages/app-core/src/components/ConfirmModal.tsx index 20211b668..7ecc14164 100644 --- a/packages/app-core/src/components/ConfirmModal.tsx +++ b/packages/app-core/src/components/ConfirmModal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -19,32 +19,41 @@ export function ConfirmModal({ onConfirm: () => void onCancel: () => void }): JSX.Element { - // Modal owns Escape (→ cancel); we only add Enter (→ confirm) here. + const cancelRef = useRef<HTMLButtonElement>(null) + const confirmRef = useRef<HTMLButtonElement>(null) + + // Modal owns Escape (→ cancel). Enter presses the focused button, so the + // button a question opens on is its answer to Enter: a danger question + // (delete, discard, remove) opens on Cancel, and Tab then Enter confirms; + // any other opens on its confirm button, so Enter confirms as it always + // did. Taken in the capture phase so nothing behind the dialog sees it. useEffect(() => { const handler = (e: KeyboardEvent): void => { - if (e.key === 'Enter') { - e.preventDefault() - e.stopPropagation() - onConfirm() - } + if (e.key !== 'Enter') return + e.preventDefault() + e.stopPropagation() + const focused = document.activeElement + if (focused === cancelRef.current) onCancel() + else if (focused === confirmRef.current) onConfirm() } window.addEventListener('keydown', handler, true) return () => window.removeEventListener('keydown', handler, true) - }, [onConfirm]) + }, [onConfirm, onCancel]) return ( <Modal size="sm" layer="modal" onClose={onCancel} + initialFocus={options.danger ? cancelRef : confirmRef} data={{ 'data-confirm-modal': '', 'data-prompt-modal': '' }} > <Modal.Header title={options.title} description={options.description} /> <Modal.Footer> - <Button variant="secondary" onClick={onCancel}> + <Button ref={cancelRef} variant="secondary" onClick={onCancel}> {options.cancelLabel ?? 'Cancel'} </Button> - <Button variant={options.danger ? 'danger' : 'primary'} onClick={onConfirm}> + <Button ref={confirmRef} variant={options.danger ? 'danger' : 'primary'} onClick={onConfirm}> {options.confirmLabel ?? 'Confirm'} </Button> </Modal.Footer> diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 23f929369..723ae08d4 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -605,6 +605,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'a', action: 'Reply in a comment thread', detail: 'Comments panel: open a reply box under the selected comment; `Mod+Enter` sends it, `Esc` cancels.' }, { keys: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions: they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.) Both walk the same cycle, in the order the panels appear on screen: sidebar → note list → editor → connections → comments → outline → calendar → timeline, and back again.' }, { keys: '↑ / ↓ / → / ← / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus (the sidebar, the note list, connections, the outline or comments), the arrows move its row cursor, Enter or → opens the row under the cursor, Esc returns focus to the editor, and ← goes back the way `h` does there: from the note list to the sidebar, from connections, the outline and comments to the editor. Shift+F10 or the Context Menu key opens the row’s menu in the sidebar and the note list. In comments, Enter or → jumps to the comment’s text, and Backspace or Delete deletes it after a confirm. With Vim mode off, Home and End jump to the ends and Page Up / Page Down move a screenful of rows; the single-key motions (j / k, gg / G) and the panels’ letter keys stay Vim-only. The sidebar, reached with Alt+H or Focus Sidebar in the command palette, works like a tree: → opens the row or expands its folder, ← collapses a folder, or from a note folds its folder and lands on it (Esc still returns to the editor).' }, + { keys: 'Enter / Tab / Esc (in a question)', action: 'Answer a confirmation', detail: 'Enter presses the highlighted button. A question that deletes or discards something (Delete permanently, Empty Trash, Discard changes) opens on Cancel, so Enter keeps everything as it was; Tab moves to the button that goes ahead, then Enter confirms. Any other question opens on its confirm button, so Enter confirms. Esc always cancels.' }, { keys: 'Mod+.', action: 'Toggle Zen mode', detail: 'Hide or restore the app chrome so only the active editor, preview, or split view stays on screen.' }, { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab. With Settings open it closes Settings instead, and in a floating window or the quick capture window it closes that window, like its close button. While a dialog, palette or menu is open it does nothing, so it never closes the tab behind it; press Esc to dismiss those first. With Vim mode on, Linux and Windows keep Ctrl+W as the pane prefix while a tab is open: close the tab with :q or :bd, from the tab menu, or rebind this.' }, { keys: 'Ctrl+Tab', action: 'Switch to previous note', detail: 'Switch to the most recently used note. Press again to alternate between the last two notes.' }, @@ -854,7 +855,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: '↑ / ↓ / ← / →', action: 'Move the cell cursor', detail: 'In either mode. ↑ from the first row reaches the column-header row, where Enter renames the field and Shift+F10 opens its column menu. In Vim mode h / j / k / l move too, k climbs onto the header the same way, and 0 / ^ jump to the first column, $ to the last.' }, { keys: 'Enter', action: 'Edit the cell', detail: 'In either mode; i does it too in Vim mode. On a checkbox cell this toggles it instead of opening an editor, and on the column header it renames the field.' }, { keys: 'Space', action: 'Select the row', detail: 'In either mode; x does it too in Vim mode. On a checkbox cell Space toggles the checkbox instead. Selected rows get the selection bar: Delete, or Show only these in this view, which makes the selection the view’s picked rows (also Show Only Selected Rows in the command palette). The view then says “Showing N picked rows” under the header, with Edit picks and Show all rows.' }, - { keys: 'Delete / Backspace', action: 'Delete the selected rows', detail: 'In either mode. Deletes the selected rows, or the row at the cursor when none is selected, after asking first, since a deleted row cannot be brought back; Cancel or Esc keeps them and the selection. When some of the rows have a linked page note, a second question asks whether to move those notes to Trash too or keep them as standalone notes.' }, + { keys: 'Delete / Backspace', action: 'Delete the selected rows', detail: 'In either mode. Deletes the selected rows, or the row at the cursor when none is selected, after asking first, since a deleted row cannot be brought back. The question opens on Cancel, so Enter or Esc keeps them and the selection; Tab to Delete, then Enter, deletes them. While you edit a view’s picks these keys do nothing. When some of the rows have a linked page note, a second question asks whether to move those notes to Trash too or keep them as standalone notes.' }, { keys: 'Shift+F10', action: 'Open the row menu', detail: 'In either mode, or the Context Menu key; m does it too in Vim mode. On a cell it is the record’s right-click menu (Open page, Delete row, and the rest); with rows selected it offers Show only selected rows, and in a view showing picked rows, Remove from this view’s picks. On the column header it is the column menu, with Hide column and Choose columns….' }, { keys: 'Esc', action: 'Clear selection / leave the grid', detail: 'In either mode. Leaves Edit picks without changing them, else clears a multi-row selection, then blurs the grid.' }, { keys: '[[ (in a text cell)', action: 'Link a note from the cell', detail: 'While editing a text cell, `[[` opens the same note search as the editor; ↑/↓ or Ctrl+N/P move, Enter inserts the `[[wikilink]]`, Escape closes the list and keeps typing.' }, From 9c27db1b8e1a8cba7e1da3c94b1211d022c3c4fc Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 17:35:21 -0500 Subject: [PATCH 21/53] Fix(watcher): one unreadable folder no longer ends live updates for the vault Review follow-up to #999, which moves the vault watcher to chokidar 6 so a large vault on macOS takes one native recursive watch instead of a file handle per note and asset, and the EMFILE flood that froze the app on open is gone. #999 also closed the watcher on any 'error', so that flood could not come back. On Linux it reached further than it meant to. Linux has no recursive watch: chokidar walks the vault and adds an inotify watch per folder, and a folder it cannot read (`lost+found` at the top of a mounted drive, a folder another user owns) is an EACCES on that walk. Chokidar reports it and goes on watching the rest, but the new handler took it as the end of the watcher, so one unreadable folder anywhere in a vault stopped live updates for all of it, with nothing on screen to say so. The watcher now passes `ignorePermissionErrors`, so a folder it cannot read is skipped the way the vault listing already skips it. Of the errors left, only running out of watch handles closes it: EMFILE or ENFILE, or ENOSPC once Linux's inotify limit is reached. Any other error costs the one path it names and is logged. A watcher closed that way left the registry holding it as though it were running, so the vault stayed unwatched until every window had left it. It now tells the registry, which drops it, keeps the windows attached (the vault is still open), and starts a new one 30 seconds later, doubling the wait after each failure up to ten minutes. Retrying at once would hit the same limit; waiting gives another app time to let its handles go, or the user time to raise the limit. When a watcher is back, main drops the vault's caches and its windows get the `resync` the remote feed already sends after a reconnect, because nothing reported the changes in between. Telling the user instead would need a new surface through main, preload and the renderer, and would still leave them to reopen the vault. Chokidar 6 and readdirp 5 are pure ESM with no native pieces, so main now bundles them next to the markdown stack instead of loading them from the asar and trusting electron-builder to put readdirp where Node looks, the bet 2.20.0 lost. The built main requires neither. `stop()` has returned a Promise since #999; the registry's watcher contract now says so, and `stopAll()` settles once every watcher has closed. The quit paths still do not wait for it: close() lets go of the native handles before it returns, and holding a quit means cancelling it and quitting again, on the same path the updater's quit-and-install takes. guide.md names Node.js 22.22, chokidar 6's minimum, as the README and both engines fields already do. Tests: each of EMFILE, ENFILE and ENOSPC closes the watcher and tells its owner; an error about one path leaves it watching; a vault with a `chmod 000` folder keeps reporting the rest; the registry restarts the watcher, resyncs its windows, backs off, ignores a late word from a watcher it replaced, and drops the retry when the last window leaves. Checked in a packaged macOS build (`electron-builder --dir`): it launches and draws, its asar loads main's whole module graph, and on a 30,000-note vault under a 10,240 file cap it lists the vault in 6 s with no EMFILE, holds 57 descriptors throughout, and picks up all 17 outside changes the harness makes (notes, folders, renames, system folders, assets, a symlinked folder, templates, config.toml, themes, snippets). On Linux (Node 22.23, as a non-root user) the watcher and registry suites pass; the unreadable-folder test fails against #999 alone, its watcher stopped on that folder's EACCES. --- apps/desktop/electron.vite.config.ts | 10 +- apps/desktop/src/main/index.ts | 10 +- apps/desktop/src/main/watcher.test.ts | 78 ++++++++++++- apps/desktop/src/main/watcher.ts | 34 +++++- apps/desktop/src/main/window-vaults.test.ts | 118 +++++++++++++++++++- apps/desktop/src/main/window-vaults.ts | 89 ++++++++++++--- guide.md | 4 +- 7 files changed, 317 insertions(+), 26 deletions(-) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 7c712a326..1d1ff1831 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -53,10 +53,18 @@ const MAIN_BUNDLED_MARKDOWN_PACKAGES = [ 'remark-math' ] +// The file watchers (vault, config.toml, themes, CSS snippets, code +// languages), bundled for the same reason. Chokidar 6 is pure ESM with +// readdirp 5 below it; left external, main would load it from the asar and +// count on electron-builder putting readdirp where Node looks for it, the bet +// 2.20.0 lost. Neither has native pieces, so Rollup inlines both. +const MAIN_BUNDLED_WATCHER_PACKAGES = ['chokidar', 'readdirp'] + const MAIN_EXTERNALIZE_EXCLUSIONS = [ ...INTERNAL_WORKSPACE_PACKAGES, ...PACKAGED_CLI_RUNTIME_PACKAGES, - ...MAIN_BUNDLED_MARKDOWN_PACKAGES + ...MAIN_BUNDLED_MARKDOWN_PACKAGES, + ...MAIN_BUNDLED_WATCHER_PACKAGES ] function onigurumaDataUrl(): Plugin { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c40333365..5cb8adde1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -6508,7 +6508,8 @@ app.on("second-instance", (_event, argv) => { }); app.on("window-all-closed", () => { - windowVaults.stopAll(); + // Not awaited, here or before quitting: see `before-quit`. + void windowVaults.stopAll(); stopRemoteWatch(); if (!isMac()) app.quit(); }); @@ -6561,7 +6562,12 @@ app.on("before-quit", () => { appQuitting = true; cloudSyncService?.stop(); void cloudAuthLoopbackServer?.stop(); - windowVaults.stopAll(); + // Not awaited. Closing a watcher lets go of its native handles before + // close() returns; the promise only waits out stats and scans already in + // flight, which exit throws away anyway. Holding the quit for it would mean + // cancelling this quit and calling app.quit() again later, and this is also + // the path the updater's quit-and-install takes. + void windowVaults.stopAll(); stopRemoteWatch(); quickCaptureQuitting = true; unregisterQuickCaptureHotkey(); diff --git a/apps/desktop/src/main/watcher.test.ts b/apps/desktop/src/main/watcher.test.ts index 172236739..338a02298 100644 --- a/apps/desktop/src/main/watcher.test.ts +++ b/apps/desktop/src/main/watcher.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { getActiveResourcesInfo } from 'node:process' @@ -190,6 +190,82 @@ describe('VaultWatcher large vaults', () => { return events.some((event) => event.path === 'Recovered.md') }, { interval: 250, timeout: 10_000 }).toBe(true) }, 15_000) + + it.each(['EMFILE', 'ENFILE', 'ENOSPC'])( + 'tells its owner when watch handles run out (%s)', + async (code) => { + const root = await makeVault() + const watch = vi.spyOn(chokidar, 'watch') + vi.spyOn(console, 'error').mockImplementation(() => {}) + const exhausted = vi.fn() + const watcher = new VaultWatcher() + watchers.push(watcher) + watcher.start(root, () => {}, exhausted) + const active = watch.mock.results.find((result) => result.type === 'return')?.value + if (!active) throw new Error('Watcher did not start') + const closed = vi.spyOn(active, 'close') + + active.emit('error', Object.assign(new Error('Out of handles'), { code })) + expect(closed).toHaveBeenCalledOnce() + expect(exhausted).toHaveBeenCalledOnce() + } + ) + + it('keeps watching after an error about one path', async () => { + const root = await makeVault() + const watch = vi.spyOn(chokidar, 'watch') + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exhausted = vi.fn() + const events: VaultChangeEvent[] = [] + const watcher = new VaultWatcher() + watchers.push(watcher) + watcher.start(root, (event) => events.push(event), exhausted) + const active = watch.mock.results.find((result) => result.type === 'return')?.value + if (!active) throw new Error('Watcher did not start') + const closed = vi.spyOn(active, 'close') + + active.emit('error', Object.assign(new Error('Cannot watch one folder'), { code: 'EINVAL' })) + expect(report).toHaveBeenCalledOnce() + expect(closed).not.toHaveBeenCalled() + expect(exhausted).not.toHaveBeenCalled() + await expect.poll(async () => { + await writeFile(path.join(root, 'Still.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'Still.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + }, 15_000) + + // Linux watches folder by folder, so an unreadable one is an EACCES the + // watcher meets while it walks the vault. macOS and Windows watch the tree + // as a whole and never open it; there the test only proves nothing regressed. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'keeps reporting the rest of the vault when one folder cannot be read', + async () => { + const root = await makeVault() + const locked = path.join(root, 'lost+found') + await mkdir(path.join(root, 'Open')) + await mkdir(locked) + await writeFile(path.join(locked, 'Hidden.md'), '# Hidden\n') + await chmod(locked, 0o000) + const report = vi.spyOn(console, 'error') + const exhausted = vi.fn() + try { + const events: VaultChangeEvent[] = [] + const watcher = new VaultWatcher() + watchers.push(watcher) + watcher.start(root, (event) => events.push(event), exhausted) + await expect.poll(async () => { + await writeFile(path.join(root, 'Open', 'After.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'Open/After.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + expect(report).not.toHaveBeenCalled() + expect(exhausted).not.toHaveBeenCalled() + } finally { + // rm cannot clear a folder it cannot read. + await chmod(locked, 0o755) + } + }, + 15_000 + ) }) describe('VaultWatcher custom templates (#723)', () => { diff --git a/apps/desktop/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts index 0ed9d25a6..e16bcb9bb 100644 --- a/apps/desktop/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -13,6 +13,11 @@ const NOTE_COMMENTS_SUFFIX = '.comments.json' const TEMPLATES_PREFIX = `${INTERNAL_VAULT_DIR}/templates/` const AGENT_EDITS_PREFIX = `${INTERNAL_VAULT_DIR}/${AGENT_EDITS_DIR}/` +/** The system ran out of watch handles: file descriptors (EMFILE, ENFILE) or, + * on Linux, inotify watches (ENOSPC). Every folder after the first failure + * fails the same way, so the watcher cannot go on reporting the vault. */ +const WATCH_HANDLES_EXHAUSTED = new Set(['EMFILE', 'ENFILE', 'ENOSPC']) + function toPosix(p: string): string { return p.split(path.sep).join('/') } @@ -103,7 +108,14 @@ export class VaultWatcher { else emit() } - start(root: string, onEvent: (ev: VaultChangeEvent) => void): void { + /** `onExhausted` runs once if the watcher shuts itself down because the + * system ran out of watch handles. It is stopped by then; nothing restarts + * it on its own. */ + start( + root: string, + onEvent: (ev: VaultChangeEvent) => void, + onExhausted: () => void = () => {} + ): void { void this.stop() this.root = root this.settings = null @@ -114,6 +126,11 @@ export class VaultWatcher { const watcher = chokidar.watch(root, { ignoreInitial: true, persistent: true, + // A folder the app cannot read (`lost+found` at the top of a mounted + // drive, a folder another user owns) is one the vault listing skips as + // well. Linux watches folder by folder, and without this its EACCES + // reached the error handler below as a failure of the whole watcher. + ignorePermissionErrors: true, ignored: (p: string) => { if (this.root && isVaultSettingsPath(this.root, p)) return false if (this.root && relativeVaultPath(this.root, p) === INTERNAL_VAULT_DIR) return false @@ -224,8 +241,19 @@ export class VaultWatcher { watcher .on('error', (error) => { if (this.watcher !== watcher) return - console.error('Vault watcher failed; live updates have stopped', error) - void this.stop() + // Chokidar reports a path it could not watch and goes on watching the + // rest, so most errors cost that one path. Running out of handles is + // the exception: the flood of failures that follows is what froze the + // app on a large vault, so that one closes the watcher and tells the + // owner, which decides when to try again. + const code = (error as NodeJS.ErrnoException).code + if (code && WATCH_HANDLES_EXHAUSTED.has(code)) { + console.error('Vault watcher ran out of watch handles; live updates have stopped', error) + void this.stop() + onExhausted() + return + } + console.error('Vault watcher could not watch a path', error) }) .on('add', handler('add')) .on('change', handler('change')) diff --git a/apps/desktop/src/main/window-vaults.test.ts b/apps/desktop/src/main/window-vaults.test.ts index 5df7f7359..8dff07014 100644 --- a/apps/desktop/src/main/window-vaults.test.ts +++ b/apps/desktop/src/main/window-vaults.test.ts @@ -1,23 +1,59 @@ import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { VaultChangeEvent } from '@shared/ipc' -import { WindowVaultRegistry, type VaultWatcherLike } from './window-vaults' +import { + WATCH_RETRY_FIRST_DELAY_MS, + WATCH_RETRY_MAX_DELAY_MS, + WindowVaultRegistry, + type VaultWatcherLike +} from './window-vaults' class TestWatcher implements VaultWatcherLike { root: string | null = null stopped = false onEvent: ((ev: VaultChangeEvent) => void) | null = null + onExhausted: (() => void) | null = null - start(root: string, onEvent: (ev: VaultChangeEvent) => void): void { + start(root: string, onEvent: (ev: VaultChangeEvent) => void, onExhausted: () => void): void { this.root = root this.onEvent = onEvent + this.onExhausted = onExhausted } - stop(): void { + async stop(): Promise<void> { this.stopped = true } + + /** What VaultWatcher does when the system runs out of watch handles. */ + exhaust(): void { + this.stopped = true + this.onExhausted?.() + } +} + +const RESYNC: VaultChangeEvent = { kind: 'change', path: '', folder: 'inbox', scope: 'resync' } + +function retryingRegistry() { + const watchers: TestWatcher[] = [] + const sent: Array<{ windowId: number; ev: VaultChangeEvent }> = [] + const invalidated: Array<{ root: string; ev: VaultChangeEvent }> = [] + const registry = new WindowVaultRegistry({ + makeWatcher: () => { + const watcher = new TestWatcher() + watchers.push(watcher) + return watcher + }, + invalidateVault: (root, ev) => invalidated.push({ root, ev }), + sendVaultChange: (windowId, ev) => sent.push({ windowId, ev }) + }) + return { registry, watchers, sent, invalidated } } +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + function change(path: string): VaultChangeEvent { return { kind: 'change', @@ -164,3 +200,77 @@ describe('WindowVaultRegistry', () => { expect(registry.renameLocalVault(docs, 'Acme API docs')).toEqual([]) }) }) + +describe('WindowVaultRegistry when a watcher runs out of watch handles', () => { + it('watches the vault again later and has its windows re-read it', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { registry, watchers, sent, invalidated } = retryingRegistry() + const root = path.resolve('/tmp/zennotes-exhausted') + registry.setLocalVault(1, { root, name: 'Big' }) + registry.setLocalVault(2, { root, name: 'Big' }) + + watchers[0].exhaust() + // A window opening the vault meanwhile does not start one early. + registry.setLocalVault(3, { root, name: 'Big' }) + vi.advanceTimersByTime(WATCH_RETRY_FIRST_DELAY_MS - 1) + expect(watchers).toHaveLength(1) + // The vault is still open while nothing watches it. + expect(registry.isPathInsideOpenLocalVault(path.join(root, 'a.md'))).toBe(true) + + vi.advanceTimersByTime(1) + expect(watchers).toHaveLength(2) + expect(watchers[1].root).toBe(root) + expect(invalidated).toEqual([{ root, ev: RESYNC }]) + expect(sent).toEqual([ + { windowId: 1, ev: RESYNC }, + { windowId: 2, ev: RESYNC }, + { windowId: 3, ev: RESYNC } + ]) + + // The new watcher reports as the first one did. + watchers[1].onEvent?.(change('a.md')) + expect(sent.slice(3).map((entry) => entry.windowId)).toEqual([1, 2, 3]) + // A late word from the watcher it replaced changes nothing. + watchers[0].onExhausted?.() + vi.advanceTimersByTime(WATCH_RETRY_MAX_DELAY_MS) + expect(watchers).toHaveLength(2) + }) + + it('waits longer after each failure, up to a cap', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { registry, watchers } = retryingRegistry() + registry.setLocalVault(1, { root: path.resolve('/tmp/zennotes-backoff'), name: 'Big' }) + + const waits: number[] = [] + for (let attempt = 0; attempt < 7; attempt++) { + const before = watchers.length + watchers[before - 1].exhaust() + let waited = 0 + while (watchers.length === before) { + vi.advanceTimersByTime(1000) + waited += 1000 + } + waits.push(waited) + } + expect(waits).toEqual([30_000, 60_000, 120_000, 240_000, 480_000, 600_000, 600_000]) + }) + + it('gives up the retry when the last window leaves the vault', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { registry, watchers, sent } = retryingRegistry() + const root = path.resolve('/tmp/zennotes-gone') + registry.setLocalVault(1, { root, name: 'Big' }) + registry.setLocalVault(2, { root: path.resolve('/tmp/zennotes-other'), name: 'Other' }) + + watchers[0].exhaust() + registry.clearWindow(1) + await registry.stopAll() + vi.advanceTimersByTime(WATCH_RETRY_MAX_DELAY_MS) + expect(watchers).toHaveLength(2) + expect(watchers[1].stopped).toBe(true) + expect(sent).toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/window-vaults.ts b/apps/desktop/src/main/window-vaults.ts index 7112f53a6..424f28b67 100644 --- a/apps/desktop/src/main/window-vaults.ts +++ b/apps/desktop/src/main/window-vaults.ts @@ -4,10 +4,20 @@ import type { VaultChangeEvent, VaultInfo } from '@shared/ipc' export type WindowWorkspaceMode = 'local' | 'remote' export interface VaultWatcherLike { - start(root: string, onEvent: (ev: VaultChangeEvent) => void): void - stop(): void + /** `onExhausted` runs if the watcher stops itself because the system ran + * out of watch handles. */ + start(root: string, onEvent: (ev: VaultChangeEvent) => void, onExhausted: () => void): void + stop(): Promise<void> } +/** How long a vault goes unwatched after the system ran out of watch handles + * before the registry tries again, doubling up to the cap on each failure. */ +export const WATCH_RETRY_FIRST_DELAY_MS = 30_000 +export const WATCH_RETRY_MAX_DELAY_MS = 10 * 60_000 + +/** Whatever changed while a vault went unwatched was never reported. */ +const RESYNC_EVENT: VaultChangeEvent = { kind: 'change', path: '', folder: 'inbox', scope: 'resync' } + interface WindowVaultSession { mode: WindowWorkspaceMode vault: VaultInfo | null @@ -15,8 +25,12 @@ interface WindowVaultSession { interface LocalVaultWatch { root: string - watcher: VaultWatcherLike + /** Null while the vault waits for another try after its watcher ran out of + * watch handles. The windows stay attached: the vault is still open. */ + watcher: VaultWatcherLike | null windowIds: Set<number> + retryTimer: ReturnType<typeof setTimeout> | null + retryDelayMs: number } export interface WindowVaultRegistryOptions { @@ -46,17 +60,15 @@ export class WindowVaultRegistry { let registration = this.localVaultWatches.get(root) if (!registration) { - const watcher = this.options.makeWatcher() registration = { root, - watcher, - windowIds: new Set<number>() + watcher: null, + windowIds: new Set<number>(), + retryTimer: null, + retryDelayMs: WATCH_RETRY_FIRST_DELAY_MS } - watcher.start(root, (ev) => { - this.options.invalidateVault(root, ev) - this.sendLocalVaultChange(root, ev) - }) this.localVaultWatches.set(root, registration) + this.watchLocalVault(registration) } registration.windowIds.add(windowId) } @@ -146,12 +158,63 @@ export class WindowVaultRegistry { } } - stopAll(): void { + /** Settles once every watcher has closed. */ + async stopAll(): Promise<void> { + const stopping: Promise<void>[] = [] for (const registration of this.localVaultWatches.values()) { - registration.watcher.stop() + stopping.push(this.unwatchLocalVault(registration)) } this.localVaultWatches.clear() this.sessions.clear() + await Promise.all(stopping) + } + + private watchLocalVault(registration: LocalVaultWatch): void { + const { root } = registration + const watcher = this.options.makeWatcher() + registration.watcher = watcher + watcher.start( + root, + (ev) => this.emitLocalVaultChange(root, ev), + () => this.retryLocalVaultWatch(registration, watcher) + ) + } + + /** + * The watcher ran out of watch handles and stopped itself. Starting a new + * one at once would hit the same wall, but the wall can move: another app + * lets go of its handles, or the user raises the limit. So the vault waits + * and tries again, longer each time it fails, and its windows re-read it + * once a watcher is back, because nothing was reporting in between. + */ + private retryLocalVaultWatch(registration: LocalVaultWatch, watcher: VaultWatcherLike): void { + if (this.localVaultWatches.get(registration.root) !== registration) return + if (registration.watcher !== watcher) return + registration.watcher = null + const delay = registration.retryDelayMs + registration.retryDelayMs = Math.min(delay * 2, WATCH_RETRY_MAX_DELAY_MS) + console.warn( + `Vault watcher for ${registration.root} stopped; trying again in ${Math.round(delay / 1000)} s` + ) + registration.retryTimer = setTimeout(() => { + registration.retryTimer = null + if (this.localVaultWatches.get(registration.root) !== registration) return + this.watchLocalVault(registration) + this.emitLocalVaultChange(registration.root, RESYNC_EVENT) + }, delay) + } + + private unwatchLocalVault(registration: LocalVaultWatch): Promise<void> { + if (registration.retryTimer) clearTimeout(registration.retryTimer) + registration.retryTimer = null + const watcher = registration.watcher + registration.watcher = null + return watcher ? watcher.stop() : Promise.resolve() + } + + private emitLocalVaultChange(root: string, ev: VaultChangeEvent): void { + this.options.invalidateVault(root, ev) + this.sendLocalVaultChange(root, ev) } private sendLocalVaultChange(root: string, ev: VaultChangeEvent): void { @@ -167,8 +230,8 @@ export class WindowVaultRegistry { if (!registration) return registration.windowIds.delete(windowId) if (registration.windowIds.size > 0) return - registration.watcher.stop() this.localVaultWatches.delete(root) + void this.unwatchLocalVault(registration) } } diff --git a/guide.md b/guide.md index c6dec50d6..e521dccbd 100644 --- a/guide.md +++ b/guide.md @@ -22,7 +22,7 @@ Use this if: ### Requirements -- Node.js 22+ +- Node.js 22.22 or newer - npm ### Steps @@ -163,7 +163,7 @@ Use this if you do not want Docker and you are okay running both the frontend an ### Requirements -- Node.js 22+ +- Node.js 22.22 or newer - npm - Go, only if you want to run the server from a ZenNotes/znserver checkout instead of the pinned release binary From 6bf2604e1f5d686425803b5ce077ed1262b59f4b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 18:05:04 -0500 Subject: [PATCH 22/53] Fix(watcher): an unreadable folder no longer ends live theme, snippet and language updates The themes, CSS overrides, language packs and config.toml watchers had no 'error' listener. Chokidar raises 'error' inside its own async scan, so with nobody listening it became an unhandled rejection in main, and the watcher reported nothing for the rest of the session. One folder the app cannot read under themes/ or languages/ was enough: the built app logged the EACCES as an UnhandledPromiseRejectionWarning and never sent the next theme edit to the window. The same steps with every folder readable sent it twice. Each watcher now listens for 'error' and logs it, and the three folder watchers set ignorePermissionErrors, so an unreadable folder costs that folder and nothing else. The config.toml watcher keeps reporting a permission error, because an unreadable config file is worth a line in the log. This was already true under chokidar 4; #999's move to chokidar 6 is where it turned up. The themes and language packs watchers gain a stop hook for the tests, as the config watcher already has. --- apps/desktop/src/main/app-config.ts | 7 +++- .../src/main/custom-code-languages.test.ts | 37 +++++++++++++++++- .../desktop/src/main/custom-code-languages.ts | 15 ++++++- apps/desktop/src/main/custom-themes.test.ts | 39 ++++++++++++++++++- apps/desktop/src/main/custom-themes.ts | 11 ++++++ apps/desktop/src/main/overrides.ts | 8 +++- 6 files changed, 111 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index c56940907..a736fcbb8 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -1020,7 +1020,12 @@ function startWatching(): void { onChangeCb?.({ ...cache }) })() }, WATCH_DEBOUNCE_MS) - watcher.on('add', handle).on('change', handle) + watcher + .on('add', handle) + .on('change', handle) + // An unreadable config.toml raises EACCES here. With no listener, chokidar's + // 'error' rejects inside its own scan instead of reaching this log. + .on('error', (error) => console.warn('Failed to watch config.toml', error)) // Intentionally ignore 'unlink': a deleted config shouldn't wipe the live // settings; the next save recreates the file from the in-memory cache. } diff --git a/apps/desktop/src/main/custom-code-languages.test.ts b/apps/desktop/src/main/custom-code-languages.test.ts index 9f1d70c2a..3d9a773bb 100644 --- a/apps/desktop/src/main/custom-code-languages.test.ts +++ b/apps/desktop/src/main/custom-code-languages.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -13,6 +13,8 @@ import { listCustomCodeLanguages, SLOW_GRAMMAR_ERROR, updateCustomCodeLanguage, + startWatchingCustomCodeLanguages, + stopWatchingCustomCodeLanguages, } from "./custom-code-languages"; const GLEAM_GRAMMAR = JSON.stringify({ @@ -179,6 +181,39 @@ describe("custom code languages (main)", () => { // These share the process-wide verdict cache on purpose: the second assertion // in each pair is that the cache is what makes the later checks instant. +describe("custom code languages watcher", () => { + // An unreadable language folder used to end live updates for every pack: + // chokidar's EACCES had no 'error' listener, and the watcher went quiet. + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "keeps reporting pack edits when one language folder cannot be read", + async () => { + const dir = await ensureCustomCodeLanguagesDir(); + const locked = join(dir, "locked"); + await mkdir(locked); + await chmod(locked, 0o000); + let reports = 0; + try { + startWatchingCustomCodeLanguages(() => reports++); + await mkdir(join(dir, "late")); + await expect + .poll( + async () => { + await writeFile(join(dir, "late", "manifest.json"), `{"n":${Date.now()}}\n`); + return reports; + }, + { interval: 300, timeout: 10_000 }, + ) + .toBeGreaterThan(0); + } finally { + await stopWatchingCustomCodeLanguages(); + // rm cannot clear a folder it cannot read. + await chmod(locked, 0o755); + } + }, + 15_000, + ); +}); + describe("grammar vetting", () => { it("kills a grammar that cannot finish the corpus, then remembers the verdict", async () => { expect(await isGrammarFastEnough(GLEAM_GRAMMAR, "source.gleam", 4000)).toBe( diff --git a/apps/desktop/src/main/custom-code-languages.ts b/apps/desktop/src/main/custom-code-languages.ts index a73dd1720..58da8da8c 100644 --- a/apps/desktop/src/main/custom-code-languages.ts +++ b/apps/desktop/src/main/custom-code-languages.ts @@ -546,6 +546,8 @@ export function startWatchingCustomCodeLanguages( watcher = chokidar.watch(getCustomCodeLanguagesDir(), { ignoreInitial: true, depth: 1, + // A pack folder the app cannot read costs that folder, not the watcher. + ignorePermissionErrors: true, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, }); watcher @@ -553,5 +555,16 @@ export function startWatchingCustomCodeLanguages( .on("change", fire) .on("unlink", fire) .on("addDir", fire) - .on("unlinkDir", fire); + .on("unlinkDir", fire) + // With no listener, chokidar's 'error' rejects inside its own scan and + // the watcher stops reporting edits for the rest of the session. + .on("error", (error) => + console.warn("[zen] the language packs watcher could not watch a path:", error), + ); +} + +/** Test/teardown hook: stop the language packs watcher. */ +export async function stopWatchingCustomCodeLanguages(): Promise<void> { + await watcher?.close().catch(() => {}); + watcher = null; } diff --git a/apps/desktop/src/main/custom-themes.test.ts b/apps/desktop/src/main/custom-themes.test.ts index 8662f40ce..3613147fe 100644 --- a/apps/desktop/src/main/custom-themes.test.ts +++ b/apps/desktop/src/main/custom-themes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises' +import { chmod, mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises' import { existsSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,7 +10,9 @@ import { deleteCustomTheme, createCustomTheme, customThemeRevealTarget, - resolveThemeAssetPath + resolveThemeAssetPath, + startWatchingCustomThemes, + stopWatchingCustomThemes } from './custom-themes' let tmp: string @@ -125,6 +127,39 @@ describe('custom themes (main)', () => { }) }) +describe('custom themes watcher', () => { + // An unreadable theme folder used to end live theme updates: chokidar's + // EACCES had no 'error' listener, and the watcher reported nothing after it. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'keeps reporting theme edits when one theme folder cannot be read', + async () => { + const dir = await ensureCustomThemesDir() + const locked = join(dir, 'locked') + await mkdir(locked) + await chmod(locked, 0o000) + let reports = 0 + try { + startWatchingCustomThemes(() => reports++) + await mkdir(join(dir, 'late')) + await expect + .poll( + async () => { + await writeFile(join(dir, 'late', 'theme.css'), `:root { --n: ${Date.now()}; }\n`) + return reports + }, + { interval: 300, timeout: 10_000 } + ) + .toBeGreaterThan(0) + } finally { + await stopWatchingCustomThemes() + // rm cannot clear a folder it cannot read. + await chmod(locked, 0o755) + } + }, + 15_000 + ) +}) + describe('resolveThemeAssetPath', () => { it('resolves a real file inside the theme folder, rejects escapes', async () => { const dir = await ensureCustomThemesDir() diff --git a/apps/desktop/src/main/custom-themes.ts b/apps/desktop/src/main/custom-themes.ts index b033c7bd4..d4e718d80 100644 --- a/apps/desktop/src/main/custom-themes.ts +++ b/apps/desktop/src/main/custom-themes.ts @@ -395,6 +395,8 @@ export function startWatchingCustomThemes(onChange: (themes: CustomTheme[]) => v const w = chokidar.watch(dir, { ignoreInitial: true, depth: 1, + // A theme folder the app cannot read costs that folder, not the watcher. + ignorePermissionErrors: true, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 } }) watcher = w @@ -403,6 +405,15 @@ export function startWatchingCustomThemes(onChange: (themes: CustomTheme[]) => v .on('unlink', fire) .on('addDir', fire) .on('unlinkDir', fire) + // With no listener, chokidar's 'error' rejects inside its own scan and + // the watcher stops reporting edits for the rest of the session. + .on('error', (error) => console.warn('[zen] the themes watcher could not watch a path:', error)) +} + +/** Test/teardown hook: stop the themes watcher. */ +export async function stopWatchingCustomThemes(): Promise<void> { + await watcher?.close().catch(() => {}) + watcher = null } /** What to reveal in the file manager: a theme's `theme.css` when the slug is diff --git a/apps/desktop/src/main/overrides.ts b/apps/desktop/src/main/overrides.ts index 2d6a1e3fe..eff2ba788 100644 --- a/apps/desktop/src/main/overrides.ts +++ b/apps/desktop/src/main/overrides.ts @@ -162,10 +162,16 @@ export function startWatchingOverrides(onChange: (overrides: Override[]) => void const w = chokidar.watch(dir, { ignoreInitial: true, depth: 0, + ignorePermissionErrors: true, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 } }) watcher = w - w.on('add', fire).on('change', fire).on('unlink', fire) + // With no listener, chokidar's 'error' rejects inside its own scan and the + // watcher stops reporting edits for the rest of the session. + w.on('add', fire) + .on('change', fire) + .on('unlink', fire) + .on('error', (error) => console.warn('[zen] the overrides watcher could not watch a path:', error)) } /** Reveal a specific override file when the name is valid and exists, otherwise From dd513589153dd0c13ab17a71f485075329a723f3 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 18:21:52 -0500 Subject: [PATCH 23/53] Fix(cloud): a note of yours that Cloud holds back keeps sync asking until you rename it 3eee49b6 made every run whose only news was a file Cloud refused count as synced, so sync would stop sitting on "needs attention" for good over an assistant edit record an older server would not take. That was right for files ZenNotes keeps for itself under .zennotes: there is nothing for the person to do, and sync offers them again once a day. It also hid a note or attachment of the person's own, whose name or path only they can change: the status bar read Synced, the tray stayed idle, and the one sentence asking them to rename it lived in Settings and a tooltip. Such a run now stays on attention while any file it holds is the person's own, and is quiet only when every held file is under .zennotes. The status row keeps the calm words ("1 file stays on this device", with Review), a status bar turned off comes back as it does for any other attention, and the tray says so. Once the note is renamed to a path Cloud takes, the next run reads Synced again. isInternalVaultPath, the rule Settings already used to word these rows, is now exported from cloud-error-copy so both read the same line. The in-app manual says which case is which. Tests: a held note of the person's own reads attention, needs the person, keeps the last clean sync time and labels calmly, beside an internal file as well, and reads ready again once a run no longer holds it (on the old code the first assertion read ready); the status row names it. On the built app against a fake Cloud: a held .zennotes file reads "Synced just now" with an idle tray, a note moved to a refused path reads "2 files stay on this device" with the tray on attention, and renaming it to a path Cloud takes moves it in Cloud and reads Synced again (12/12). --- .../app-core/src/components/StatusBar.test.ts | 25 ++++++++++ .../app-core/src/components/StatusBar.tsx | 4 +- .../app-core/src/lib/cloud-auto-sync.test.ts | 50 +++++++++++++++++++ packages/app-core/src/lib/cloud-auto-sync.ts | 19 ++++--- packages/app-core/src/lib/help.ts | 2 +- .../shared-domain/src/cloud-error-copy.ts | 3 +- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/packages/app-core/src/components/StatusBar.test.ts b/packages/app-core/src/components/StatusBar.test.ts index c782616c5..fd9131cfc 100644 --- a/packages/app-core/src/components/StatusBar.test.ts +++ b/packages/app-core/src/components/StatusBar.test.ts @@ -163,6 +163,31 @@ describe("cloud sync status time", () => { host.remove(); }); + it("names a held note of the person's own in the status row until it is renamed", () => { + useCloudSyncStatusStore.setState({ + phase: "attention", + vaultName: "Notes", + lastSyncedAt: Date.now(), + error: "Plan.md stays on this device because its path is too long.", + lastSummary: { + cursor: 1, pulled: 0, pushed: 2, bootstrap_conflicts: [], local_conflicts: [], conflicts: [], + refused_paths: [{ path: `${"Projects/".repeat(120)}Plan.md`, detail: "" }], + }, + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + act(() => root.render(createElement(StatusBar, { note: null }))); + + const status = host.querySelector<HTMLElement>("[data-cloud-sync-status]"); + expect(status?.textContent).toContain("1 file stays on this device"); + expect(status?.className).not.toContain("text-success"); + + act(() => root.unmount()); + host.remove(); + }); + it("shows conflicted syncs as incomplete and offers a review action", () => { useCloudSyncStatusStore.setState({ phase: "attention", diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index c8d6ea52e..2506b24c6 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -177,8 +177,8 @@ function CloudSyncStatus({ : lastSyncedAt === null ? "Cloud ready" : `Synced ${formatRelativeSyncTime(lastSyncedAt, now)}`; - // Files Cloud refused leave a synced run synced (#1004); the hover still - // says they wait, for anyone who looks. + // Files ZenNotes keeps for itself that Cloud refused leave a synced run + // synced (#1004); the hover still says they wait, for anyone who looks. const held = lastSummary?.refused_paths?.length ?? 0; const heldNote = held > 0 diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index 0ec66cad8..c80efcc9b 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -900,6 +900,56 @@ describe("cloudSyncAttentionItems (Discord: name the file, not the count)", () = } }); + it("still needs the person while a note of theirs is held, until they rename it", async () => { + const record = ".zennotes/agent-edits/inbox/Note.md.json"; + const note = `${"Projects/".repeat(120)}Plan.md`; + const own: CloudSyncRunSummary = { + ...base, + pushed: 3, + refused_paths: [{ path: note, detail: "" }], + }; + clearCloudSyncStatus(); + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-10-29T12:00:00Z")); + await syncCloudVaultWithStatus({ syncCloudVault: async () => ({ ...base, pushed: 1 }) }, "Notes"); + vi.setSystemTime(new Date("2026-10-29T12:05:00Z")); + await syncCloudVaultWithStatus({ syncCloudVault: async () => own }, "Notes"); + const held = useCloudSyncStatusStore.getState(); + expect(held.phase).toBe("attention"); + expect(held.error).toBe(cloudSyncAttentionMessage(own)); + // Only the person can fix it, so a status bar turned off comes back and + // the tray says so; the words stay calm. + expect(cloudSyncNeedsUser()).toBe(true); + expect(traySyncStatusFor(held)?.state).not.toBe("idle"); + expect(cloudSyncAttentionLabel(own)).toBe("1 file stays on this device"); + // The last time everything synced, not this run. + expect(held.lastSyncedAt).toBe(Date.parse("2026-10-29T12:00:00Z")); + + // Held beside a file ZenNotes keeps for itself, the note still counts. + await syncCloudVaultWithStatus( + { + syncCloudVault: async () => ({ + ...own, + refused_paths: [{ path: record, detail: "" }, { path: note, detail: "" }], + }), + }, + "Notes", + ); + expect(useCloudSyncStatusStore.getState().phase).toBe("attention"); + + // Renamed and synced: back to synced. + vi.setSystemTime(new Date("2026-10-29T12:10:00Z")); + await syncCloudVaultWithStatus({ syncCloudVault: async () => ({ ...base, pushed: 1 }) }, "Notes"); + expect(useCloudSyncStatusStore.getState().phase).toBe("ready"); + expect(useCloudSyncStatusStore.getState().lastSyncedAt).toBe(Date.parse("2026-10-29T12:10:00Z")); + expect(cloudSyncNeedsUser()).toBe(false); + } finally { + clearCloudSyncStatus(); + vi.useRealTimers(); + } + }); + it("remembers the last run summary so Review can show it without another sync", async () => { clearCloudSyncStatus(); const summary: CloudSyncRunSummary = { diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index b251ab2e4..f07876cb4 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -21,6 +21,7 @@ import { cloudSyncRefusedPathsLabel, cloudSyncRefusedPathsMessage, describeCloudError, + isInternalVaultPath, type CloudErrorCopy, type CloudErrorKind, } from "@zennotes/shared-domain/cloud-error-copy"; @@ -466,14 +467,20 @@ function attentionMessageWithoutSettings( /** * What a run leaves on the status: null when nothing in it needs the person. - * Files Cloud refused are set aside and offered again on their own (#1004), - * so a run whose only news is those has synced everything it could: the - * status reads synced, the tray stays idle and a status bar turned off stays - * off. Settings → Cloud still lists each file with Cloud's answer, from the - * summary (cloudSyncAttentionMessage keeps the sentence for it). + * A file ZenNotes keeps for itself that Cloud refused is set aside and + * offered again on its own (#1004), so a run whose only news is such files + * has synced everything it could: the status reads synced, the tray stays + * idle and a status bar turned off stays off. Settings → Cloud still lists + * each file with Cloud's answer, from the summary. A note or attachment of + * the person's is different: it waits until they rename or move it, so it + * keeps the status on attention, in the calm words cloudSyncAttentionLabel + * gives it, until a run no longer holds it. */ function runAttentionMessage(summary: CloudSyncRunSummary): string | null { - return cloudSyncAttentionIsRefusedOnly(summary) ? null : cloudSyncAttentionMessage(summary); + const quiet = + cloudSyncAttentionIsRefusedOnly(summary) && + (summary.refused_paths ?? []).every((row) => isInternalVaultPath(row.path)); + return quiet ? null : cloudSyncAttentionMessage(summary); } /** True while sync waits for an answer about the vault settings. */ diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 723ae08d4..49590e80c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -249,7 +249,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'When Cloud reports a problem', body: - 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.\n\nWhen Cloud refuses one file, the rest of the vault keeps syncing, and the run counts as synced: the status bar reads **Synced** as usual (its tooltip adds **1 file stays on this device**), and Settings → Cloud lists the file, what to do about it, and Cloud\'s answer under Details. Nothing is lost. Usually it is a file ZenNotes keeps for itself under `.zennotes`, and there is nothing to change on your side: sync offers it to Cloud again once a day, so it goes up by itself once Cloud takes it; if it keeps coming back, copy the details and choose Report an Issue. If it is one of your notes or attachments, Cloud did not accept its name or location, most often because the path is longer than the 1,024 characters Cloud takes: give it a shorter name or move it to a folder nearer the top of the vault, and it syncs under the new path. A note you moved or renamed there stays in Cloud under its old name until then, so your other devices keep it.' + 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.\n\nWhen Cloud refuses one file, the rest of the vault keeps syncing, and Settings → Cloud lists the file, what to do about it, and Cloud\'s answer under Details. Nothing is lost. Usually it is a file ZenNotes keeps for itself under `.zennotes`, and there is nothing to change on your side: the run counts as synced, the status bar reads **Synced** as usual (its tooltip adds **1 file stays on this device**), and sync offers the file to Cloud again once a day, so it goes up by itself once Cloud takes it; if it keeps coming back, copy the details and choose Report an Issue. If it is one of your notes or attachments, it waits for you: the status bar reads **1 file stays on this device** until you fix it, even when you have turned the status bar off. Cloud did not accept its name or location, most often because the path is longer than the 1,024 characters Cloud takes: give it a shorter name or move it to a folder nearer the top of the vault, and it syncs under the new path. A note you moved or renamed there stays in Cloud under its old name until then, so your other devices keep it.' }, { title: 'Customize the look: themes vs. overrides', diff --git a/packages/shared-domain/src/cloud-error-copy.ts b/packages/shared-domain/src/cloud-error-copy.ts index e81f0d654..cbbf404db 100644 --- a/packages/shared-domain/src/cloud-error-copy.ts +++ b/packages/shared-domain/src/cloud-error-copy.ts @@ -342,7 +342,8 @@ export function cloudSyncRefusedPathRow(path: string): string { return "Cloud didn't accept this name or location, so the file stays on this device. Rename it or move it to another folder; Details has Cloud's answer." } -function isInternalVaultPath(path: string): boolean { +/** A file ZenNotes keeps for itself under `.zennotes`, never the person's to rename. */ +export function isInternalVaultPath(path: string): boolean { return path.toLowerCase().startsWith('.zennotes/') } From 4bed24c6349663f0ad72739e36f7fdab3ca45c65 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 19:18:27 -0500 Subject: [PATCH 24/53] Fix(menu): a / pressed as a menu opens keeps the find field A context menu takes the keyboard as it mounts and again on the next frame, in case the event that opened it moved focus elsewhere (#850). That frame took focus back unconditionally. With Vim on, the code language menu (#1014) opens on its list, and `/` moves to the find field; a `/` that landed before the frame lost the field to it, and the next letters went to the list. CI's ARM runner pressed it inside the first frame (PR #1019, ContextMenu.test.ts:309). The frame now takes focus back only when it has left the menu. Focus on the list or in the field stays where it is; the opener moving focus away is still undone, as #850 needs. Tests: with the frame held back, `/` then the frame leaves the field focused (on the old code the frame put it on the list, CI's exact assertion), and focus moved outside the menu before the frame still comes back to it. --- .../src/components/ContextMenu.test.ts | 32 +++++++++++++++++++ .../app-core/src/components/ContextMenu.tsx | 5 ++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/components/ContextMenu.test.ts b/packages/app-core/src/components/ContextMenu.test.ts index 64f46d4e4..ece0e1ef6 100644 --- a/packages/app-core/src/components/ContextMenu.test.ts +++ b/packages/app-core/src/components/ContextMenu.test.ts @@ -312,6 +312,38 @@ describe('ContextMenu with a find field (#1014)', () => { expect(rows()).toEqual(['Java', 'JavaScript', 'JSON']) }) + // The menu takes the keyboard again on the frame after it opens (#850). A `/` + // that landed before that frame lost the field to it: a CI runner pressed it + // inside the first frame. + it('leaves the field its focus when / lands before the menu’s next frame', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)) + vi.stubGlobal('cancelAnimationFrame', () => {}) + useStore.setState({ vimMode: true }) + await render() + await act(async () => keydown(menuEl(), '/')) + expect(document.activeElement).toBe(field()) + await act(async () => frames.splice(0).forEach((callback) => callback(0))) + expect(document.activeElement).toBe(field()) + }) + + it('still takes the keyboard back on its next frame when focus left the menu (#850)', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)) + vi.stubGlobal('cancelAnimationFrame', () => {}) + const elsewhere = document.createElement('button') + document.body.append(elsewhere) + try { + useStore.setState({ vimMode: true }) + await render() + elsewhere.focus() + await act(async () => frames.splice(0).forEach((callback) => callback(0))) + expect(document.activeElement).toBe(menuEl()) + } finally { + elsewhere.remove() + } + }) + it('opens on the list on a touch screen, so no keyboard covers it', async () => { vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(pointer: coarse)' })) await render() diff --git a/packages/app-core/src/components/ContextMenu.tsx b/packages/app-core/src/components/ContextMenu.tsx index 367c05d7a..e7258c353 100644 --- a/packages/app-core/src/components/ContextMenu.tsx +++ b/packages/app-core/src/components/ContextMenu.tsx @@ -140,7 +140,9 @@ export function ContextMenu({ x, y, items, title, findPlaceholder, anchorTop, on // Take the keyboard as the menu mounts, so a key pressed before the next // frame cannot reach whatever held focus (the calendar panel, after a // keyboard drop), and again on that frame in case the event that opened the - // menu moves focus after mount (#850). Closing hands focus back. + // menu moves focus after mount (#850). That frame only takes focus back from + // outside: a `/` pressed before it has already moved to the find field. + // Closing hands focus back. // // A menu with a find field gives it the keyboard when letters are text, // that is with Vim off; with Vim on the list keeps it, where j and k move @@ -151,6 +153,7 @@ export function ContextMenu({ x, y, items, title, findPlaceholder, anchorTop, on findPlaceholder && !useStore.getState().vimMode && !coarsePointer() ? findRef.current : ref.current target()?.focus({ preventScroll: true }) const id = requestAnimationFrame(() => { + if (ref.current?.contains(document.activeElement)) return target()?.focus({ preventScroll: true }) }) return () => { From b71b8c2c5628177e1dcf214e185e1456086b787d Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 19:18:27 -0500 Subject: [PATCH 25/53] Test(workflows): the applier's tests get a budget for a slow Windows disk PR #1019's Windows runner ran the whole workflow-apply file at 54 s against 10 to 15 s on main's last two runs, and every disk-heavy desktop file about three times slower with it (vault 6 to 22 s, the Cloud sync service 3 to 11 s), none of them changed on the branch. "Applies several ops across several files, in order" took 1.5 s on main and passed the 5 s default there. Every op in this file is real disk work, each file journalled and fsynced before the write it covers, so the file sets a 30 s timeout with the reason beside it. Nothing in the applier changes. --- apps/desktop/src/main/workflow-apply.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/main/workflow-apply.test.ts b/apps/desktop/src/main/workflow-apply.test.ts index 1601e91a6..6f62e143b 100644 --- a/apps/desktop/src/main/workflow-apply.test.ts +++ b/apps/desktop/src/main/workflow-apply.test.ts @@ -40,6 +40,12 @@ import { import { registerEphemeralRoot, unregisterEphemeralRoot } from './ephemeral-vaults' import { readNoteCreatedAt } from './note-creation-metadata' +// Every op here is real disk work, each file journalled and fsynced before +// the write it covers. A loaded Windows CI runner ran this file at 54 s +// against its usual 10 to 15 s (PR #1019), and a three-file run passed the +// 5 s default there; the budget is the disk's, not the code's. +vi.setConfig({ testTimeout: 30_000 }) + /** * Paths whose atomic write should fail, so the "the rollback itself failed" * branch can be exercised without a real broken disk. Hoisted because From f6413063869abaf3c802123db3bdb1993fae0628 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 09:13:35 -0500 Subject: [PATCH 26/53] Fix(daily notes): today's note from a template takes the rolled tasks, not a second note yeminn (Discord, 2026-10-29) found that opening today's daily note, with "Roll over unfinished tasks" on, a daily-note template and a Cloud-synced vault, made "2026-10-30 2.md" holding the rolled tasks while the editor showed an empty "2026-10-30.md". Any one of the three switched off and it worked. Creating from a template left the new note out of the note list until its own listing landed, and refreshNotes drops a listing that a newer one overtook. The watcher's echo of that very write starts the newer one, and while a Cloud run keeps main busy, the echo arrives before the template's listing does. The rollover, started fire-and-forget right after, looked for today's note in that list, found none, created it, and main gave the file the next free name. A note made without a template joined the list at once (createAndOpen), which is why None worked. Opening today's note now hands the note it just opened or made to the rollover (rolloverUnfinishedTasksIntoToday({ today })), so it never looks it up. createFromTemplate and ensureDailyNoteForDate also put the new note in the list at once, as createAndOpen does, and createFromTemplate resolves to the note it made. refreshNotes still drops an overtaken listing; the callers no longer depend on it. Reproduced in the built app first: with main's note listing held back 1.5 s (a stand-in for a busy Cloud run), the template made "<today> 2.md" with the task and opened the empty note; without the template it did not. Test: a template note whose listing a newer one overtakes, with the rollover looking before that one lands (on the old code createNote ran twice and the task went to "<today> 2.md"). --- packages/app-core/src/store.test.ts | 86 +++++++++++++++++++++++++++++ packages/app-core/src/store.ts | 33 ++++++++--- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 881a3437e..d18e5bb9c 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -502,6 +502,92 @@ describe('daily task rollover', () => { }) }) +describe('daily note from a template, with rollover (yeminn, Discord 2026-10-29)', () => { + // Opening today's note from a template created it, then started the rollover. + // The template path left the new note out of the note list until its own + // listing landed, and that listing is dropped when a newer one started in the + // meantime: the watcher's echo of the very same write does exactly that while + // a Cloud run keeps main busy. The rollover then found no note for today, + // created one, got "<today> 2.md" and moved the tasks there, while the editor + // showed the empty "<today>.md". + it('rolls the tasks into the note it opened when a newer listing overtakes the template\'s', async () => { + const iso = (date: Date) => [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0')].join('-') + const now = new Date() + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + const sourcePath = `inbox/Daily Notes/${iso(yesterday)}.md` + const targetPath = `inbox/Daily Notes/${iso(now)}.md` + const files = new Map([[sourcePath, `# ${iso(yesterday)}\n\n- [ ] Call the bank\n`]]) + const notes = () => [...files].map(([path, body]) => makeNote(body, path)) + const createNote = vi.fn(async (_folder: string, title: string, subpath: string) => { + // Main picks the next free name when the file is already there. + let path = `inbox/${subpath}/${title}.md` + for (let n = 2; files.has(path); n++) path = `inbox/${subpath}/${title} ${n}.md` + files.set(path, `# ${title}\n`) + return makeNote(files.get(path)!, path) + }) + // The first listing after the create is overtaken by a newer one (the + // watcher's echo), which lands only after the rollover has looked. + let release!: () => void + const echoLanded = new Promise<void>((resolve) => { + release = resolve + }) + let listings = 0 + const { useStore } = await loadStore() + installZen({ + createNote, + listNotes: vi.fn(async () => { + listings += 1 + if (listings === 1) { + const echo = useStore.getState().refreshNotes() + void echo + await new Promise((resolve) => setTimeout(resolve, 0)) + return notes() + } + if (listings === 2) { + await echoLanded + return notes() + } + return notes() + }), + listFolders: vi.fn(async () => []), + hasAssetsDir: vi.fn(async () => false), + readNote: vi.fn(async (path: string) => makeNote(files.get(path)!, path)), + writeNote: vi.fn(async (path: string, body: string) => { + files.set(path, body) + return makeNote(body, path) + }) + }) + useStore.setState({ + notes: notes(), + customTemplates: [{ + id: 'custom:daily', name: 'Daily', description: '', category: 'Custom', builtin: false, + body: '# {{title}}\n\n## Tasks\n\n' + }], + vaultSettings: { + ...useStore.getState().vaultSettings, + dailyNotes: { + enabled: true, directory: 'Daily Notes', templateId: 'custom:daily', rolloverUnfinishedTasks: true + } + } + }) + + try { + await useStore.getState().openDailyNoteForDate(now) + await vi.waitFor(() => expect(files.get(sourcePath)).not.toContain('Call the bank')) + } finally { + release() + } + + expect(createNote).toHaveBeenCalledOnce() + expect([...files.keys()].sort()).toEqual([sourcePath, targetPath].sort()) + const today = useStore.getState().noteContents[targetPath]?.body ?? files.get(targetPath) + expect(today).toContain('- [ ] Call the bank') + expect(useStore.getState().selectedPath).toBe(targetPath) + }) +}) + describe('weekly note patterns', () => { it('creates weekly notes using the configured directory and title patterns', async () => { const created = { diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 6dc0bb12d..e73ac215a 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -4055,10 +4055,11 @@ interface Store { /** Create + open a note from a template, substituting variables and placing * the caret at `{{cursor}}`. Falls back to a title prompt when the template * has no titleTemplate and no explicit title is supplied. */ + /** Resolves to the note it made, or null when cancelled or it failed. */ createFromTemplate: ( template: NoteTemplate, opts?: { folder?: NoteFolder; subpath?: string; title?: string; date?: Date } - ) => Promise<void> + ) => Promise<NoteMeta | null> saveActiveNoteAsTemplate: () => Promise<void> saveActiveNoteAs: (newName: string) => Promise<void> setWordWrap: (on: boolean) => void @@ -4092,6 +4093,8 @@ interface Store { rolloverUnfinishedTasksIntoToday: (opts?: { force?: boolean open?: boolean + /** Today's daily note when the caller has just opened or made it. */ + today?: NoteMeta }) => Promise<number> /** Mark the first-run onboarding as complete (or skipped). Persists. */ completeOnboarding: () => void @@ -10145,13 +10148,14 @@ export const useStore = create<Store>((set, get) => { if (!settings.dailyNotes.enabled) return const { title, subpath } = dailyNoteLocationForDate(date, settings) const existing = findDailyNoteForDate(state.notes, settings, date) + let opened: NoteMeta | null = existing ?? null if (existing) { set({ view: { kind: 'folder', folder: 'inbox', subpath } }) await get().selectNote(existing.path) } else { const template = resolveTemplate(state.customTemplates, settings.dailyNotes.templateId) if (template) { - await get().createFromTemplate(template, { folder: 'inbox', subpath, title, date }) + opened = await get().createFromTemplate(template, { folder: 'inbox', subpath, title, date }) } else { await get().createAndOpen('inbox', subpath, { title }) } @@ -10164,9 +10168,12 @@ export const useStore = create<Store>((set, get) => { requestEditorFocus({ read: !!existing && options?.readIfExisting === true }) // Opening *today's* note rolls unfinished tasks forward from past daily // notes (Obsidian-style) when enabled. Fire-and-forget so the note shows - // right away; the rollover appends into the now-open buffer. + // right away; the rollover appends into the now-open buffer. It is handed + // the note just opened or made rather than looking it up: the note list + // can trail a note made a moment ago, and a rollover that found no note + // for today made a second one ("<today> 2.md") and filled that instead. if (noteTitleForDate(date) === noteTitleForDate(new Date())) { - void get().rolloverUnfinishedTasksIntoToday() + void get().rolloverUnfinishedTasksIntoToday(opened ? { today: opened } : undefined) } }, @@ -10186,6 +10193,10 @@ export const useStore = create<Store>((set, get) => { try { const meta = await window.zen.createNote('inbox', title, subpath) rememberEditModeForCreatedNote(meta.path) + // In the list at once, as createAndOpen does: the listing below is + // dropped when a newer one starts first (the watcher's echo of this very + // write does), and a second call meanwhile must find today's note. + set((s) => ({ notes: s.notes.some((n) => n.path === meta.path) ? s.notes : [...s.notes, meta] })) if (body) await window.zen.writeNote(meta.path, body) emitNoteEvent('note-created', meta.path) await get().refreshNotes() @@ -10248,7 +10259,7 @@ export const useStore = create<Store>((set, get) => { const todayIso = noteTitleForDate(today) const vaultRoot = get().vault?.root ?? '' if (!force && !settings.dailyNotes.rolloverUnfinishedTasks) return 0 - const todayNote = await get().ensureDailyNoteForDate(today) + const todayNote = opts?.today ?? (await get().ensureDailyNoteForDate(today)) if (!todayNote) return 0 if (opts?.open) { const { subpath } = dailyNoteLocationForDate(today, settings) @@ -10475,7 +10486,7 @@ export const useStore = create<Store>((set, get) => { await get().loadCustomTemplates() }, - createFromTemplate: trackNoteWrite(undefined, async (template, opts) => { + createFromTemplate: trackNoteWrite(null, async (template, opts) => { try { // 1. Destination. An explicit folder (e.g. right-click on a folder) is // used directly; otherwise prompt, defaulting to the vault root so the @@ -10496,7 +10507,7 @@ export const useStore = create<Store>((set, get) => { const entered = await promptApp( buildTemplateDestinationPrompt(template.name, initialPath, state.folders) ) - if (entered == null) return // cancelled + if (entered == null) return null // cancelled const dest = parseTemplateDestination(entered) folder = dest.folder subpath = dest.subpath @@ -10513,13 +10524,17 @@ export const useStore = create<Store>((set, get) => { initialValue: template.name, okLabel: 'Create' }) - if (entered == null) return // cancelled + if (entered == null) return null // cancelled title = entered.trim() } if (!title) title = template.name const { body, cursorOffset } = renderTemplate(template.body, { title, now: opts?.date }) const meta = await window.zen.createNote(folder, title, subpath) rememberEditModeForCreatedNote(meta.path) + // In the list at once, as createAndOpen does: the listing below is + // dropped when a newer one starts first, which the watcher's echo of + // this very write does while main is busy (a Cloud run hashing the vault). + set((s) => ({ notes: s.notes.some((n) => n.path === meta.path) ? s.notes : [...s.notes, meta] })) emitNoteEvent('note-created', meta.path) // Write the rendered body before opening so the editor never flashes the // default `# Title` scaffold (mirrors importDroppedMarkdownFiles). @@ -10536,8 +10551,10 @@ export const useStore = create<Store>((set, get) => { // command palette, a folder menu — where focus would otherwise stay on // the picker/prompt that just closed. (#436, mirrors the daily-note flow) requestEditorFocus() + return get().notes.find((n) => n.path === meta.path) ?? meta } catch (err) { console.error('createFromTemplate failed', err) + return null } }), From 156269b6b91803ba8abe7c24ee31db451ef2964c Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 09:13:56 -0500 Subject: [PATCH 27/53] Fix(notes): an edit to a note no pane shows is saved even when the note list refreshes first Found while verifying the daily-note fix with main busy: the rollover moved a task into today's note but left it in yesterday's as well, so the next open would roll it again. The rollover trims a past note through its cached buffer when the app holds one (in the reproduction yesterday's note was cached without being open; the rollover never read it from disk), and updateNoteBody saves it 350 ms later. refreshNotes pruned every buffer no pane, pinned pane or link popover holds, unsaved edits included, and persistNote writes only a buffer that is still there. A listing that landed inside those 350 ms dropped the trim unwritten. Any edit the app makes to a note nobody has open (the rollover, the task lists) ran the same risk. A buffer with an unsaved edit, a save timer or a save in flight now stays through a listing, open or not, and the first listing after its save lets it go. Tests: an edit to a cached note no pane shows is written when a listing lands before its save (on the old code nothing was written), and the buffer is released by the next listing once saved. In the built app, with main's listing held back 1.5 s, yesterday's note now gives the task up in every case (template or not, local or Cloud). --- packages/app-core/src/store.test.ts | 55 +++++++++++++++++++++++++++++ packages/app-core/src/store.ts | 10 ++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index d18e5bb9c..6c9b6e588 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -588,6 +588,61 @@ describe('daily note from a template, with rollover (yeminn, Discord 2026-10-29) }) }) +describe('an edit to a note cached but open in no pane', () => { + // The rollover trims a past daily note through its cached buffer when there + // is one, and the save runs PATH_SAVE_DEBOUNCE_MS later. A note listing that + // landed first dropped every buffer no pane shows, unsaved edits included, + // so the save found nothing to write and the task stayed in yesterday's note + // as well as today's (seen with a busy main, yeminn, Discord 2026-10-29). + // A listing carries metadata only, never a body. + const listed = (body: string, path: string) => { + const { body: _body, ...meta } = makeNote(body, path) + return meta + } + + it('is still saved when a note listing lands before its save', async () => { + const path = 'inbox/Daily Notes/2026-10-29.md' + const writeNote = vi.fn(async (target: string, body: string) => makeNote(body, target)) + installZen({ + listNotes: vi.fn(async () => [listed('# 2026-10-29\n\n- [ ] Call the bank\n', path)]), + writeNote + }) + const { useStore } = await loadStore() + useStore.setState({ + notes: [makeNote('# 2026-10-29\n\n- [ ] Call the bank\n', path)], + noteContents: { [path]: makeNote('# 2026-10-29\n\n- [ ] Call the bank\n', path) } + }) + + await onFakeClock(1000, async () => { + useStore.getState().updateNoteBody(path, '# 2026-10-29\n\n') + await useStore.getState().refreshNotes() + }) + + expect(writeNote).toHaveBeenCalledWith(path, '# 2026-10-29\n\n') + }) + + it('is let go once saved, by the next listing', async () => { + const path = 'inbox/Daily Notes/2026-10-29.md' + installZen({ + listNotes: vi.fn(async () => [listed('# 2026-10-29\n\n', path)]), + writeNote: vi.fn(async (target: string, body: string) => makeNote(body, target)) + }) + const { useStore } = await loadStore() + useStore.setState({ + notes: [makeNote('# 2026-10-29\n\n- [ ] Call the bank\n', path)], + noteContents: { [path]: makeNote('# 2026-10-29\n\n- [ ] Call the bank\n', path) } + }) + + await onFakeClock(1000, async () => { + useStore.getState().updateNoteBody(path, '# 2026-10-29\n\n') + }) + await useStore.getState().refreshNotes() + + expect(useStore.getState().noteDirty[path]).toBeUndefined() + expect(useStore.getState().noteContents[path]).toBeUndefined() + }) +}) + describe('weekly note patterns', () => { it('creates weekly notes using the configured directory and title patterns', async () => { const created = { diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index e73ac215a..1d9b444bc 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -8132,15 +8132,21 @@ export const useStore = create<Store>((set, get) => { if (pinnedRefPath) referenced.add(pinnedRefPath) // The link popover edits a cached note no pane may have open. for (const held of linkPopoverHeldPaths(s)) referenced.add(held) + // A buffer with an edit not yet on disk stays until its save lands, + // open or not: the rollover and the task lists edit notes no pane + // shows, and a listing that landed between such an edit and its + // debounced save used to drop the edit unwritten. + const unsaved = (path: string): boolean => + s.noteDirty[path] === true || pathSaveTimers.has(path) || pathSaveQueues.has(path) const contents: Record<string, NoteContent> = {} const dirty: Record<string, boolean> = {} for (const [path, content] of Object.entries(s.noteContents)) { - if (!referenced.has(path)) continue + if (!referenced.has(path) && !unsaved(path)) continue const latestMeta = noteMetaByPath.get(path) contents[path] = latestMeta ? { ...content, ...latestMeta } : content } for (const [path, isDirty] of Object.entries(s.noteDirty)) { - if (referenced.has(path)) dirty[path] = isDirty + if (referenced.has(path) || unsaved(path)) dirty[path] = isDirty } const next = { notes: From 6197e500a7bf98a7396748d40e86d319507ee70f Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 09:22:51 -0500 Subject: [PATCH 28/53] Fix(tour): the start note's numbered list keeps its text in one column past 9 Julie (Discord, 2026-10-30) noticed that the numbered list under "The tour" in 00 Start Here moves its text one column to the right at item 10, and asked for 1 to 9 to carry a leading space, the way she writes her own lists. Items 1 to 9 now start with a space, so " 9." sits above "10." and every item's text starts in the same column, in the source and in Edit. A marker indented by up to three spaces is still a list item in CommonMark, so the editor and Preview still read one list of 21 items counting from 1. Preview's HTML is the same as before; it already lines the numbers up on its own. The editor itself never pads: Enter, o and O, the / menu and "Turn into Numbered list" each write "N. ", and the renumber pass rewrites only the digits, so a list padded by hand goes on as " 10." after " 9.". The tour is written the way Julie described, not the way the editor would extend it. Tests: the start note's markers are padded to the widest number (fails on the old text), and Preview renders one <ol> of 21 items, with no start attribute and no paragraphs. In the built app, a tour generated from the palette into a scratch vault put every item's dot at one x and every item's text at another in Edit, and Preview showed one list from 1 to 21. The server writes the tour from its own copy (demo-tour.json in ZenNotes/znserver), which still has the old list. --- packages/app-core/src/lib/demo-tour.test.ts | 26 ++++++++++++++++++++ packages/shared-domain/src/demo-tour-data.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/lib/demo-tour.test.ts b/packages/app-core/src/lib/demo-tour.test.ts index 2c5fab5b4..b433f8dbf 100644 --- a/packages/app-core/src/lib/demo-tour.test.ts +++ b/packages/app-core/src/lib/demo-tour.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { describe, expect, it } from 'vitest' import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@shared/demo-tour-data' import { @@ -7,6 +9,7 @@ import { isDemoTourStartPath } from '@shared/demo-tour' import { formDirFromCsvPath, formTitleFromCsvPath } from '@shared/databases' +import { renderMarkdown } from './markdown' import { extractWikilinkTargets, resolveWikilinkTarget } from './wikilinks' // Built from its code point so this file never contains the character itself. @@ -60,6 +63,29 @@ describe('demo tour vault', () => { }) }) +describe('the numbered tour on the start note', () => { + const start = DEMO_TOUR_NOTES.find((note) => note.path === DEMO_TOUR_START_PATH)!.body + const items = start.split('\n').filter((line) => /^ *\d+\. /.test(line)) + + // " 9. " above "10. " keeps every item's text in one column. + it('pads the shorter numbers so the text lines up', () => { + const width = String(items.length).length + expect(items.length).toBeGreaterThan(9) + expect(items.map((line) => /^ *\d+\. /.exec(line)![0])).toEqual( + items.map((_, index) => `${String(index + 1).padStart(width)}. `) + ) + }) + + it('stays one list counting from 1 in Preview', () => { + const html = renderMarkdown(start) + const list = html.slice(html.indexOf('<ol'), html.indexOf('</ol>')) + expect(html.match(/<ol[ >]/g)).toHaveLength(1) + expect(list).not.toMatch(/^<ol[^>]* start=/) + expect(list.match(/<li[ >]/g)).toHaveLength(items.length) + expect(list).not.toContain('<p') + }) +}) + describe('demo tour notes under their older names', () => { const olderStart = `inbox/demo/00 ${LONG_DASH} Start Here.md` diff --git a/packages/shared-domain/src/demo-tour-data.ts b/packages/shared-domain/src/demo-tour-data.ts index 5b5f8ce36..678792b28 100644 --- a/packages/shared-domain/src/demo-tour-data.ts +++ b/packages/shared-domain/src/demo-tour-data.ts @@ -6,7 +6,7 @@ export interface DemoTourTemplateFile { export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ { path: "inbox/demo/00 Start Here.md", - body: "# Start here: ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 Markdown Basics]]: headings, emphasis, lists, links, frontmatter, slash commands, and formatting a selection\n2. [[02 Code Blocks]]: fenced code, syntax highlighting, and languages you add yourself\n3. [[03 Tables and Task Lists]]: editable tables, task states, fields, subtasks, and the Tasks list\n4. [[04 Math with KaTeX]]: inline and display math, numbered equations, and Typst as an alternative\n5. [[05 Mermaid Diagrams]]: flow, sequence, state, gantt, and pie diagrams, drawn in your theme\n6. [[05b Math Diagrams]]: TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[05c D2 Diagrams]]: D2 architecture diagrams, with nested containers, data models, and sequences\n8. [[06 Callouts and Footnotes]]: callouts, footnotes, highlights, images, sizes, video embeds, and local files\n9. [[07 Wiki Links and Tags]]: wikilinks, block links, embeds, tags, backlinks, and the Atlas map\n10. [[08 Daily Notes]]: daily, weekly, and monthly notes, quick capture, and date shortcuts\n11. [[09 Vim Cheat Sheet]]: the app-specific motions, leader flows, folds, and ex commands\n12. [[10 Ideas and Tasks]]: a realistic note that composes multiple features at once\n13. [[11 Workspace, Search, and Views]]: home, tabs, splits, panels, comments, archive, trash, and session restore\n14. [[12 Settings and Keymaps]]: themes, CSS overrides, editor options, remappable keys, and `config.toml`\n15. [[13 Commands, Help, and Demo Tour]]: command palette discovery, ex commands, built-in Help, PDF export, and this tour\n16. [[14 Reference Pane and Floating Windows]]: pinned notes, research context, and detached note windows\n17. [[15 Search Backends and Fuzzy Workflows]]: note search, vault text search, find in a note, fzf, and ripgrep\n18. [[16 Tasks Calendar and Kanban]]: the Tasks calendar, Kanban boards, saved filters, and whole notes as tasks\n19. [[17 Templates, Drawings, and Databases]]: templates, Excalidraw drawings, and spreadsheet-like databases\n20. [[18 Workflows]]: repeatable changes across many notes, planned before anything is written\n21. [[19 Cloud, CLI, and AI Agents]]: ZenNotes Cloud, the tray, the `zn` CLI, and assistants over MCP\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading and list folding and outline jumps\n- wikilinks, block links, embeds, tags, backlinks, and the Atlas map\n- quick capture from anywhere, and Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks as a list, a calendar, and Kanban boards\n- templates, Excalidraw drawings, and databases\n- note search, vault text search, and find in a note\n- Mermaid, D2, TikZ, JSXGraph, and function-plot diagrams\n- KaTeX or Typst math\n- slash commands, `@` dates, and text replacements\n- comments on selected text\n- Vim mode, leader hints, hint mode, ex commands, and pane motion\n- themes, CSS overrides, keymaps, and a portable `config.toml`\n- workflows that plan before they write\n- ZenNotes Cloud, the `zn` CLI, and AI assistants over MCP\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n^portable\n\n#demo #reference #tour\n" + body: "# Start here: ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n 1. [[01 Markdown Basics]]: headings, emphasis, lists, links, frontmatter, slash commands, and formatting a selection\n 2. [[02 Code Blocks]]: fenced code, syntax highlighting, and languages you add yourself\n 3. [[03 Tables and Task Lists]]: editable tables, task states, fields, subtasks, and the Tasks list\n 4. [[04 Math with KaTeX]]: inline and display math, numbered equations, and Typst as an alternative\n 5. [[05 Mermaid Diagrams]]: flow, sequence, state, gantt, and pie diagrams, drawn in your theme\n 6. [[05b Math Diagrams]]: TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n 7. [[05c D2 Diagrams]]: D2 architecture diagrams, with nested containers, data models, and sequences\n 8. [[06 Callouts and Footnotes]]: callouts, footnotes, highlights, images, sizes, video embeds, and local files\n 9. [[07 Wiki Links and Tags]]: wikilinks, block links, embeds, tags, backlinks, and the Atlas map\n10. [[08 Daily Notes]]: daily, weekly, and monthly notes, quick capture, and date shortcuts\n11. [[09 Vim Cheat Sheet]]: the app-specific motions, leader flows, folds, and ex commands\n12. [[10 Ideas and Tasks]]: a realistic note that composes multiple features at once\n13. [[11 Workspace, Search, and Views]]: home, tabs, splits, panels, comments, archive, trash, and session restore\n14. [[12 Settings and Keymaps]]: themes, CSS overrides, editor options, remappable keys, and `config.toml`\n15. [[13 Commands, Help, and Demo Tour]]: command palette discovery, ex commands, built-in Help, PDF export, and this tour\n16. [[14 Reference Pane and Floating Windows]]: pinned notes, research context, and detached note windows\n17. [[15 Search Backends and Fuzzy Workflows]]: note search, vault text search, find in a note, fzf, and ripgrep\n18. [[16 Tasks Calendar and Kanban]]: the Tasks calendar, Kanban boards, saved filters, and whole notes as tasks\n19. [[17 Templates, Drawings, and Databases]]: templates, Excalidraw drawings, and spreadsheet-like databases\n20. [[18 Workflows]]: repeatable changes across many notes, planned before anything is written\n21. [[19 Cloud, CLI, and AI Agents]]: ZenNotes Cloud, the tray, the `zn` CLI, and assistants over MCP\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading and list folding and outline jumps\n- wikilinks, block links, embeds, tags, backlinks, and the Atlas map\n- quick capture from anywhere, and Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks as a list, a calendar, and Kanban boards\n- templates, Excalidraw drawings, and databases\n- note search, vault text search, and find in a note\n- Mermaid, D2, TikZ, JSXGraph, and function-plot diagrams\n- KaTeX or Typst math\n- slash commands, `@` dates, and text replacements\n- comments on selected text\n- Vim mode, leader hints, hint mode, ex commands, and pane motion\n- themes, CSS overrides, keymaps, and a portable `config.toml`\n- workflows that plan before they write\n- ZenNotes Cloud, the `zn` CLI, and AI assistants over MCP\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n^portable\n\n#demo #reference #tour\n" }, { path: "inbox/demo/01 Markdown Basics.md", From cd3596168ca227b95946915c776bf0d84b763ef3 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 09:33:36 -0500 Subject: [PATCH 29/53] Test(watcher): the macOS echo of the test vault's own folder is not the event under test PR #1019's macOS run failed "announces a template file under its own scope" at watcher.test.ts:286: the first event was not the template but an add for "99 - Archive", the folder makeVault creates a moment before start(). Since chokidar 6 (#999) macOS watches the vault through FSEvents, and the stream can still report that mkdir after the watcher started. The same run on b71b8c2c passed, and the event is the fixture's setup, not anything the test did. The file names that echo once (isSetupEcho) and skips it where tests wait for the first event or expect none: waitForEvent, the startup window's held events, the atomic save's scratch write, and the templates test's ignored files. Nothing in the watcher changes; a stray folder add at startup costs the app one extra note listing. --- apps/desktop/src/main/watcher.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/watcher.test.ts b/apps/desktop/src/main/watcher.test.ts index 338a02298..fb1b9708a 100644 --- a/apps/desktop/src/main/watcher.test.ts +++ b/apps/desktop/src/main/watcher.test.ts @@ -41,9 +41,19 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } { const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)) /** Wait for `events` to be non-empty, or give up. */ +// macOS watches the vault through FSEvents since chokidar 6 (#999), and the +// stream can report makeVault's own `99 - Archive` folder, made a moment before +// start(), as an add after it (PR #1019's macOS run). That event is the +// fixture's, not what a test is waiting for. +const isSetupEcho = (event: VaultChangeEvent): boolean => + event.scope === 'folder' && event.path === '99 - Archive' +const testEvents = (events: VaultChangeEvent[]): VaultChangeEvent[] => + events.filter((event) => !isSetupEcho(event)) + async function waitForEvent(events: VaultChangeEvent[]): Promise<VaultChangeEvent | null> { for (let i = 0; i < 60; i++) { - if (events.length > 0) return events[0] as VaultChangeEvent + const [first] = testEvents(events) + if (first) return first await sleep(50) } return null @@ -72,7 +82,7 @@ describe('VaultWatcher settings startup window', () => { // Long past chokidar's awaitWriteFinish window: without the buffer the // event would already have been delivered, classified as `inbox`. await sleep(600) - expect(events).toEqual([]) + expect(testEvents(events)).toEqual([]) settings.resolve(settingsWithArchiveRemap()) const first = await waitForEvent(events) @@ -116,7 +126,7 @@ describe('VaultWatcher atomic saves', () => { const scratch = path.join(root, 'Daily.md.3252272.1787800172047252.tmp') await writeFile(scratch, '# Daily\n') await sleep(250) - expect(events).toEqual([]) + expect(testEvents(events)).toEqual([]) await rename(scratch, path.join(root, 'Daily.md')) const completed = await waitForEvent(events) @@ -294,7 +304,7 @@ describe('VaultWatcher custom templates (#723)', () => { await writeFile(path.join(dir, '.draft.md'), 'hidden') await writeFile(path.join(dir, 'notes.txt'), 'text') await sleep(600) - expect(events).toEqual([]) + expect(testEvents(events)).toEqual([]) }, 15_000 ) From f3d7476a984e74d348ea2a731707981a4829caba Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 09:54:42 -0500 Subject: [PATCH 30/53] Feat(editor): a numbered list padded to line up its numbers stays lined up as it grows Julie (Discord, 2026-10-30) pads 1 to 9 with a leading space so the numbers line up with 10 and up. The editor kept that padding through no edit: Enter on ` 9. i` copied the item's space and wrote ` 10. `, Vim o/O did the same through listContinuationPrefix, and the renumber pass rewrote only digits, so an item inserted mid-list turned ` 9.` into ` 10.`. The first edit that crossed a width broke the alignment. The renumber pass, which already runs after Enter, o/O, moves, pastes and deletes, now keeps a list right-aligned when it was right-aligned before the edit. Every marker ends in one column: a marker that widens gives up a space (` 9.` to `10.`), one that narrows takes one (`10.` to ` 9.`), and every marker gains a space when the list outgrows the column (99 to 100). The item text keeps one start column, so a child indented under ` 9.` stays inside it once it is `10.`. The realignment rides on the same transaction, so one undo takes it back with the edit. Telling padding from indentation: the lead is measured from the ListItem's start, which Lezer puts at the container's content column, so the indentation that nests a list never counts. In a top-level list (in the document or a quote) any leading space is padding: CommonMark ignores up to three there and no editor route writes them, so ` 1.` to ` 9.` alone is enough. In a nested list a uniform lead is indentation (Tab writes four spaces, one past `1. ` and two past `- `), so only markers of different widths that end in one column prove padding. The list is judged as it stood before the edit, found through an item the edit left alone, because Enter and o leave every padded list misaligned for a moment. What it does not do: a list nobody padded keeps today's spacing (`9.` then `10.`), whichever route wrote it; a list whose markers were not aligned (` 9.` then ` 10.`, as Enter wrote them until now) is left as it is; a space typed or deleted in front of a marker by hand is kept, so the pass never fights someone padding or unpadding a list; and nothing is padded past three spaces. Tests: Enter, o and O after a padded ` 9.`; an item inserted, pasted, moved and deleted inside a padded list; 99 to 100; a child kept nested; a padded list in a quote; a nested list padded by hand; hand edits kept; one undo step through history. Unchanged: an unpadded list through Enter, o, an insert and a move, an unaligned list, and nested lists under `1. ` and `- `. The eleven padded cases fail on the old pass. In the built app over CDP (both stores isolated, Vim on): A, Enter after ` 9. i` gives `10. j`; O on ` 9. i` gives ` 9. x` above `10. i`; o after an unpadded `9. i` gives `10. j`; o in a Tab-nested list gives ` 10. j`; Enter mid-list in a 12-item padded list realigns it and one u restores it; the saved file matches the editor. --- .../src/lib/cm-ordered-list-renumber.test.ts | 266 +++++++++++++++++- .../src/lib/cm-ordered-list-renumber.ts | 205 ++++++++++++-- 2 files changed, 436 insertions(+), 35 deletions(-) diff --git a/packages/app-core/src/lib/cm-ordered-list-renumber.test.ts b/packages/app-core/src/lib/cm-ordered-list-renumber.test.ts index 99a5c9949..4e94d5ed2 100644 --- a/packages/app-core/src/lib/cm-ordered-list-renumber.test.ts +++ b/packages/app-core/src/lib/cm-ordered-list-renumber.test.ts @@ -1,8 +1,14 @@ -import { markdown, markdownLanguage } from '@codemirror/lang-markdown' -import { ensureSyntaxTree } from '@codemirror/language' +import { history, undo, undoDepth } from '@codemirror/commands' +import { + insertNewlineContinueMarkup, + markdown, + markdownLanguage +} from '@codemirror/lang-markdown' +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' import { EditorState, type ChangeSpec, + type Extension, type TransactionSpec } from '@codemirror/state' import { describe, expect, it } from 'vitest' @@ -10,11 +16,21 @@ import { orderedListRenumber, skipOrderedListRenumber } from './cm-ordered-list-renumber' +import { listContinuationPrefix } from './list-continuation' -function makeState(doc: string): EditorState { +function makeState( + doc: string, + cursor = 0, + extensions: Extension[] = [] +): EditorState { const state = EditorState.create({ doc, - extensions: [markdown({ base: markdownLanguage }), orderedListRenumber] + selection: { anchor: cursor }, + extensions: [ + markdown({ base: markdownLanguage }), + orderedListRenumber, + ...extensions + ] }) // Force a full parse so the transactionExtender can rely on a // complete syntax tree when it walks for OrderedList nodes. @@ -164,3 +180,245 @@ describe('orderedListRenumber', () => { expect(undone.state.doc.toString()).toBe(before) }) }) + +/** + * One item per entry of `texts`, numbered from 1. `width` right-aligns the + * numbers to that many digits (` 9.` above `10.`); 0 writes them bare. + */ +function items(texts: string[], width = 0, indent = ''): string { + return texts + .map((text, i) => `${indent}${String(i + 1).padStart(width)}. ${text}`) + .join('\n') +} + +function letters(count: number): string[] { + return Array.from({ length: count }, (_, i) => + String.fromCharCode(97 + (i % 26)) + ) +} + +/** Press Enter (lang-markdown's list continuation) with the cursor at `pos`. */ +function pressEnter(doc: string, pos: number): { doc: string; head: number } { + let state = makeState(doc, pos) + insertNewlineContinueMarkup({ + state, + dispatch: (tr) => { + state = tr.state + } + }) + return { doc: state.doc.toString(), head: state.selection.main.head } +} + +/** Vim `o` (below) or `O` (above) on 1-based `lineNumber`, as cm-vim-open-line writes it. */ +function openLine(doc: string, lineNumber: number, below: boolean): string { + const state = makeState(doc) + const line = state.doc.line(lineNumber) + const prefix = listContinuationPrefix(line.text) + if (prefix == null) throw new Error(`line ${lineNumber} is not a list item`) + const changes = below + ? { from: line.to, insert: `\n${prefix}` } + : { from: line.from, insert: `${prefix}\n` } + return state.update({ changes }).state.doc.toString() +} + +function endOfLine(doc: string, lineNumber: number): number { + return makeState(doc).doc.line(lineNumber).to +} + +describe('orderedListRenumber on right-aligned lists', () => { + it('Enter after ` 9.` writes `10.`, not the ` 10.` lang-markdown copies', () => { + // A top-level list whose numbers carry a leading space is padded + // ahead of its tenth item, as Julie writes them. + const doc = items(letters(9), 2) + const result = pressEnter(doc, doc.length) + expect(result.doc).toBe(`${doc}\n10. `) + expect(result.head).toBe(result.doc.length) + }) + + it('Vim `o` after ` 9.` writes `10.`', () => { + const doc = items(letters(9), 2) + expect(openLine(doc, 9, true)).toBe(`${doc}\n10. `) + }) + + it('Vim `O` above ` 9.` keeps both markers in line', () => { + const doc = items(letters(9), 2) + expect(openLine(doc, 9, false)).toBe( + items([...letters(8), '', 'i'], 2) + ) + }) + + it('an item added mid-list moves ` 9.` to `10.` and drops its space', () => { + const doc = items(letters(12), 2) + const result = pressEnter(doc, endOfLine(doc, 5)) + const texts = letters(12) + texts.splice(5, 0, '') + expect(result.doc).toBe(items(texts, 2)) + }) + + it('pads an unpadded item pasted into a padded list', () => { + const doc = items(letters(10), 2) + const at = makeState(doc).doc.line(3).from + const result = applyAndRead(doc, { from: at, insert: '1. new\n' }) + const texts = letters(10) + texts.splice(2, 0, 'new') + expect(result).toBe(items(texts, 2)) + }) + + it('keeps the alignment when an item moves across `10.`', () => { + // Alt+Down on ` 9. i`: one change rewrites both lines. + const doc = items(letters(11), 2) + const state = makeState(doc) + const from = state.doc.line(9).from + const to = state.doc.line(10).to + const result = state + .update({ changes: { from, to, insert: '10. j\n 9. i' } }) + .state.doc.toString() + const texts = letters(11) + texts.splice(8, 2, 'j', 'i') + expect(result).toBe(items(texts, 2)) + }) + + it('a deleted item narrows `10.` to ` 9.`', () => { + const doc = items(letters(10), 2) + const state = makeState(doc) + const line = state.doc.line(4) + const result = state + .update({ changes: { from: line.from, to: line.to + 1 } }) + .state.doc.toString() + const texts = letters(10) + texts.splice(3, 1) + expect(result).toBe(items(texts, 2)) + }) + + it('pads every marker once the list outgrows the column (99 to 100)', () => { + const doc = items(letters(99), 2) + const result = pressEnter(doc, doc.length) + expect(result.doc).toBe(items([...letters(99), ''], 3)) + }) + + it('keeps a child nested when its item widens from ` 9.` to `10.`', () => { + // Right-aligned markers keep every item's text in one column, so the + // child indented to that column stays inside its item. + const doc = [items(letters(9), 2), ' - child', '10. j'].join('\n') + const result = pressEnter(doc, endOfLine(doc, 1)) + expect(result.doc).toBe( + [items(['a', '', ...letters(9).slice(1)], 2), ' - child', '11. j'].join( + '\n' + ) + ) + const state = makeState(result.doc) + const bullet = syntaxTree(state).resolveInner( + result.doc.indexOf('- child'), + 1 + ) + expect(bullet.parent?.parent?.name).toBe('BulletList') + expect(bullet.parent?.parent?.parent?.name).toBe('ListItem') + }) + + it('keeps a padded list inside a quote aligned', () => { + const doc = items(letters(9), 2) + .split('\n') + .map((line) => `> ${line}`) + .join('\n') + const result = pressEnter(doc, doc.length) + expect(result.doc).toBe(`${doc}\n> 10. `) + }) + + it('keeps a nested list padded by hand aligned', () => { + // Under `1. outer` the content column is 3. Markers of two widths + // ending in one column prove the padding. + const inner = items(letters(10), 2, ' ') + const doc = `1. outer\n${inner}` + const result = pressEnter(doc, endOfLine(doc, 6)) + const texts = letters(10) + texts.splice(5, 0, '') + expect(result.doc).toBe(`1. outer\n${items(texts, 2, ' ')}`) + }) + + it('leaves a marker someone just unpadded by hand', () => { + const doc = items(letters(10), 2) + const result = applyAndRead(doc, { from: 0, to: 1 }) + expect(result).toBe(doc.slice(1)) + }) + + it('leaves a marker someone just padded further by hand', () => { + const doc = items(letters(10), 2) + const result = applyAndRead(doc, { from: 0, insert: ' ' }) + expect(result).toBe(` ${doc}`) + }) + + it('undoes Enter and the realignment in one step', () => { + const doc = items(letters(12), 2) + let state = makeState(doc, endOfLine(doc, 5), [history()]) + insertNewlineContinueMarkup({ + state, + dispatch: (tr) => { + state = tr.state + } + }) + expect(state.doc.toString()).not.toBe(doc) + expect(undoDepth(state)).toBe(1) + undo({ + state, + dispatch: (tr) => { + state = tr.state + } + }) + expect(state.doc.toString()).toBe(doc) + }) +}) + +describe('orderedListRenumber on lists nobody padded', () => { + it('Enter after `9.` still writes `10.`', () => { + const doc = items(letters(9)) + expect(pressEnter(doc, doc.length).doc).toBe(`${doc}\n10. `) + }) + + it('Vim `o` after `9.` still writes `10.`', () => { + const doc = items(letters(9)) + expect(openLine(doc, 9, true)).toBe(`${doc}\n10. `) + }) + + it('an item added mid-list only changes digits', () => { + const doc = items(letters(12)) + const result = pressEnter(doc, endOfLine(doc, 5)) + const texts = letters(12) + texts.splice(5, 0, '') + expect(result.doc).toBe(items(texts)) + }) + + it('a moved item only changes digits', () => { + const doc = items(letters(11)) + const state = makeState(doc) + const from = state.doc.line(9).from + const to = state.doc.line(10).to + const result = state + .update({ changes: { from, to, insert: '10. j\n9. i' } }) + .state.doc.toString() + const texts = letters(11) + texts.splice(8, 2, 'j', 'i') + expect(result).toBe(items(texts)) + }) + + it('keeps the spaces of a list that was not aligned', () => { + // ` 9.` then ` 10.`: what Enter wrote before this pass aligned lists. + const doc = ' 8. h\n 9. i\n 10. j' + expect(pressEnter(doc, doc.length).doc).toBe(`${doc}\n 11. `) + }) + + it('keeps the indentation of a nested list under `1. `', () => { + // Tab writes four spaces: one past the parent's content column. + const doc = `1. outer\n${items(letters(9), 0, ' ')}` + expect(pressEnter(doc, doc.length).doc).toBe(`${doc}\n 10. `) + }) + + it('keeps the indentation of a nested list under `- `', () => { + // Two past the parent's content column. + const doc = `- outer\n${items(letters(9), 0, ' ')}` + expect(pressEnter(doc, doc.length).doc).toBe(`${doc}\n 10. `) + const inserted = items(['new', ...letters(9)], 0, ' ') + expect( + applyAndRead(doc, { from: '- outer\n'.length, insert: ' 1. new\n' }) + ).toBe(`- outer\n${inserted}`) + }) +}) diff --git a/packages/app-core/src/lib/cm-ordered-list-renumber.ts b/packages/app-core/src/lib/cm-ordered-list-renumber.ts index 57bdce171..a80c0b522 100644 --- a/packages/app-core/src/lib/cm-ordered-list-renumber.ts +++ b/packages/app-core/src/lib/cm-ordered-list-renumber.ts @@ -20,6 +20,18 @@ * list deliberately authored as `5. … 6. … 7. …` keeps the start * when later items get edited). * + * Right-aligned lists. Some people pad the shorter numbers so every + * marker ends in one column (` 9.` above `10.`). When a list was padded + * that way before the edit, the pass keeps it padded: a marker that + * widens gives up its leading space (` 9.` becomes `10.`), one that + * narrows takes a space (`10.` becomes ` 9.`), and every marker gains a + * space when the widest number outgrows the column (99 to 100), so the + * item text keeps one start column. Enter and `o`/`O` copy the leading + * space of the item they continue (` 9.` gives ` 10.`); this pass is + * what puts the new marker in line. A list nobody padded keeps today's + * spacing: only its digits change. See `paddedMarkerEnd` for how + * padding is told apart from indentation. + * * The renumber is appended to the same transaction (via a * `transactionFilter` returning the original plus a sequential * change spec), so the move and the renumber land as a single @@ -35,7 +47,8 @@ import { Annotation, EditorState, type ChangeSpec, - type Extension + type Extension, + type Transaction } from '@codemirror/state' import type { SyntaxNode, Tree } from '@lezer/common' @@ -63,8 +76,8 @@ function rangesOverlap(a: Range, b: Range): boolean { * Falls back to whatever partial tree we have if the parse budget * runs out — renumbering is best-effort. */ -function getReadyTree(state: EditorState): Tree { - return ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state) +function getReadyTree(state: EditorState, upto = state.doc.length): Tree { + return ensureSyntaxTree(state, upto, 50) ?? syntaxTree(state) } function collectOrderedLists( @@ -103,16 +116,20 @@ function collectOrderedLists( return [...found.values()] } -function appendRenumberChanges( - state: EditorState, - list: SyntaxNode, - multiLineRanges: ReadonlyArray<Range>, - out: ChangeSpec[] -): void { - let firstNumber: number | null = null - let punctuation: '.' | ')' = '.' - let index = 0 +interface OrderedItem { + /** Start of the `ListItem`: the column its container's content starts at. */ + from: number + markFrom: number + markTo: number + /** What sits between `from` and the marker: padding, or indentation. */ + lead: string + mark: string + number: number + punctuation: '.' | ')' +} +function orderedItems(state: EditorState, list: SyntaxNode): OrderedItem[] { + const items: OrderedItem[] = [] for (let child = list.firstChild; child; child = child.nextSibling) { if (child.name !== 'ListItem') continue const mark = child.firstChild @@ -120,26 +137,123 @@ function appendRenumberChanges( const text = state.doc.sliceString(mark.from, mark.to) const match = ORDERED_MARK_RE.exec(text) if (!match) continue + items.push({ + from: child.from, + markFrom: mark.from, + markTo: mark.to, + lead: state.doc.sliceString(child.from, mark.from), + mark: text, + number: Number(match[1]), + punctuation: match[2] as '.' | ')' + }) + } + return items +} - if (firstNumber == null) { - const markRange: Range = [mark.from, mark.to] - const touchedByMultiLine = multiLineRanges.some((r) => - rangesOverlap(r, markRange) - ) - firstNumber = touchedByMultiLine ? 1 : Number(match[1]) - punctuation = match[2] as '.' | ')' - const expected = `${firstNumber}${punctuation}` - if (text !== expected) { - out.push({ from: mark.from, to: mark.to, insert: expected }) - } - } else { - const expected = `${firstNumber + index}${punctuation}` - if (text !== expected) { - out.push({ from: mark.from, to: mark.to, insert: expected }) +function insideListItem(node: SyntaxNode): boolean { + for (let cur = node.parent; cur; cur = cur.parent) { + if (cur.name === 'ListItem') return true + } + return false +} + +// A list marker sits 0 to 3 spaces past its container's content column; +// four would make the line indented code or a lazy continuation. +const MAX_PADDING = 3 + +/** + * The column (counted from the list's base) every marker ends at when the + * list is right-aligned by hand, or null when it is not. + * + * `lead` is measured from the `ListItem`'s start, which Lezer puts at the + * container's content column, so the indentation that nests a list under + * its parent item is never counted. What is left is padding only where it + * cannot be indentation: + * - In a top-level list (in the document or a quote), any leading + * space counts. CommonMark ignores up to three there and no editor + * route writes them, so ` 1.` to ` 9.` is padding waiting for `10.`. + * - In a nested list, a uniform lead is indentation: Tab writes four + * spaces under `1. ` (one past its content column) and under `- ` + * (two past). Only markers of different widths that still end in + * one column (` 9.` above ` 10.`) prove padding there. + * A lead holding a tab is indentation either way. + */ +function paddedMarkerEnd( + items: ReadonlyArray<OrderedItem>, + nested: boolean +): number | null { + if (items.length === 0) return null + if (items.some((item) => !/^ *$/.test(item.lead))) return null + const end = items[0].lead.length + items[0].mark.length + if (items.some((item) => item.lead.length + item.mark.length !== end)) { + return null + } + const leads = items.map((item) => item.lead.length) + const most = Math.max(...leads) + if (most === 0) return null + if (nested && Math.min(...leads) === most) return null + return end +} + +function appendRenumberChanges( + items: ReadonlyArray<OrderedItem>, + paddedEnd: number | null, + keepsLead: (item: OrderedItem) => boolean, + multiLineRanges: ReadonlyArray<Range>, + out: ChangeSpec[] +): void { + const first = items[0] + const firstMark: Range = [first.markFrom, first.markTo] + const touchedByMultiLine = multiLineRanges.some((r) => + rangesOverlap(r, firstMark) + ) + const firstNumber = touchedByMultiLine ? 1 : first.number + const marks = items.map((_, i) => `${firstNumber + i}${first.punctuation}`) + + // Numbers only grow down the list, so the last marker is the widest and + // the first the narrowest. + const widest = marks[marks.length - 1].length + let end = paddedEnd == null ? null : Math.max(paddedEnd, widest) + if (end != null && end - marks[0].length > MAX_PADDING) end = null + + items.forEach((item, i) => { + const expected = marks[i] + if (end == null || keepsLead(item) || !/^ *$/.test(item.lead)) { + if (item.mark !== expected) { + out.push({ from: item.markFrom, to: item.markTo, insert: expected }) } + return } - index++ - } + const padded = ' '.repeat(end - expected.length) + expected + if (item.lead + item.mark !== padded) { + out.push({ from: item.from, to: item.markTo, insert: padded }) + } + }) +} + +/** + * The list `items` belong to as it stood before `tr`, found through an item + * `tr` left alone. Padding is judged on that list, not on `tr`'s result, + * because Enter and `o` copy ` 9.`'s space onto the ` 10.` they write and + * so leave every padded list misaligned for a moment. + */ +function listBefore( + tr: Transaction, + list: SyntaxNode, + items: ReadonlyArray<OrderedItem>, + ranges: ReadonlyArray<Range> +): SyntaxNode | null { + const anchor = items.find( + (item) => !ranges.some((r) => rangesOverlap(r, [item.from, item.markTo])) + ) + if (!anchor) return null + const back = tr.changes.invertedDesc + const pos = back.mapPos(anchor.markFrom) + // Parse the old state only as far as the list reaches. + const tree = getReadyTree(tr.startState, back.mapPos(list.to)) + let cur: SyntaxNode | null = tree.resolveInner(pos, 1) + while (cur && cur.name !== 'OrderedList') cur = cur.parent + return cur } export const orderedListRenumber: Extension = EditorState.transactionFilter.of( @@ -152,8 +266,11 @@ export const orderedListRenumber: Extension = EditorState.transactionFilter.of( const newDoc = tr.state.doc const ranges: Range[] = [] const multiLineRanges: Range[] = [] + // Single-line edits that only add or remove blanks: someone padding or + // unpadding a marker by hand, which the pass must not undo. + const blankEdits: Range[] = [] - tr.changes.iterChanges((fromA, toA, fromB, toB) => { + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { ranges.push([fromB, toB]) const crossedLineInOld = oldDoc.lineAt(fromA).number !== oldDoc.lineAt(toA).number @@ -161,6 +278,11 @@ export const orderedListRenumber: Extension = EditorState.transactionFilter.of( newDoc.lineAt(fromB).number !== newDoc.lineAt(toB).number if (crossedLineInOld || crossedLineInNew) { multiLineRanges.push([fromB, toB]) + } else if ( + /^[ \t]*$/.test(oldDoc.sliceString(fromA, toA)) && + /^[ \t]*$/.test(inserted.toString()) + ) { + blankEdits.push([fromB, toB]) } }) @@ -169,9 +291,30 @@ export const orderedListRenumber: Extension = EditorState.transactionFilter.of( const lists = collectOrderedLists(tr.state, ranges) if (lists.length === 0) return tr + const keepsLead = (item: OrderedItem): boolean => + blankEdits.some(([from, to]) => from >= item.from && to <= item.markFrom) + const changes: ChangeSpec[] = [] for (const list of lists) { - appendRenumberChanges(tr.state, list, multiLineRanges, changes) + const items = orderedItems(tr.state, list) + if (items.length === 0) continue + const before = listBefore(tr, list, items, ranges) + const paddedEnd = before + ? paddedMarkerEnd( + orderedItems(tr.startState, before), + insideListItem(before) + ) + : paddedMarkerEnd( + items.filter((item) => !keepsLead(item)), + insideListItem(list) + ) + appendRenumberChanges( + items, + paddedEnd, + keepsLead, + multiLineRanges, + changes + ) } if (changes.length === 0) return tr From 969f50e3d629197e60586b2e8e3f654b646e6f66 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 18:48:00 -0500 Subject: [PATCH 31/53] Feat(mcp): the bundled MCP server can share part of a vault, read-only if asked An assistant connected through MCP saw the whole vault, and the only way to narrow it was to launch zn mcp with the scope flags zn learned for #21 (ZenNotes/tui). Settings > MCP installs the server bundled with the app whenever zn is not app-managed, so those clients had no scope at all. The bundled server, both out/main/mcp.js and the desktop's own zn mcp, now takes the same --allow-path, --deny-path, --read-only and --access flags and enforces them on every tool through a decorator around the vault backend: listings, search, tags, tasks, backlinks, assets, comments and errors. A path is resolved against the vault's own listings before it is checked, so a hidden note, a path through a symbolic link, a case variant and .zennotes all answer "Note not found" exactly like a missing note, and no error or vault_info carries the vault's absolute path. Tools a scope does not offer are not listed, every tool says whether it only reads or can destroy, and the server instructions tell the model it sees part of the vault. A --deny-path that names nothing in the vault shares nothing until it is fixed. The rules live once, in shared-domain/mcp-scope.ts, and a contract fixture (bridge-contract/fixtures/mcp-scope.json) holds this engine and zn to the same answers byte for byte; zn runs a copy of it. Building it found that both engines took a destination segment the listings did not know as a new folder, so a scoped assistant could create notes through a link into a hidden folder; a destination now has to be empty on disk where the listings know nothing. This engine still rewrites no inbound links on a rename, so a scoped rename writes into no other note; zn rewrites links in the notes the assistant can see. On a ZenNotes server, which rewrites links in every note, a scoped assistant cannot rename. Profiles chosen from Settings come next (Phase 2 of docs/ideas/mcp-scoped-access.md in ZenNotes/tui). --- apps/desktop/src/cli/args.ts | 1 + apps/desktop/src/cli/backend.ts | 24 + apps/desktop/src/cli/commands/mcp.ts | 22 +- apps/desktop/src/cli/help.ts | 2 +- apps/desktop/src/mcp/index.ts | 20 +- apps/desktop/src/mcp/scope-contract.test.ts | 232 +++++++ apps/desktop/src/mcp/scoped-backend.test.ts | 191 ++++++ apps/desktop/src/mcp/scoped-backend.ts | 646 ++++++++++++++++++ apps/desktop/src/mcp/server.ts | 95 ++- apps/desktop/src/mcp/vault-ops.ts | 2 +- packages/app-core/src/lib/help.ts | 5 + packages/bridge-contract/fixtures/README.md | 33 +- .../bridge-contract/fixtures/mcp-scope.json | 543 +++++++++++++++ packages/shared-domain/src/mcp-scope.test.ts | 120 ++++ packages/shared-domain/src/mcp-scope.ts | 364 ++++++++++ tooling/scripts/sync-contract-fixtures.mjs | 51 +- 16 files changed, 2311 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/mcp/scope-contract.test.ts create mode 100644 apps/desktop/src/mcp/scoped-backend.test.ts create mode 100644 apps/desktop/src/mcp/scoped-backend.ts create mode 100644 packages/bridge-contract/fixtures/mcp-scope.json create mode 100644 packages/shared-domain/src/mcp-scope.test.ts create mode 100644 packages/shared-domain/src/mcp-scope.ts diff --git a/apps/desktop/src/cli/args.ts b/apps/desktop/src/cli/args.ts index 766fb3f05..b0e5a7cbd 100644 --- a/apps/desktop/src/cli/args.ts +++ b/apps/desktop/src/cli/args.ts @@ -28,6 +28,7 @@ export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([ 'meta', 'new-window', 'page', + 'read-only', 'reopen', 'unchecked', 'yes' diff --git a/apps/desktop/src/cli/backend.ts b/apps/desktop/src/cli/backend.ts index 6bc4ab233..915f08fe3 100644 --- a/apps/desktop/src/cli/backend.ts +++ b/apps/desktop/src/cli/backend.ts @@ -73,6 +73,7 @@ import { type DatabaseVaultLayout } from '@shared/database-ops' import { createAbsenceAwareReader } from '@shared/remote-absence' +import { normalizeSystemFolderPaths, type SystemFolderPaths } from '@shared/system-folder-paths' import { setCell } from '@shared/database-records' import { csvPathForFormDir, @@ -107,6 +108,11 @@ export type VaultDescription = authConfigured: boolean } +export interface VaultLayout { + primaryNotesLocation: PrimaryNotesLocation + systemFolderPaths: SystemFolderPaths +} + export interface VaultBackend { readonly kind: 'local' | 'remote' /** How to name this vault in output and errors. */ @@ -116,6 +122,10 @@ export interface VaultBackend { readonly root: string describe(): Promise<VaultDescription> + /** Where the system folders live and whether the inbox is the vault root: + * what classifying a vault-relative path needs, for a scoped MCP client + * (ZenNotes/tui#21). */ + vaultLayout(): Promise<VaultLayout> listNotes(): Promise<NoteMeta[]> listAssets(): Promise<VaultAssetMeta[]> listFolders(): Promise<{ folder: NoteFolder; subpath: string }[]> @@ -227,6 +237,13 @@ class LocalBackend implements VaultBackend { root: this.root, primaryNotesLocation: await readPrimaryNotesLocation(this.root) }) + vaultLayout = async (): Promise<VaultLayout> => { + const layout = await readDatabaseVaultLayout(this.root) + return { + primaryNotesLocation: layout.primaryNotesAtRoot ? 'root' : 'inbox', + systemFolderPaths: layout.systemFolderPaths ?? {} + } + } listNotes = (): Promise<NoteMeta[]> => listNotes(this.root) listAssets = (): Promise<VaultAssetMeta[]> => listAssets(this.root) listFolders = (): Promise<{ folder: NoteFolder; subpath: string }[]> => listFolders(this.root) @@ -330,6 +347,13 @@ class RemoteBackend implements VaultBackend { authConfigured: !!this.client.authToken } } + vaultLayout = async (): Promise<VaultLayout> => { + const settings = await this.client.getVaultSettings() + return { + primaryNotesLocation: settings.primaryNotesLocation === 'root' ? 'root' : 'inbox', + systemFolderPaths: normalizeSystemFolderPaths(settings.systemFolderPaths) + } + } listNotes = (): Promise<NoteMeta[]> => this.client.listNotes() listAssets = async (): Promise<VaultAssetMeta[]> => (await this.client.listAssets()).map((asset) => ({ diff --git a/apps/desktop/src/cli/commands/mcp.ts b/apps/desktop/src/cli/commands/mcp.ts index 8789436b5..04b07c485 100644 --- a/apps/desktop/src/cli/commands/mcp.ts +++ b/apps/desktop/src/cli/commands/mcp.ts @@ -12,14 +12,21 @@ * names nothing is reported on stderr at startup and the server still * starts; the CLI's usual "resolve, then fail the command" would leave * the MCP client with a dead server and no message. + * + * --allow-path, --deny-path, --read-only and --access narrow what the + * client may see and change (ZenNotes/tui#21); the server enforces them + * on every tool. A bad entry is the one flag that stops the server before + * it serves: starting anyway would share more than the user asked for. */ +import { mcpScopeFromFlags, type McpScope } from '@shared/mcp-scope' import { runMcpServer } from '../../mcp/server.js' -import type { ParsedArgs } from '../args.js' +import { getBool, getMany, getString, type ParsedArgs } from '../args.js' import { resolveTarget } from '../vault-target.js' export async function cmdMcp(args: ParsedArgs): Promise<void> { - await runMcpServer({ resolveTarget: () => resolveTarget(args) }) + const scope = mcpScopeFromArgs(args) + await runMcpServer({ resolveTarget: () => resolveTarget(args), scope }) // The MCP SDK's connect() returns once stdin/stdout listeners are // wired up. Node's event loop keeps the process alive while those // listeners exist, but the CLI dispatcher would otherwise see this @@ -29,3 +36,14 @@ export async function cmdMcp(args: ParsedArgs): Promise<void> { // (Claude Code / Desktop / Codex) chooses to give it. await new Promise<void>(() => {}) } + +/** The scope the flags ask for; null when none were given, full access. The + * legacy out/main/mcp.js entry reads the same flags off its own argv. */ +export function mcpScopeFromArgs(args: ParsedArgs): McpScope | null { + return mcpScopeFromFlags({ + allow: getMany(args, 'allow-path'), + deny: getMany(args, 'deny-path'), + access: getString(args, 'access'), + readOnly: getBool(args, 'read-only') + }) +} diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index e2563db55..6cb4a18d4 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -180,7 +180,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { heading: 'MCP', rows: [ - { name: 'mcp', description: 'Start the MCP stdio server (Claude / Codex)' } + { name: 'mcp', description: 'Start the MCP stdio server (Claude / Codex). The path flags share only part of the vault, on every tool', flags: '--allow-path <p>… --deny-path <p>… --read-only --access read|comment|write' } ] } ] diff --git a/apps/desktop/src/mcp/index.ts b/apps/desktop/src/mcp/index.ts index 69e04cc9f..7cf99534c 100644 --- a/apps/desktop/src/mcp/index.ts +++ b/apps/desktop/src/mcp/index.ts @@ -5,11 +5,29 @@ * Code / Desktop / Codex configs that already point at this file) * keep working through this shim; new installs prefer the unified * `zn mcp` subcommand wired by mcp-integrations.ts. + * + * It reads the scope flags `zn mcp` takes (--allow-path, --deny-path, + * --read-only, --access, ZenNotes/tui#21) off its own argv, so a client + * that launches this file directly can be scoped the same way. A bad + * entry stops it before it serves anything, with one line in the + * client's log rather than a stack. */ +import { parse } from '../cli/args.js' +import { mcpScopeFromArgs } from '../cli/commands/mcp.js' +import type { McpScope } from '@shared/mcp-scope' import { runMcpServer } from './server.js' -runMcpServer().catch((err) => { +let scope: McpScope | null +try { + scope = mcpScopeFromArgs(parse(process.argv.slice(2))) +} catch (err) { + // eslint-disable-next-line no-console + console.error(`[zennotes-mcp] ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +} + +runMcpServer({ scope }).catch((err) => { // eslint-disable-next-line no-console console.error('[zennotes-mcp] fatal', err) process.exit(1) diff --git a/apps/desktop/src/mcp/scope-contract.test.ts b/apps/desktop/src/mcp/scope-contract.test.ts new file mode 100644 index 000000000..904629631 --- /dev/null +++ b/apps/desktop/src/mcp/scope-contract.test.ts @@ -0,0 +1,232 @@ +// The shared scoped-access contract (ZenNotes/tui#21), run against this +// engine the way a client reaches it: a real vault on disk, the stdio +// server's handlers over an in-memory transport. The Go `zn mcp` runs the same +// fixture in internal/mcp/scope_contract_test.go (ZenNotes/tui), so both +// engines give one client the same answers byte for byte. When a case fails, +// settle which behavior is intended before touching the fixture; never copy +// one engine's output into it. +import { promises as fsp, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { compareMcpScopePaths, newMcpScope, type McpScopeFlags } from '@shared/mcp-scope' +import { runMcpServer } from './server' + +interface VaultSeed { + files: Record<string, string> + symlinks?: Record<string, string> + outside?: Record<string, string> +} + +interface Expectation { + error?: string + ok?: boolean + notOffered?: boolean + paths?: string[] + folders?: string[] + match?: unknown + contains?: string[] + excludes?: string[] + absentKeys?: string[] +} + +interface ContractCase { + id: string + vault: string + flags: McpScopeFlags | null + tools?: { + count?: number + exactly?: string[] + include?: string[] + absent?: string[] + annotations?: Record<string, Record<string, unknown>> + } + calls: { tool: string; args: Record<string, unknown>; expect: Expectation }[] + disk?: { exists?: string[]; absent?: string[]; equals?: Record<string, string> } +} + +interface Fixture { + schemaVersion: number + vaults: Record<string, VaultSeed> + flagCases: { + id: string + flags: McpScopeFlags + scope?: { access: string; allow: string[]; deny: string[] } + error?: string + }[] + cases: ContractCase[] +} + +const fixture = JSON.parse( + readFileSync( + fileURLToPath( + new URL('../../../../packages/bridge-contract/fixtures/mcp-scope.json', import.meta.url) + ), + 'utf8' + ) +) as Fixture + +const sorted = (values: string[]): string[] => [...values].sort(compareMcpScopePaths) + +async function seed(base: string, vault: VaultSeed): Promise<string> { + const root = path.join(base, 'vault') + const write = async (dir: string, files: Record<string, string>): Promise<void> => { + for (const [rel, body] of Object.entries(files)) { + const abs = path.join(dir, ...rel.split('/')) + await fsp.mkdir(path.dirname(abs), { recursive: true }) + await fsp.writeFile(abs, body, 'utf8') + } + } + await write(root, vault.files) + await write(path.join(base, 'outside'), vault.outside ?? {}) + for (const [rel, target] of Object.entries(vault.symlinks ?? {})) { + await fsp.symlink(target, path.join(root, ...rel.split('/'))) + } + return root +} + +/** Every key the fixture names matches; arrays match element for element. */ +function expectMatch(actual: unknown, expected: unknown, where: string): void { + if (Array.isArray(expected)) { + expect(Array.isArray(actual), where).toBe(true) + const list = actual as unknown[] + expect(list.length, where).toBe(expected.length) + expected.forEach((item, i) => expectMatch(list[i], item, `${where}[${i}]`)) + return + } + if (expected && typeof expected === 'object') { + expect(actual && typeof actual === 'object' && !Array.isArray(actual), where).toBe(true) + for (const [key, value] of Object.entries(expected)) { + expectMatch((actual as Record<string, unknown>)[key], value, `${where}.${key}`) + } + return + } + expect(actual, where).toBe(expected) +} + +describe('shared scoped-access contract: flags', () => { + it('is a fixture this runner understands', () => { + expect(fixture.schemaVersion).toBe(1) + expect(fixture.cases.length).toBeGreaterThan(0) + }) + + it.each(fixture.flagCases)('$id', (c) => { + if (c.error) { + expect(() => newMcpScope(c.flags)).toThrow(c.error) + try { + newMcpScope(c.flags) + } catch (err) { + expect((err as Error).message).toBe(c.error) + } + return + } + expect(newMcpScope(c.flags)).toEqual(c.scope) + }) +}) + +describe('shared scoped-access contract: tools on a real vault', () => { + let base: string + + beforeEach(async () => { + base = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-scope-')) + await fsp.mkdir(path.join(base, 'config'), { recursive: true }) + vi.stubEnv('ZENNOTES_CONFIG_DIR', path.join(base, 'config')) + vi.stubEnv('ZENNOTES_USER_DATA_PATH', path.join(base, 'config')) + // The stale-deny hint goes to stderr, which is the client's log. + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + await fsp.rm(base, { recursive: true, force: true }) + }) + + it.each(fixture.cases)('$id', async (c) => { + const root = await seed(base, fixture.vaults[c.vault]) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await runMcpServer({ + resolveTarget: async () => ({ kind: 'local', root }), + scope: c.flags ? newMcpScope(c.flags) : null, + transport: serverTransport + }) + const client = new Client({ name: 'scope-contract', version: '0' }) + await client.connect(clientTransport) + try { + if (c.tools) { + const listed = (await client.listTools()).tools + const names = sorted(listed.map((t) => t.name)) + if (c.tools.count != null) expect(names.length, 'tool count').toBe(c.tools.count) + if (c.tools.exactly) expect(names).toEqual(sorted(c.tools.exactly)) + for (const name of c.tools.include ?? []) expect(names).toContain(name) + for (const name of c.tools.absent ?? []) expect(names).not.toContain(name) + for (const [name, annotations] of Object.entries(c.tools.annotations ?? {})) { + expect(listed.find((t) => t.name === name)?.annotations, name).toEqual(annotations) + } + } + + for (const call of c.calls) { + const where = `${call.tool} ${JSON.stringify(call.args)}` + let text = '' + let failed = false + try { + const result = await client.callTool({ name: call.tool, arguments: call.args }) + text = (result.content as { text: string }[])[0]?.text ?? '' + failed = !!result.isError + } catch (err) { + text = err instanceof Error ? err.message : String(err) + failed = true + } + const e = call.expect + if (e.notOffered) { + expect(failed, where).toBe(true) + continue + } + if (e.error != null) { + expect(failed, where).toBe(true) + expect(text, where).toBe(`Error: ${e.error}`) + } + if (e.ok || e.paths || e.folders || e.match !== undefined || e.absentKeys) { + expect(failed, `${where}: ${text}`).toBe(false) + } + if (e.paths) { + const notes = JSON.parse(text) as { path: string }[] + expect(sorted(notes.map((n) => n.path)), where).toEqual(sorted(e.paths)) + } + if (e.folders) { + const folders = JSON.parse(text) as { folder: string; subpath: string }[] + expect(sorted(folders.map((f) => `${f.folder}:${f.subpath}`)), where).toEqual( + sorted(e.folders) + ) + } + if (e.match !== undefined) expectMatch(JSON.parse(text), e.match, where) + if (e.absentKeys) { + const object = JSON.parse(text) as Record<string, unknown> + for (const key of e.absentKeys) expect(object, where).not.toHaveProperty(key) + } + for (const needle of e.contains ?? []) { + expect(text, where).toContain(needle.replace('$VAULT', root)) + } + for (const needle of e.excludes ?? []) { + expect(text, where).not.toContain(needle.replace('$VAULT', root)) + } + } + + const at = (rel: string): string => path.join(root, ...rel.split('/')) + for (const rel of c.disk?.exists ?? []) { + await expect(fsp.lstat(at(rel)), `${rel} exists`).resolves.toBeTruthy() + } + for (const rel of c.disk?.absent ?? []) { + await expect(fsp.lstat(at(rel)), `${rel} is absent`).rejects.toThrow() + } + for (const [rel, body] of Object.entries(c.disk?.equals ?? {})) { + expect(await fsp.readFile(at(rel), 'utf8'), rel).toBe(body) + } + } finally { + await client.close() + } + }) +}) diff --git a/apps/desktop/src/mcp/scoped-backend.test.ts b/apps/desktop/src/mcp/scoped-backend.test.ts new file mode 100644 index 000000000..9429696f1 --- /dev/null +++ b/apps/desktop/src/mcp/scoped-backend.test.ts @@ -0,0 +1,191 @@ +import { promises as fsp } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MCP_TOOL_ACCESS, mcpScopeInstructions, newMcpScope } from '@shared/mcp-scope' +import { parse } from '../cli/args' +import { createBackend, type VaultBackend } from '../cli/backend' +import { mcpScopeFromArgs } from '../cli/commands/mcp' +import { ScopedBackend } from './scoped-backend' +import { callTool, describeToolError, listToolNames, runMcpServer } from './server' + +// What the shared fixture (bridge-contract/fixtures/mcp-scope.json, run by +// scope-contract.test.ts) cannot reach: a server-backed vault, error text from +// the filesystem, the server instructions and the launch flags. zn's own +// tests (internal/mcp/scope_test.go in ZenNotes/tui) cover the same ground. + +const shared = newMcpScope({ access: 'write', allow: ['Projects'], deny: ['Projects/Clients'] }) + +let root: string + +beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-scoped-')) + const files: Record<string, string> = { + '.zennotes/vault.json': '{"primaryNotesLocation":"root"}', + 'Projects/Plan.md': '# Plan\n', + 'Projects/Clients/Acme.md': '# Acme\n', + 'Journal/Diary.md': '# Diary\n\nabout [[Plan]]\n' + } + for (const [rel, body] of Object.entries(files)) { + await fsp.mkdir(path.dirname(path.join(root, rel)), { recursive: true }) + await fsp.writeFile(path.join(root, rel), body) + } + vi.stubEnv('ZENNOTES_CONFIG_DIR', path.join(root, '.config')) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + await fsp.rm(root, { recursive: true, force: true }) +}) + +/** A local vault dressed as a server's: a server rewrites inbound links in + * every note itself, which is what the remote checks key on. */ +function serverBacked(): VaultBackend { + const local = createBackend({ kind: 'local', root }) + return { + ...local, + kind: 'remote', + root: '', + label: 'home (http://10.0.0.5:7878)', + describe: async () => ({ + kind: 'remote', + baseUrl: 'http://10.0.0.5:7878', + name: 'home', + vaultPath: '/srv/notes', + vaultName: 'notes', + primaryNotesLocation: 'root', + authConfigured: true + }) + } as VaultBackend +} + +describe('every MCP tool has an access class', () => { + it('classifies exactly the tools the server registers', () => { + expect([...listToolNames()].sort()).toEqual(Object.keys(MCP_TOOL_ACCESS).sort()) + }) +}) + +describe('a scoped client on a ZenNotes server', () => { + it('cannot rename, since the server would rewrite links in notes it cannot see', async () => { + const backend = serverBacked() + const rename = vi.spyOn(backend, 'renameNote') + await expect( + callTool('rename_note', { path: 'Projects/Plan.md', newTitle: 'Other' }, backend, shared) + ).rejects.toThrow('the server rewrites links in every note') + expect(rename).not.toHaveBeenCalled() + await expect(fsp.stat(path.join(root, 'Projects', 'Plan.md'))).resolves.toBeTruthy() + }) + + it('still renames when it sees the whole vault', async () => { + const backend = serverBacked() + const scope = newMcpScope({ access: 'write' }) + await expect( + callTool('rename_note', { path: 'Projects/Plan.md', newTitle: 'Other' }, backend, scope) + ).resolves.toMatchObject({ path: 'Projects/Other.md' }) + }) + + it('learns the server and its layout from vault_info, not where the server keeps the vault', async () => { + const info = (await callTool('vault_info', {}, serverBacked(), shared)) as Record<string, unknown> + expect(Object.keys(info)).toEqual([ + 'kind', + 'server', + 'serverName', + 'primaryNotesLocation', + 'topFolders', + 'subfolders', + 'access', + 'notes' + ]) + expect(info).toMatchObject({ kind: 'remote', server: 'http://10.0.0.5:7878', serverName: 'home' }) + expect(JSON.stringify(info)).not.toContain('/srv/notes') + }) +}) + +describe('a local scoped client', () => { + it('renames a note it sees without writing into the ones it does not', async () => { + const backend = createBackend({ kind: 'local', root }) + await callTool('rename_note', { path: 'Projects/Plan.md', newTitle: 'Plan B' }, backend, shared) + await expect(fsp.readFile(path.join(root, 'Journal', 'Diary.md'), 'utf8')).resolves.toBe( + '# Diary\n\nabout [[Plan]]\n' + ) + }) + + it('cannot give a note a name it could not see', async () => { + const backend = createBackend({ kind: 'local', root }) + const narrow = newMcpScope({ access: 'write', allow: ['Projects/Plan.md'] }) + await expect( + callTool('rename_note', { path: 'Projects/Plan.md', newTitle: 'Elsewhere' }, backend, narrow) + ).rejects.toThrow('This assistant cannot give the note that name.') + }) + + it('gets errors without the vault’s absolute path', () => { + const local = createBackend({ kind: 'local', root }) + const failure = new Error(`EACCES: permission denied, open '${path.join(root, 'Projects', 'Plan.md')}'`) + const scoped = describeToolError(failure, null, new ScopedBackend(local, shared)) + expect(scoped).not.toContain(root) + expect(scoped).toContain('Projects/Plan.md') + expect(describeToolError(failure, null, local)).toContain(root) + }) +}) + +describe('the server under a scope', () => { + async function instructionsFor(scope: ReturnType<typeof newMcpScope> | null): Promise<string> { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await runMcpServer({ + resolveTarget: async () => ({ kind: 'local', root }), + scope, + transport: serverTransport + }) + const client = new Client({ name: 'scope-test', version: '0' }) + await client.connect(clientTransport) + const text = client.getInstructions() ?? '' + await client.close() + return text + } + + it('tells the model what it can reach', async () => { + const scope = newMcpScope({ access: 'read', allow: ['Projects'] }) + expect((await instructionsFor(scope)).endsWith(mcpScopeInstructions(scope))).toBe(true) + expect(await instructionsFor(null)).not.toContain('## Access') + }) + + it('names a stale deny entry on stderr once, and never to the model', async () => { + const stderr: string[] = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)) + return true + }) + const backend = createBackend({ kind: 'local', root }) + const stale = newMcpScope({ access: 'read', deny: ['Renamed Elsewhere'] }) + for (let i = 0; i < 2; i++) { + await expect(callTool('list_notes', {}, backend, stale)).rejects.toThrow('nothing is shared') + } + expect(stderr.filter((line) => line.includes('Renamed Elsewhere'))).toHaveLength(1) + }) +}) + +describe('scope flags on the command line', () => { + it('reads zn mcp’s flags, with --read-only as a switch', () => { + const scope = mcpScopeFromArgs( + parse(['--read-only', '--allow-path', '/projects', '--allow-path', 'Areas/Health', '--deny-path', 'Projects/Clients']) + ) + expect(scope).toEqual({ + access: 'read', + allow: ['projects', 'Areas/Health'], + deny: ['Projects/Clients'] + }) + // A switch never swallows the next token. + expect(parse(['--read-only', 'inbox']).positionals).toEqual(['inbox']) + expect(mcpScopeFromArgs(parse(['--access', 'comment']))).toMatchObject({ access: 'comment' }) + }) + + it('means full access without them, and refuses a bad entry', () => { + expect(mcpScopeFromArgs(parse(['--vault', 'work']))).toBeNull() + expect(() => mcpScopeFromArgs(parse(['--allow-path', '.zennotes']))).toThrow( + '--allow-path ".zennotes" names a hidden folder' + ) + }) +}) diff --git a/apps/desktop/src/mcp/scoped-backend.ts b/apps/desktop/src/mcp/scoped-backend.ts new file mode 100644 index 000000000..25ea670a2 --- /dev/null +++ b/apps/desktop/src/mcp/scoped-backend.ts @@ -0,0 +1,646 @@ +/** + * A VaultBackend that shows an MCP client only what its scope allows + * (ZenNotes/tui#21, @shared/mcp-scope). Every tool runs against one, so no + * handler can skip the check, and a new handler written against VaultBackend + * gets it for free. The Go `zn mcp` has the same decorator in + * internal/mcp/scoped.go (ZenNotes/tui); the two answer the shared fixture + * packages/bridge-contract/fixtures/mcp-scope.json with the same bytes. + * + * A path is resolved against the vault's own listings before the scope check, + * never trusted as given: the listings walk real directories and regular + * files only, so a path through a symbolic link, a case variant of a hidden + * folder on a case-insensitive volume, or a hidden directory simply has no + * match. Whatever the client is never shown does not exist for it, and the + * error for a hidden path is the same bytes as for a missing one. The same + * rule covers a vault on a ZenNotes server, whose listings come from the + * server. + * + * One is made per tool call; the listings it reads are cached for that call. + */ + +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { + foldMcpScopePath, + isInsideMcpScopeEntry, + isInsideMcpScopeEntryFolded, + isMcpScopeActive, + isMcpScopeRestricted, + mcpAccessAtLeast, + mcpAccessRefusal, + mcpScopeAccessInfo, + type McpAccess, + type McpScope, + type McpScopeAccessInfo +} from '@shared/mcp-scope' +import { + resolveFolderPath, + systemFolderForDirName, + type SystemFolderPaths +} from '@shared/system-folder-paths' +import type { DatabaseOps } from '@shared/database-ops' +import type { VaultAssetMeta, VaultBackend, VaultDescription, VaultLayout } from '../cli/backend.js' +import { + sanitizeTitle, + type NoteComment, + type NoteCommentInput, + type NoteContent, + type NoteFolder, + type NoteMeta, + type VaultTask, + type VaultTextSearchMatch +} from './vault-ops.js' + +type FolderEntry = { folder: NoteFolder; subpath: string } + +const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] + +/** Kept out of the root-mode note tree however the system folders are + * mapped: the asset directories and the internal one. */ +const RESERVED_NON_SYSTEM_ROOT_NAMES = ['assets', 'attachements', '_assets', '.zennotes'] + +const NOT_OFFERED = 'That is not available to an assistant with scoped access.' + +const STALE_DENY = + "This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed." + +const REMOTE_RENAME = + 'Renaming notes on a ZenNotes server is not available to an assistant that sees only part of the vault: the server rewrites links in every note, including ones this assistant cannot see.' + +const notFound = (rel: string): Error => new Error(`Note not found: ${rel}`) +const folderNotFound = (subpath: string): Error => new Error(`Folder not found: ${subpath}`) + +/** The stderr hint about a missing deny entry, once per entry for the life of + * the process. */ +const staleDenyLogged = new Set<string>() + +function joinRel(dir: string, sub: string): string { + if (!dir) return sub + if (!sub) return dir + return `${dir}/${sub}` +} + +/** The vault's spelling of `p` among known paths: exact, else the one path + * equal to it after case and accent folding. */ +function spell(p: string, known: readonly string[]): string | null { + const folded = foldMcpScopePath(p) + let match: string | null = null + let count = 0 + for (const k of known) { + if (k === p) return k + if (foldMcpScopePath(k) === folded) { + match = k + count += 1 + } + } + return count === 1 ? match : null +} + +/** A client's path in vault-relative POSIX form, or null: `.` and `..` + * segments and hidden names are never resolved. */ +function cleanRequest(raw: string): string | null { + const parts: string[] = [] + for (const seg of raw.replace(/\\/g, '/').split('/')) { + if (!seg) continue + if (seg === '.' || seg === '..' || seg.startsWith('.') || seg.includes('\u0000')) return null + parts.push(seg) + } + return parts.length > 0 ? parts.join('/') : null +} + +/** The top-level directories the root walk keeps out of the inbox. Mirrors + * hiddenPrimaryRootNames in ZenNotes/tui and hiddenRootNamesWith in + * vault-ops. */ +function hiddenPrimaryRootNames(paths: SystemFolderPaths): Set<string> { + const names = new Set(RESERVED_NON_SYSTEM_ROOT_NAMES) + for (const folder of ['quick', 'archive', 'trash'] as NoteFolder[]) { + names.add(resolveFolderPath(folder, paths)) + } + return names +} + +/** Whether a top-level name a client typed is an asset, internal or system + * directory. Case-insensitive on purpose: on a case-insensitive volume + * `Attachements/` is the attachments directory. */ +function isReservedRootName(name: string, paths: SystemFolderPaths): boolean { + const lower = name.toLowerCase() + if (RESERVED_NON_SYSTEM_ROOT_NAMES.some((reserved) => reserved.toLowerCase() === lower)) { + return true + } + return systemFolderForDirName(name, paths) != null +} + +/** The bucket a vault-relative path classifies into by its first segment. */ +function folderForRelativePath(rel: string, paths: SystemFolderPaths): NoteFolder | null { + const top = rel.split('/')[0] ?? '' + if (!top || top.startsWith('.')) return null + const system = systemFolderForDirName(top, paths) + if (system) return system + if (hiddenPrimaryRootNames(paths).has(top)) return null + return 'inbox' +} + +/** A note's directory relative to its bucket root, '' at the root. */ +function folderSubpathOf( + rel: string, + folder: NoteFolder, + primaryAtRoot: boolean, + paths: SystemFolderPaths +): string { + const dir = path.posix.dirname(rel) + if (dir === '.') return '' + if (folder === 'inbox' && primaryAtRoot) return dir + const top = resolveFolderPath(folder, paths) + if (dir.toLowerCase() === top.toLowerCase()) return '' + if ( + dir.length > top.length + 1 && + dir.slice(0, top.length).toLowerCase() === top.toLowerCase() && + dir[top.length] === '/' + ) { + return dir.slice(top.length + 1) + } + return '' +} + +/** The vault as one call sees it. */ +class ScopeView { + /** The visible notes. */ + readonly notes: NoteMeta[] = [] + /** Every listed note, visible or not. */ + readonly byPath = new Map<string, NoteMeta>() + /** Every listed folder by its vault-relative directory. */ + readonly folders = new Map<string, FolderEntry>() + /** The folder paths plus the bucket directories, for spelling lookups. */ + readonly dirs: string[] = [] + readonly allow: string[] = [] + readonly deny: string[] = [] + readonly missingAllow: string[] = [] + readonly ancestors = new Set<string>() + + constructor( + readonly paths: SystemFolderPaths, + readonly primaryAtRoot: boolean, + readonly allFolders: FolderEntry[], + /** Whether anything at all sits at a vault-relative path, for a vault on + * this machine; null for a server's, whose walker is its own. */ + private readonly onDisk: ((rel: string) => Promise<boolean>) | null + ) {} + + bucketDir(folder: NoteFolder): string { + if (folder === 'inbox' && this.primaryAtRoot) return '' + return resolveFolderPath(folder, this.paths) + } + + folderRel(entry: FolderEntry): string { + return joinRel(this.bucketDir(entry.folder), entry.subpath) + } + + covers(p: string): boolean { + return this.allow.length === 0 || this.allow.some((entry) => isInsideMcpScopeEntry(p, entry)) + } + + denied(p: string): boolean { + return this.deny.some((entry) => isInsideMcpScopeEntryFolded(p, entry)) + } + + visible = (p: string): boolean => this.covers(p) && !this.denied(p) + + /** Whether a deny entry lies at or below `p`. */ + denyInside(p: string): boolean { + return this.deny.some((entry) => isInsideMcpScopeEntryFolded(entry, p)) + } + + /** A client's note path resolved to a visible listed note. */ + note(raw: string): NoteMeta | null { + const clean = cleanRequest(raw) + if (!clean) return null + let meta = this.byPath.get(clean) ?? null + if (!meta) { + const folded = foldMcpScopePath(clean) + let count = 0 + for (const [p, m] of this.byPath) { + if (foldMcpScopePath(p) === folded) { + meta = m + count += 1 + } + } + if (count !== 1) return null + } + return meta && this.visible(meta.path) ? meta : null + } + + /** + * Where a note or folder would go, as the subpath under its bucket in the + * vault's spelling, or a refusal. The directory must sit in the bucket asked + * for (a subpath never reaches another system folder or a hidden one) and + * inside an allow entry, not merely above one. + */ + async destination(folder: NoteFolder, rawSub: string): Promise<string> { + let sub = rawSub.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') + if (sub) { + const clean = cleanRequest(sub) + if (!clean) throw this.createRefusal() + sub = clean + } + // A database folder's insides are its record pages, not notes. + if (sub.split('/').some((seg) => seg.toLowerCase().endsWith('.base'))) { + throw this.createRefusal() + } + const bucket = this.bucketDir(folder) + if (!bucket && sub && isReservedRootName(sub.split('/')[0] ?? '', this.paths)) { + throw this.createRefusal() + } + const rel = joinRel(bucket, sub) + if (rel && folderForRelativePath(`${rel}/x.md`, this.paths) !== folder) { + throw this.createRefusal() + } + // Existing folders keep their spelling; new segments are created as given. + let spelled = bucket + let firstNew: string | null = null + if (sub) { + for (const seg of sub.split('/')) { + const next = joinRel(spelled, seg) + const listed = spell(next, this.dirs) + if (listed == null && firstNew == null) firstNew = next + spelled = listed ?? next + } + } + if (!this.visible(spelled)) throw this.createRefusal() + // A segment the listings do not know is new only when nothing is there. + // The walks skip symbolic links, so `Projects/inner -> ../Journal` reads + // as missing, and creating through it would write into the hidden folder. + if (firstNew != null && this.onDisk && (await this.onDisk(firstNew))) { + throw this.createRefusal() + } + if (!bucket) return spelled + return spelled.slice(bucket.length).replace(/^\//, '') + } + + /** The one answer for every destination a client may not use, hidden or + * not, so it confirms nothing about hidden folders. */ + createRefusal(): Error { + if (this.allow.length > 0) { + return new Error( + `This assistant can create and move notes only inside: ${this.allow.join(', ')}.` + ) + } + return new Error('This assistant cannot put notes there.') + } + + /** A client's (bucket, subpath) resolved to a visible listed folder. */ + folder(folder: NoteFolder, rawSub: string): { rel: string; entry: FolderEntry } | null { + const clean = cleanRequest(rawSub) + if (!clean) return null + const rel = spell(joinRel(this.bucketDir(folder), clean), this.dirs) + if (rel == null) return null + const entry = this.folders.get(rel) + if (!entry || entry.folder !== folder || !this.visible(rel)) return null + return { rel, entry } + } +} + +export class ScopedBackend implements VaultBackend { + private view: Promise<ScopeView> | null = null + + constructor( + readonly inner: VaultBackend, + readonly scope: McpScope + ) {} + + get kind(): 'local' | 'remote' { + return this.inner.kind + } + + get label(): string { + return this.inner.label + } + + get root(): string { + return this.inner.root + } + + private load(): Promise<ScopeView> { + this.view ??= this.build() + return this.view + } + + private async build(): Promise<ScopeView> { + const layout = await this.inner.vaultLayout() + const notes = await this.inner.listNotes() + const folders = await this.inner.listFolders() + const root = this.inner.root + const v = new ScopeView( + layout.systemFolderPaths, + layout.primaryNotesLocation === 'root', + folders, + this.inner.kind === 'local' && root + ? (rel) => + fs.lstat(path.join(root, ...rel.split('/'))).then( + () => true, + // Anything but plain absence counts as something being there. + (err: NodeJS.ErrnoException) => err.code !== 'ENOENT' + ) + : null + ) + for (const folder of FOLDERS) { + const dir = v.bucketDir(folder) + if (dir) v.dirs.push(dir) + } + for (const entry of folders) { + const rel = v.folderRel(entry) + v.folders.set(rel, entry) + v.dirs.push(rel) + } + const known: string[] = [] + for (const note of notes) { + v.byPath.set(note.path, note) + known.push(note.path) + } + known.push(...v.dirs) + + // Entries take the vault's own spelling, so `--allow-path /projects` + // finds `Projects/`. + for (const entry of this.scope.allow) { + let canonical = spell(entry, known) + if (canonical == null) { + v.missingAllow.push(entry) + canonical = entry + } + v.allow.push(canonical) + for (let p = path.posix.dirname(canonical); p !== '.' && p !== '/'; p = path.posix.dirname(p)) { + v.ancestors.add(p) + } + } + // A deny entry that matches nothing was renamed or deleted outside + // ZenNotes, or belongs to another vault: what it hid may now sit under a + // new name, so the scope shares nothing until it is fixed. Only stderr, + // which lands in the client's logs and not in front of the model, names + // the entry. + for (const entry of this.scope.deny) { + const folded = foldMcpScopePath(entry) + if (!known.some((p) => foldMcpScopePath(p) === folded)) { + if (!staleDenyLogged.has(entry)) { + staleDenyLogged.add(entry) + process.stderr.write( + `[zennotes-mcp] --deny-path ${entry} matches nothing in this vault, so the MCP server shares nothing until it is fixed\n` + ) + } + throw new Error(STALE_DENY) + } + v.deny.push(entry) + } + for (const note of notes) { + if (v.visible(note.path)) v.notes.push(note) + } + return v + } + + private need(access: McpAccess): void { + if (!mcpAccessAtLeast(this.scope.access, access)) throw new Error(mcpAccessRefusal(this.scope)) + } + + /** Loads the view and resolves a note: the first step of every per-note + * operation. */ + private async noteFor(rel: string, access: McpAccess): Promise<NoteMeta> { + this.need(access) + const v = await this.load() + const meta = v.note(rel) + if (!meta) throw notFound(rel) + return meta + } + + /* ---------- VaultBackend ---------------------------------------------- */ + + describe = (): Promise<VaultDescription> => this.inner.describe() + vaultLayout = (): Promise<VaultLayout> => this.inner.vaultLayout() + + listNotes = async (): Promise<NoteMeta[]> => [...(await this.load()).notes] + + /** The visible folders plus the folders above an allow entry, which are + * listed by name (the entry names them already) so the tree makes sense, + * while nothing directly inside them is visible. */ + listFolders = async (): Promise<FolderEntry[]> => { + const v = await this.load() + return v.allFolders.filter((entry) => { + const rel = v.folderRel(entry) + return v.visible(rel) || (v.ancestors.has(rel) && !v.denied(rel)) + }) + } + + listAssets = async (): Promise<VaultAssetMeta[]> => { + const v = await this.load() + return (await this.inner.listAssets()).filter((asset) => v.visible(asset.path)) + } + + readNote = async (rel: string): Promise<NoteContent> => + await this.inner.readNote((await this.noteFor(rel, 'read')).path) + + writeNote = async (rel: string, body: string): Promise<NoteMeta> => + await this.inner.writeNote((await this.noteFor(rel, 'write')).path, body) + + createNote = async ( + folder: NoteFolder, + title?: string, + subpath = '', + body?: string + ): Promise<NoteMeta> => { + this.need('write') + const v = await this.load() + return await this.inner.createNote(folder, title, await v.destination(folder, subpath), body) + } + + appendToNote = async (rel: string, text: string): Promise<NoteMeta> => + await this.inner.appendToNote((await this.noteFor(rel, 'write')).path, text) + + prependToNote = async (rel: string, text: string): Promise<NoteMeta> => + await this.inner.prependToNote((await this.noteFor(rel, 'write')).path, text) + + /** + * The new name must be one the client can see. This engine's rename does + * not rewrite inbound links at all, so it never writes into a note the + * client cannot see; zn rewrites links in the notes it can see only. A + * server rewrites inbound links in every note itself, so on a remote vault + * a client that sees part of it cannot rename. + */ + renameNote = async (rel: string, nextTitle: string): Promise<NoteMeta> => { + const meta = await this.noteFor(rel, 'write') + const v = await this.load() + const next = path.posix.join(path.posix.dirname(meta.path), `${sanitizeTitle(nextTitle)}.md`) + if (!v.visible(next)) throw new Error('This assistant cannot give the note that name.') + if (isMcpScopeRestricted(this.scope) && this.inner.kind === 'remote') { + throw new Error(REMOTE_RENAME) + } + return await this.inner.renameNote(meta.path, nextTitle) + } + + moveNote = async (rel: string, folder: NoteFolder, subpath: string): Promise<NoteMeta> => { + const meta = await this.noteFor(rel, 'write') + const v = await this.load() + return await this.inner.moveNote(meta.path, folder, await v.destination(folder, subpath)) + } + + // Archiving and trashing are lifecycle exits: the note may leave what the + // client sees, and the user can undo either in the app. + archiveNote = async (rel: string): Promise<NoteMeta> => + await this.inner.archiveNote((await this.noteFor(rel, 'write')).path) + + moveToTrash = async (rel: string): Promise<NoteMeta> => + await this.inner.moveToTrash((await this.noteFor(rel, 'write')).path) + + /** A note brought back to the inbox must land where the client can see it: + * the same subpath under the inbox bucket. */ + private async restoreTarget(rel: string): Promise<NoteMeta> { + const meta = await this.noteFor(rel, 'write') + const v = await this.load() + await v.destination('inbox', folderSubpathOf(meta.path, meta.folder, v.primaryAtRoot, v.paths)) + return meta + } + + unarchiveNote = async (rel: string): Promise<NoteMeta> => + await this.inner.unarchiveNote((await this.restoreTarget(rel)).path) + + restoreFromTrash = async (rel: string): Promise<NoteMeta> => + await this.inner.restoreFromTrash((await this.restoreTarget(rel)).path) + + duplicateNote = async (rel: string): Promise<NoteMeta> => + await this.inner.duplicateNote((await this.noteFor(rel, 'write')).path) + + deleteNote = async (rel: string): Promise<void> => + await this.inner.deleteNote((await this.noteFor(rel, 'write')).path) + + emptyTrash = async (): Promise<void> => { + this.need('write') + if (isMcpScopeRestricted(this.scope)) throw new Error(NOT_OFFERED) + await this.inner.emptyTrash() + } + + insertAtLine = async (rel: string, lineNumber: number, text: string): Promise<NoteMeta> => + await this.inner.insertAtLine((await this.noteFor(rel, 'write')).path, lineNumber, text) + + replaceInNote = async ( + rel: string, + find: string, + replace: string, + occurrence: 'first' | 'all' + ): Promise<{ meta: NoteMeta; replacements: number }> => + await this.inner.replaceInNote((await this.noteFor(rel, 'write')).path, find, replace, occurrence) + + createFolder = async (folder: NoteFolder, subpath: string): Promise<void> => { + this.need('write') + const v = await this.load() + const sub = await v.destination(folder, subpath) + if (!sub) throw new Error('Folder name is required') + await this.inner.createFolder(folder, sub) + } + + /** + * A folder a client may rename or delete as a whole: visible, and holding + * nothing hidden, or the operation would carry or destroy what the client + * cannot see. The refusal for a folder that holds something hidden is the + * one bit this tells a client, documented in the design. + */ + private async wholeFolder( + folder: NoteFolder, + subpath: string, + verb: string + ): Promise<{ v: ScopeView; subpath: string }> { + this.need('write') + const v = await this.load() + const found = v.folder(folder, subpath) + if (!found) throw folderNotFound(subpath) + if (v.denyInside(found.rel)) throw new Error(`This assistant cannot ${verb} that folder.`) + return { v, subpath: found.entry.subpath } + } + + renameFolder = async ( + folder: NoteFolder, + oldSubpath: string, + newSubpath: string + ): Promise<string> => { + const { v, subpath: oldSub } = await this.wholeFolder(folder, oldSubpath, 'rename') + const newSub = await v.destination(folder, newSubpath) + if (!newSub) throw new Error('Both old and new folder paths are required') + return await this.inner.renameFolder(folder, oldSub, newSub) + } + + deleteFolder = async (folder: NoteFolder, subpath: string): Promise<void> => { + const { subpath: sub } = await this.wholeFolder(folder, subpath, 'delete') + await this.inner.deleteFolder(folder, sub) + } + + /** Filters before it limits: the inner walk stops at its limit, so + * filtering its first page would let hidden matches crowd out visible + * ones, and a short page would say how many hidden notes match. */ + searchText = async (query: string, limit: number): Promise<VaultTextSearchMatch[]> => { + const v = await this.load() + const cap = limit <= 0 ? 80 : limit + const out: VaultTextSearchMatch[] = [] + for (const match of await this.inner.searchText(query, Number.MAX_SAFE_INTEGER)) { + if (out.length >= cap) break + const note = v.byPath.get(match.path) + if (note && v.visible(note.path)) out.push(match) + } + return out + } + + backlinks = async (rel: string): Promise<NoteMeta[]> => { + const meta = await this.noteFor(rel, 'read') + const v = await this.load() + return (await this.inner.backlinks(meta.path)).filter((note) => v.visible(note.path)) + } + + scanAllTasks = async (opts?: { includeExcluded?: boolean }): Promise<VaultTask[]> => { + const v = await this.load() + return (await this.inner.scanAllTasks(opts)).filter( + (task) => v.byPath.has(task.sourcePath) && v.visible(task.sourcePath) + ) + } + + toggleTask = async (taskId: string): Promise<VaultTask | null> => { + const hash = taskId.lastIndexOf('#') + if (hash <= 0) throw new Error(`Task not found: ${taskId}`) + const meta = await this.noteFor(taskId.slice(0, hash), 'write') + return await this.inner.toggleTask(meta.path + taskId.slice(hash)) + } + + listComments = async (rel: string): Promise<NoteComment[]> => + await this.inner.listComments((await this.noteFor(rel, 'read')).path) + + writeComments = async (rel: string, comments: NoteCommentInput[]): Promise<NoteComment[]> => + await this.inner.writeComments((await this.noteFor(rel, 'comment')).path, comments) + + /** No MCP tool reaches databases. */ + databaseOps = (): DatabaseOps => { + throw new Error(NOT_OFFERED) + } + + /** The scope as vault_info reports it, with entries in the vault's + * spelling, or why it shares nothing. */ + async accessInfo(): Promise<McpScopeAccessInfo> { + try { + const v = await this.load() + return mcpScopeAccessInfo(this.scope, v.allow, v.missingAllow) + } catch (err) { + return mcpScopeAccessInfo( + this.scope, + this.scope.allow, + [], + err instanceof Error ? err.message : String(err) + ) + } + } +} + +/** A call's backend under its scope; full access runs bare. */ +export function scopeBackend(backend: VaultBackend, scope: McpScope | null | undefined): VaultBackend { + return isMcpScopeActive(scope) ? new ScopedBackend(backend, scope) : backend +} + +/** + * Keeps the vault's absolute location out of a scoped client's errors: an + * MCP-only client has no use for it, and it names the user's home folder. + */ +export function scrubVaultRoot(message: string, root: string): string { + if (!root) return message + return message.split(root + path.sep).join('').split(root).join('the vault') +} diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index a3cd66179..6c348f5f2 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -30,6 +30,16 @@ import { import { RemoteRequestError } from '../main/remote/connection.js' import type { NoteFolder } from './vault-ops.js' import { addComment, listCommentThreads, replyToComment, resolveComment } from './comment-ops.js' +import { + isMcpScopeActive, + isMcpScopeRestricted, + isMcpToolOffered, + MCP_SCOPE_VAULT_INFO_NOTE, + mcpScopeInstructions, + mcpToolAnnotations, + type McpScope +} from '@shared/mcp-scope' +import { ScopedBackend, scopeBackend, scrubVaultRoot } from './scoped-backend.js' interface ToolContext { /** Set on the vault_info call that moved the session to a new vault: the @@ -139,7 +149,13 @@ const TOOLS: ToolDef[] = [ inputSchema: { type: 'object', properties: {} } }, handler: async (_args, backend, context) => { - const [description, folders] = await Promise.all([backend.describe(), backend.listFolders()]) + const scoped = backend instanceof ScopedBackend ? backend : null + const [description, folders] = await Promise.all([ + backend.describe(), + // A scope that shares nothing still answers vault_info, with the + // reason under access.blocked. + scoped ? backend.listFolders().catch(() => []) : backend.listFolders() + ]) const primaryNotesLocation = description.primaryNotesLocation const isRootMode = primaryNotesLocation === 'root' const switchNote = context?.previous @@ -150,6 +166,24 @@ const TOOLS: ToolDef[] = [ (isRootMode ? 'This vault uses ROOT mode: notes for the conceptual `inbox` folder live directly at the vault root (e.g. `MyNote.md`), not under `inbox/`. The folders quick/, archive/, trash/ are real subdirectories at the root.' : 'This vault uses INBOX mode: notes for the conceptual `inbox` folder live under `inbox/` (e.g. `inbox/MyNote.md`).') + if (scoped) { + // An assistant with scoped access gets no absolute paths: it has no + // use for them, and they name the user's home folder. + return { + kind: description.kind, + ...(description.kind === 'remote' + ? { server: description.baseUrl, serverName: description.name || null } + : {}), + primaryNotesLocation, + topFolders: ['inbox', 'quick', 'archive', 'trash'], + subfolders: folders, + access: await scoped.accessInfo(), + notes: + switchNote + + pathNotes + + (isMcpScopeRestricted(scoped.scope) ? MCP_SCOPE_VAULT_INFO_NOTE : '') + } + } if (description.kind === 'remote') { // The workspace the desktop app has open is a server (#688). Every // tool reads and writes through its HTTP API; the only thing this @@ -956,17 +990,21 @@ const TOOLS: ToolDef[] = [ /* ---------- Server plumbing ------------------------------------------ */ -/** Run one tool by name. The seam the tests use; the stdio server below goes - * through the same handlers. */ +/** Run one tool by name, under a scope when one is given, as the stdio + * server runs it: a tool the scope does not offer is unknown, and the rest + * see the vault through the scope. The seam the tests use; the stdio server + * below goes through the same handlers. */ export async function callTool( name: string, args: Record<string, unknown>, - backend: VaultBackend + backend: VaultBackend, + scope: McpScope | null = null ): Promise<unknown> { const tool = TOOLS.find((t) => t.schema.name === name) - if (!tool) throw new Error(`Unknown tool: ${name}`) - return await runRecordingAgentEdit(name, args, backend, defaultCommentAuthor(), () => - tool.handler(args, backend) + if (!tool || !isMcpToolOffered(name, scope)) throw new Error(`Unknown tool: ${name}`) + const seen = scopeBackend(backend, scope) + return await runRecordingAgentEdit(name, args, seen, defaultCommentAuthor(), () => + tool.handler(args, seen) ) } @@ -978,11 +1016,17 @@ export function listToolNames(): string[] { * A server that turns the request away is the one failure an agent cannot * fix by retrying: the desktop app keeps its token in the OS secret store, * where this process cannot read it, so say how to give it one. `target` is - * the vault the call ran against, null when none resolved. The Go `zn mcp` + * the vault the call ran against, null when none resolved. A client with + * scoped access never sees the vault's absolute location. The Go `zn mcp` * (ZenNotes/tui, internal/mcp/server.go) words it the same way. */ -export function describeToolError(err: unknown, target: VaultTarget | null = null): string { - const message = err instanceof Error ? err.message : String(err) +export function describeToolError( + err: unknown, + target: VaultTarget | null = null, + backend: VaultBackend | null = null +): string { + let message = err instanceof Error ? err.message : String(err) + if (backend instanceof ScopedBackend) message = scrubVaultRoot(message, backend.root) if (err instanceof RemoteRequestError && (err.status === 401 || err.status === 403)) { const server = target?.kind === 'remote' ? target : null // The environment outranks a saved token, so a stale one there keeps @@ -1116,6 +1160,13 @@ export interface McpServerOptions { * has open (#688), which is also the default here. */ resolveTarget?: () => Promise<VaultTarget> + /** + * What clients may see and change (ZenNotes/tui#21), from the server's own + * launch flags (`--allow-path`, `--deny-path`, `--read-only`, `--access`), + * never from a tool call. Null is full access. It applies to whichever + * vault the session follows: entries are vault-relative. + */ + scope?: McpScope | null /** Defaults to stdio, the only transport the clients speak. Tests bind an * in-memory pair instead. */ transport?: Transport @@ -1141,7 +1192,8 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise<void ) }) - const instructions = await resolveInstructions() + const scope = isMcpScopeActive(options.scope) ? options.scope : null + const instructions = (await resolveInstructions()) + (scope ? mcpScopeInstructions(scope) : '') const server = new Server( { name: 'zennotes', version: '0.1.0' }, { @@ -1154,35 +1206,44 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise<void connectedClientName = server.getClientVersion()?.name ?? null } + // A tool the scope does not offer is not listed, so the model never plans + // around it, and a call to it is an unknown tool. Every tool says whether + // it only reads or can destroy, scoped or not. server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: TOOLS.map((t) => t.schema) + tools: TOOLS.filter((t) => isMcpToolOffered(t.schema.name, scope)).map((t) => ({ + ...t.schema, + annotations: mcpToolAnnotations(t.schema.name) + })) })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params const tool = TOOLS.find((t) => t.schema.name === name) - if (!tool) { + if (!tool || !isMcpToolOffered(name, scope)) { return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true } } let target: VaultTarget | null = null + let backend: VaultBackend | null = null try { const resolved = await vaults.backendFor(name === 'vault_info') - const { backend, previous } = resolved + const { previous } = resolved target = resolved.target + backend = scopeBackend(resolved.backend, scope) const toolArgs = (args ?? {}) as Record<string, unknown> + const seen = backend // An edit to a note is recorded so the user can review it in the app. - const result = await runRecordingAgentEdit(name, toolArgs, backend, defaultCommentAuthor(), () => - tool.handler(toolArgs, backend, { previous }) + const result = await runRecordingAgentEdit(name, toolArgs, seen, defaultCommentAuthor(), () => + tool.handler(toolArgs, seen, { previous }) ) const payload = typeof result === 'string' ? result : JSON.stringify(result, null, 2) return { content: [{ type: 'text', text: payload }] } } catch (err) { return { - content: [{ type: 'text', text: `Error: ${describeToolError(err, target)}` }], + content: [{ type: 'text', text: `Error: ${describeToolError(err, target, backend)}` }], isError: true } } diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 55957c024..75b900fc1 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -1089,7 +1089,7 @@ async function uniqueTitle(dir: string, base: string): Promise<string> { } } -function sanitizeTitle(raw: string): string { +export function sanitizeTitle(raw: string): string { // Filenames must be safe on all 3 OSes. Strip path separators, null, // and common reserved characters. return raw diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 49590e80c..46935ac80 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1474,6 +1474,11 @@ export const HELP_CLI: HelpCard[] = [ body: '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. The Claude desktop app\'s Code tab runs Claude Code, so it reads the **Claude Code (CLI + Desktop Code tab)** entry in Settings → MCP; **Claude Desktop (Chat)** reaches only its Chat tab. Once `zn` is installed from Settings → CLI, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves; otherwise, a `zn` installed some other way included, they run the server bundled with the app. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. Switch vaults in the app and the server follows: the assistant\'s next tool call is refused with a note naming the old and the new vault, and once `vault_info` confirms the switch it carries on in the new one, so nothing planned in one vault lands in the other. A server that requires a token needs one the MCP server can read, because the app keeps its own copy in the OS secret store. Settings → MCP → Server token → **Let assistants use it** saves it to `credentials.toml` in the config folder, the file `zn connect` writes and both servers read (`zn mcp` and the bundled one), and the assistant\'s next call uses it. Any program running as you can read that file, so it is your choice: **Stop sharing** removes the token, forgetting the server in Settings → Vault removes it too, and a new token you save for the server replaces it. From a terminal, `zn connect <server-url> --no-default` saves it the same way without changing which vault `zn` follows, and `ZENNOTES_REMOTE_TOKEN` in the MCP client\'s environment works as well. To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token <token>` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' }, + { + title: 'Share part of a vault with an assistant', + body: + 'An assistant connected through MCP sees the whole vault unless its launch flags say otherwise, and the server enforces them on every tool: listings, search, tags, tasks, backlinks, assets, comments and error messages, so a hidden note cannot be found another way. `--allow-path <path>` shares only that folder or note and what is inside it (repeat it for more), `--deny-path <path>` hides one even inside an allowed folder, `--read-only` offers the reading tools only, and `--access comment` adds the comment tools to them. Paths are vault-relative, as the tools report them: `Projects` in a vault that keeps notes at its root, `inbox/Projects` in one with an Inbox folder, and a system folder you moved under its own name (`99 Archive/Projects` shares that folder\'s archived notes). A path covers the folder and everything below it, never a sibling that only starts with the same letters, and case and accents do not matter. `zn mcp` and the server bundled with the app take the same flags and give the same answers; add them after the command in the client\'s config, for example `"args": ["mcp", "--allow-path", "Projects", "--read-only"]`. Settings → MCP then shows that client as **Needs update**, and updating it there removes the flags.\n\nWhat the assistant gets: a hidden note answers exactly like a missing one (`Note not found`), and so do paths through a symbolic link, case variants and the `.zennotes` folder. Its errors and `vault_info` never show where the vault lives on disk, and `vault_info` lists what was shared under `access`. Tools it cannot use are not offered, and `empty_trash` never is once paths are restricted, since it would delete trashed notes it cannot see. New notes, moves and restores land only inside allowed folders, never through a symbolic link; archiving or trashing a note it can see is allowed even when the archive or trash is not shared, and the note then leaves its view. A folder is renamed or deleted as a whole only when nothing inside it is hidden. Renaming a note rewrites `[[links]]` only in notes the assistant can see with `zn mcp`, and in none with the bundled server; on a self-hosted ZenNotes server, which rewrites links in every note, an assistant that sees part of the vault cannot rename notes. A `--deny-path` that names nothing in the vault (renamed outside ZenNotes, or meant for another vault) shares nothing at all until it is fixed, so a rename never quietly reveals what it hid; the MCP server\'s log, which the client keeps, names the entry.\n\nThe flags govern the ZenNotes tools only. An assistant that can also run commands or read files (Claude Code, Codex, OpenCode) can still read the vault from disk.' + }, { title: 'Review an assistant’s edits', body: diff --git a/packages/bridge-contract/fixtures/README.md b/packages/bridge-contract/fixtures/README.md index f1567d63f..f6be144bf 100644 --- a/packages/bridge-contract/fixtures/README.md +++ b/packages/bridge-contract/fixtures/README.md @@ -36,10 +36,31 @@ Its client sends the edited Markdown; the server verifies storage and parsing before and after that write. The JSON and SHA-256 provenance are vendored in its `testdata` directory so `go test ./...` needs no Node or sibling checkout. +`mcp-scope.json` is scoped MCP access (ZenNotes/tui#21): what an MCP client +launched with `--allow-path`, `--deny-path`, `--read-only` or `--access` may see +and change. It seeds real vaults (a root-mode vault with a symbolic link to a +hidden folder and one out of the vault, accented folder names, an inbox-mode +vault with remapped system folders), builds a scope from flags, and drives the +MCP server the way a client does: the tools it lists with their annotations, +each call's result or exact error text (`Error: ` plus `error`), and what is on +disk afterwards. `$VAULT` in `contains` / `excludes` is the vault's absolute +path. `flagCases` hold the flag validation, refusals byte for byte. The +desktop's bundled server runs it in `apps/desktop/src/mcp/scope-contract.test.ts` +and the Go `zn mcp` in ZenNotes/tui `internal/mcp/scope_contract_test.go`; the +rules themselves live in `packages/shared-domain/src/mcp-scope.ts`. + +Two engine differences are left out of it on purpose. Renaming a note under a +path restriction rewrites `[[links]]` in the notes the client can see in zn, +and in no note at all in the desktop's engine, which never rewrites inbound +links; both leave hidden notes untouched, which is what the fixture checks. +And `list_assets` lists every non-note file in zn but only the attachments +folders in the desktop's engine, so the fixture checks only that hidden assets +stay out. + After changing a fixture, run `npm run sync:contract-fixtures -- <znserver checkout>` -(or set `ZENNOTES_SERVER_DIR`), then the TypeScript and Go checks in both -repositories; `npm run check:contract-fixtures -- <checkout>` compares without -writing. The TUI -consumer and fixture artifact publication are still pending. Do not silently -rewrite expected results to match a divergent implementation; identify the -intended behavior first. +(or set `ZENNOTES_SERVER_DIR`) and `npm run sync:contract-fixtures -- --tui <tui +checkout>` (or set `ZENNOTES_TUI_DIR`), then the TypeScript and Go checks in +every repository; `npm run check:contract-fixtures` with the same arguments +compares without writing. Fixture artifact publication is still pending. Do not +silently rewrite expected results to match a divergent implementation; identify +the intended behavior first. diff --git a/packages/bridge-contract/fixtures/mcp-scope.json b/packages/bridge-contract/fixtures/mcp-scope.json new file mode 100644 index 000000000..0b510a698 --- /dev/null +++ b/packages/bridge-contract/fixtures/mcp-scope.json @@ -0,0 +1,543 @@ +{ + "schemaVersion": 1, + "vaults": { + "root": { + "files": { + ".zennotes/vault.json": "{\"primaryNotesLocation\":\"root\"}", + "Projects/Plan.md": "# Plan\n\n- [ ] ship it\n\nSee [[Diary]]. #proj needle\n", + "Projects/Sub/Step.md": "# Step\n", + "Projects/Sub/More/Further.md": "# Further\n", + "Projects/Sub/More/Again.md": "# Again\n", + "Projects/Sub/More/Last.md": "# Last\n", + "Projects/Clients/Acme.md": "# Acme\n\nclient secret needle #private\n- [ ] invoice acme\n", + "Projects/Clients/Acme Two.md": "# Acme Two\n", + "Projects/diagram.png": "png", + "Areas/Health/Run.md": "# Run\n\nafter [[Plan]]\n", + "Areas/Health/Clinic/Visit.md": "# Visit\n", + "Areas/Health/Clinic/Notes.md": "# Notes\n", + "Areas/Health/Clinic/Bills.md": "# Bills\n", + "Areas/Money.md": "# Money\n\nneedle\n", + "Journal/Diary.md": "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n", + "Journal/Many1.md": "needle\nneedle\nneedle\n", + "Journal/Many2.md": "needle\nneedle\nneedle\n", + "Journal/Sub/Deep.md": "# Deep\n", + "Journal/Sub/Deeper/Bottom.md": "# Bottom\n", + "Journal/Sub/Deeper/Bottom2.md": "# Bottom2\n", + "archive/Projects/Old.md": "# Old\n", + "trash/Projects/Thrown.md": "# Thrown\n", + "attachements/pic.png": "png" + }, + "symlinks": { + "Projects/inner": "../Journal", + "Projects/link": "../../outside" + }, + "outside": { + "secret.md": "# Secret\n\nneedle outside the vault\n" + } + }, + "accents": { + "files": { + ".zennotes/vault.json": "{\"primaryNotesLocation\":\"root\"}", + "Café/Menu.md": "# Menu\n\ncroissant\n", + "Cafeteria/Lunch.md": "# Lunch\n", + "Notes/Ideas.md": "# Ideas\n" + } + }, + "remapped": { + "files": { + ".zennotes/vault.json": "{\"primaryNotesLocation\":\"inbox\",\"systemFolderPaths\":{\"inbox\":\"01 Inbox\",\"archive\":\"99 Archive\"}}", + "01 Inbox/Projects/A.md": "# A\n", + "01 Inbox/Private/B.md": "# B\n", + "99 Archive/Projects/Old.md": "# Old\n", + "99 Archive/Private/Gone.md": "# Gone\n" + } + } + }, + "flagCases": [ + { + "id": "entries-are-cleaned-and-read-only-wins", + "flags": { "access": "write", "readOnly": true, "allow": ["/Projects/", "Areas\\Health", "Projects"] }, + "scope": { "access": "read", "allow": ["Projects", "Areas/Health"], "deny": [] } + }, + { + "id": "access-spellings", + "flags": { "access": " Edit ", "deny": ["Journal"] }, + "scope": { "access": "write", "allow": [], "deny": ["Journal"] } + }, + { + "id": "comment-access", + "flags": { "access": "comment" }, + "scope": { "access": "comment", "allow": [], "deny": [] } + }, + { + "id": "hidden-entry", + "flags": { "allow": [".zennotes"] }, + "error": "--allow-path \".zennotes\" names a hidden folder, which an assistant never sees" + }, + { + "id": "hidden-segment-in-deny", + "flags": { "deny": ["a/.hidden/b"] }, + "error": "--deny-path \"a/.hidden/b\" names a hidden folder, which an assistant never sees" + }, + { + "id": "traversal", + "flags": { "allow": ["Projects/../Journal"] }, + "error": "--allow-path \"Projects/../Journal\": . and .. are not allowed; name a folder or note inside the vault" + }, + { + "id": "dot-slash", + "flags": { "allow": ["./"] }, + "error": "--allow-path \"./\": . and .. are not allowed; name a folder or note inside the vault" + }, + { + "id": "empty-entry", + "flags": { "allow": [""] }, + "error": "--allow-path \"\" is empty; name a folder or note inside the vault" + }, + { + "id": "slash-only-entry", + "flags": { "deny": ["/"] }, + "error": "--deny-path \"/\" is empty; name a folder or note inside the vault" + }, + { + "id": "nul-in-entry", + "flags": { "allow": ["Projects\u0000x"] }, + "error": "--allow-path \"Projects\\x00x\" is not a valid path" + }, + { + "id": "unknown-access", + "flags": { "access": "admin" }, + "error": "--access must be read, comment or write, not \"admin\"" + } + ], + "cases": [ + { + "id": "listings-show-only-what-is-shared", + "vault": "root", + "flags": { "access": "write", "allow": ["/projects", "Areas/Health"], "deny": ["Projects/Clients"] }, + "tools": { "count": 33, "absent": ["empty_trash"] }, + "calls": [ + { + "tool": "list_notes", + "args": {}, + "expect": { + "paths": [ + "Areas/Health/Clinic/Bills.md", + "Areas/Health/Clinic/Notes.md", + "Areas/Health/Clinic/Visit.md", + "Areas/Health/Run.md", + "Projects/Plan.md", + "Projects/Sub/More/Again.md", + "Projects/Sub/More/Further.md", + "Projects/Sub/More/Last.md", + "Projects/Sub/Step.md" + ] + } + }, + { "tool": "list_notes", "args": { "folder": "trash" }, "expect": { "paths": [] } }, + { + "tool": "list_folders", + "args": {}, + "expect": { + "folders": [ + "inbox:Areas", + "inbox:Areas/Health", + "inbox:Areas/Health/Clinic", + "inbox:Projects", + "inbox:Projects/Sub", + "inbox:Projects/Sub/More" + ] + } + }, + { "tool": "list_tags", "args": {}, "expect": { "contains": ["proj"], "excludes": ["private"] } }, + { "tool": "list_tasks", "args": {}, "expect": { "contains": ["ship it"], "excludes": ["invoice", "mum"] } }, + { "tool": "list_assets", "args": {}, "expect": { "excludes": ["attachements", "pic.png"] } }, + { "tool": "backlinks", "args": { "path": "Projects/Plan.md" }, "expect": { "paths": ["Areas/Health/Run.md"] } }, + { "tool": "search_by_tag", "args": { "tag": "private" }, "expect": { "paths": [] } }, + { "tool": "search_by_title", "args": { "query": "diary" }, "expect": { "paths": [] } }, + { "tool": "search_by_title", "args": { "query": "plan" }, "expect": { "paths": ["Projects/Plan.md"] } }, + { "tool": "search_text", "args": { "query": "needle", "limit": 1 }, "expect": { "paths": ["Projects/Plan.md"] } }, + { "tool": "search_text", "args": { "query": "needle" }, "expect": { "paths": ["Projects/Plan.md"] } }, + { "tool": "search_text", "args": { "query": "outside the vault" }, "expect": { "paths": [] } } + ] + }, + { + "id": "a-hidden-note-answers-like-a-missing-one", + "vault": "root", + "flags": { "access": "write", "allow": ["/projects", "Areas/Health"], "deny": ["Projects/Clients"] }, + "calls": [ + { "tool": "read_note", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "read_note", "args": { "path": "journal/DIARY.md" }, "expect": { "error": "Note not found: journal/DIARY.md" } }, + { "tool": "read_note", "args": { "path": "Projects/inner/Diary.md" }, "expect": { "error": "Note not found: Projects/inner/Diary.md" } }, + { "tool": "read_note", "args": { "path": "Projects/link/secret.md" }, "expect": { "error": "Note not found: Projects/link/secret.md" } }, + { "tool": "read_note", "args": { "path": "Projects/Clients/Acme.md" }, "expect": { "error": "Note not found: Projects/Clients/Acme.md" } }, + { "tool": "read_note", "args": { "path": "projects/clients/ACME.md" }, "expect": { "error": "Note not found: projects/clients/ACME.md" } }, + { "tool": "read_note", "args": { "path": ".zennotes/vault.json" }, "expect": { "error": "Note not found: .zennotes/vault.json" } }, + { "tool": "read_note", "args": { "path": "Projects/../Journal/Diary.md" }, "expect": { "error": "Note not found: Projects/../Journal/Diary.md" } }, + { "tool": "read_note", "args": { "path": "Journal/Missing.md" }, "expect": { "error": "Note not found: Journal/Missing.md" } }, + { "tool": "read_note", "args": { "path": "Areas/Money.md" }, "expect": { "error": "Note not found: Areas/Money.md" } }, + { "tool": "read_note", "args": { "path": "archive/Projects/Old.md" }, "expect": { "error": "Note not found: archive/Projects/Old.md" } }, + { "tool": "read_note", "args": { "path": "attachements/pic.png" }, "expect": { "error": "Note not found: attachements/pic.png" } }, + { "tool": "read_note", "args": { "path": "Projects/diagram.png" }, "expect": { "error": "Note not found: Projects/diagram.png" } }, + { "tool": "read_note", "args": { "path": "projects/plan.md" }, "expect": { "match": { "path": "Projects/Plan.md" } } }, + { "tool": "list_comments", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "backlinks", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "toggle_task", "args": { "id": "Journal/Diary.md#0" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "append_to_note", "args": { "path": "Journal/Diary.md", "text": "x" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "write_note", "args": { "path": "Projects/inner/Diary.md", "body": "gone" }, "expect": { "error": "Note not found: Projects/inner/Diary.md" } }, + { "tool": "add_comment", "args": { "path": "Journal/Diary.md", "body": "x" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "move_to_trash", "args": { "path": "Projects/Clients/Acme.md" }, "expect": { "error": "Note not found: Projects/Clients/Acme.md" } }, + { "tool": "delete_note", "args": { "path": "Journal/Diary.md", "confirm": true }, "expect": { "error": "Note not found: Journal/Diary.md" } } + ], + "disk": { + "equals": { + "Journal/Diary.md": "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n", + "Projects/Clients/Acme.md": "# Acme\n\nclient secret needle #private\n- [ ] invoice acme\n" + } + } + }, + { + "id": "writes-stay-inside-the-scope", + "vault": "root", + "flags": { "access": "write", "allow": ["/projects", "Areas/Health"], "deny": ["Projects/Clients"] }, + "calls": [ + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "projects/New", "title": "Idea" }, "expect": { "match": { "path": "Projects/New/Idea.md" } } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Journal", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Areas", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": ".zennotes", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Attachements", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "archive", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Clients", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "projects/clients/Deeper", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/../Journal", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Books.base", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "archive", "subpath": "Projects", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_folder", "args": { "folder": "inbox", "subpath": "Journal/Planted" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "move_note", "args": { "path": "Projects/Plan.md", "targetFolder": "inbox", "targetSubpath": "Journal" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "move_note", "args": { "path": "Projects/Sub/Step.md", "targetFolder": "archive", "targetSubpath": "Projects" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "rename_note", "args": { "path": "Projects/Plan.md", "newTitle": "Plan B" }, "expect": { "match": { "path": "Projects/Plan B.md" } } }, + { "tool": "rename_note", "args": { "path": "Projects/Clients/Acme Two.md", "newTitle": "Acme" }, "expect": { "error": "Note not found: Projects/Clients/Acme Two.md" } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Projects", "confirm": true }, "expect": { "error": "This assistant cannot delete that folder." } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Journal", "confirm": true }, "expect": { "error": "Folder not found: Journal" } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "attachements", "confirm": true }, "expect": { "error": "Folder not found: attachements" } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Projects/inner", "confirm": true }, "expect": { "error": "Folder not found: Projects/inner" } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "Projects", "newSubpath": "Work" }, "expect": { "error": "This assistant cannot rename that folder." } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "Areas/Health", "newSubpath": "Health" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "projects/sub", "newSubpath": "Projects/Steps" }, "expect": { "ok": true } }, + { "tool": "move_to_trash", "args": { "path": "Projects/Steps/Step.md" }, "expect": { "match": { "path": "trash/Projects/Steps/Step.md", "folder": "trash" } } }, + { "tool": "read_note", "args": { "path": "trash/Projects/Steps/Step.md" }, "expect": { "error": "Note not found: trash/Projects/Steps/Step.md" } }, + { "tool": "restore_from_trash", "args": { "path": "trash/Projects/Steps/Step.md" }, "expect": { "error": "Note not found: trash/Projects/Steps/Step.md" } } + ], + "disk": { + "exists": [ + "Projects/New/Idea.md", + "Projects/Plan B.md", + "Projects/Steps/More/Last.md", + "trash/Projects/Steps/Step.md", + "Journal/Diary.md", + "Projects/Clients/Acme.md", + "Projects/Clients/Acme Two.md", + "attachements/pic.png" + ], + "absent": ["Projects/Plan.md", "Projects/Sub", "Journal/Planted", "Journal/Nope.md", "Nope.md"], + "equals": { + "Journal/Diary.md": "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n" + } + } + }, + { + "id": "nothing-is-created-through-a-link", + "vault": "root", + "flags": { "access": "write", "allow": ["Projects"] }, + "calls": [ + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/inner", "title": "ThroughLink" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/link", "title": "Out" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Plan.md", "title": "Under" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_folder", "args": { "folder": "inbox", "subpath": "Projects/inner/Made" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "move_note", "args": { "path": "Projects/Sub/Step.md", "targetFolder": "inbox", "targetSubpath": "Projects/inner" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "Projects/Sub", "newSubpath": "Projects/inner/Sub" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Fresh/Deeper", "title": "New" }, "expect": { "match": { "path": "Projects/Fresh/Deeper/New.md" } } } + ], + "disk": { + "exists": ["Projects/Sub/Step.md", "Projects/Fresh/Deeper/New.md"], + "absent": ["Journal/ThroughLink.md", "Journal/Made", "Journal/Step.md", "Journal/Sub/Step.md", "Projects/Plan.md/Under.md"] + } + }, + { + "id": "read-access-lists-only-read-tools", + "vault": "root", + "flags": { "readOnly": true }, + "tools": { + "exactly": [ + "backlinks", + "list_assets", + "list_comments", + "list_folders", + "list_notes", + "list_tags", + "list_tasks", + "read_note", + "search_by_tag", + "search_by_title", + "search_text", + "vault_info" + ], + "annotations": { + "read_note": { "readOnlyHint": true, "idempotentHint": false, "openWorldHint": false }, + "vault_info": { "readOnlyHint": true, "idempotentHint": false, "openWorldHint": false } + } + }, + "calls": [ + { "tool": "write_note", "args": { "path": "Projects/Plan.md", "body": "gone" }, "expect": { "notOffered": true } }, + { "tool": "add_comment", "args": { "path": "Projects/Plan.md", "body": "x" }, "expect": { "notOffered": true } }, + { "tool": "read_note", "args": { "path": "Journal/Diary.md" }, "expect": { "match": { "path": "Journal/Diary.md" } } } + ], + "disk": { + "equals": { "Projects/Plan.md": "# Plan\n\n- [ ] ship it\n\nSee [[Diary]]. #proj needle\n" }, + "absent": [".zennotes/comments"] + } + }, + { + "id": "comment-access-adds-the-comment-tools", + "vault": "root", + "flags": { "access": "comment", "allow": ["Projects"] }, + "tools": { + "exactly": [ + "add_comment", + "backlinks", + "list_assets", + "list_comments", + "list_folders", + "list_notes", + "list_tags", + "list_tasks", + "read_note", + "reply_to_comment", + "resolve_comment", + "search_by_tag", + "search_by_title", + "search_text", + "vault_info" + ], + "annotations": { + "add_comment": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false } + } + }, + "calls": [ + { "tool": "add_comment", "args": { "path": "projects/plan.md", "body": "Looks good", "anchor_text": "ship it" }, "expect": { "match": { "body": "Looks good", "anchorText": "ship it" } } }, + { "tool": "list_comments", "args": { "path": "Projects/Plan.md" }, "expect": { "contains": ["Looks good"] } }, + { "tool": "add_comment", "args": { "path": "Journal/Diary.md", "body": "x" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "toggle_task", "args": { "id": "Projects/Plan.md#0" }, "expect": { "notOffered": true } } + ], + "disk": { + "equals": { "Projects/Plan.md": "# Plan\n\n- [ ] ship it\n\nSee [[Diary]]. #proj needle\n" } + } + }, + { + "id": "full-access-lists-every-tool-with-annotations", + "vault": "root", + "flags": { "access": "write" }, + "tools": { + "count": 34, + "include": ["empty_trash", "delete_folder"], + "annotations": { + "list_notes": { "readOnlyHint": true, "idempotentHint": false, "openWorldHint": false }, + "write_note": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "replace_in_note": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "delete_note": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "delete_folder": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "empty_trash": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "append_to_note": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }, + "move_to_trash": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }, + "create_note": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false } + } + }, + "calls": [ + { "tool": "vault_info", "args": {}, "expect": { "contains": ["$VAULT"], "absentKeys": ["access"] } } + ] + }, + { + "id": "deny-only-keeps-the-rest-of-the-vault", + "vault": "root", + "flags": { "deny": ["Journal"] }, + "tools": { "count": 33, "absent": ["empty_trash"] }, + "calls": [ + { + "tool": "list_notes", + "args": {}, + "expect": { + "paths": [ + "Areas/Health/Clinic/Bills.md", + "Areas/Health/Clinic/Notes.md", + "Areas/Health/Clinic/Visit.md", + "Areas/Health/Run.md", + "Areas/Money.md", + "Projects/Clients/Acme Two.md", + "Projects/Clients/Acme.md", + "Projects/Plan.md", + "Projects/Sub/More/Again.md", + "Projects/Sub/More/Further.md", + "Projects/Sub/More/Last.md", + "Projects/Sub/Step.md", + "archive/Projects/Old.md" + ] + } + }, + { "tool": "list_assets", "args": {}, "expect": { "contains": ["attachements/pic.png"] } }, + { "tool": "backlinks", "args": { "path": "Projects/Plan.md" }, "expect": { "paths": ["Areas/Health/Run.md"] } }, + { "tool": "create_note", "args": { "folder": "inbox", "title": "Top" }, "expect": { "match": { "path": "Top.md" } } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "journal", "title": "Nope" }, "expect": { "error": "This assistant cannot put notes there." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Journal/Sub", "title": "Nope" }, "expect": { "error": "This assistant cannot put notes there." } }, + { "tool": "restore_from_trash", "args": { "path": "trash/Projects/Thrown.md" }, "expect": { "match": { "path": "Projects/Thrown.md", "folder": "inbox" } } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Areas/Health/Clinic", "confirm": true }, "expect": { "ok": true } }, + { "tool": "empty_trash", "args": { "confirm": true }, "expect": { "notOffered": true } } + ], + "disk": { + "exists": ["Top.md", "Projects/Thrown.md", "Journal/Diary.md", "Journal/Sub/Deep.md"], + "absent": ["Areas/Health/Clinic", "Journal/Nope.md", "Journal/Sub/Nope.md"] + } + }, + { + "id": "an-allowed-note-shows-its-folders-by-name-only", + "vault": "root", + "flags": { "readOnly": true, "allow": ["areas/money.md"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Areas/Money.md"] } }, + { "tool": "list_folders", "args": {}, "expect": { "folders": ["inbox:Areas"] } }, + { "tool": "read_note", "args": { "path": "Areas/Health/Run.md" }, "expect": { "error": "Note not found: Areas/Health/Run.md" } }, + { "tool": "search_text", "args": { "query": "needle" }, "expect": { "paths": ["Areas/Money.md"] } }, + { + "tool": "vault_info", + "args": {}, + "expect": { "match": { "access": { "level": "read", "partial": true, "allow": ["Areas/Money.md"], "missingAllow": [] } } } + } + ] + }, + { + "id": "vault-info-names-no-absolute-paths-or-deny-entries", + "vault": "root", + "flags": { "access": "read", "allow": ["projects", "Areas/Gone"], "deny": ["Projects/Clients"] }, + "calls": [ + { + "tool": "vault_info", + "args": {}, + "expect": { + "match": { + "kind": "local", + "primaryNotesLocation": "root", + "topFolders": ["inbox", "quick", "archive", "trash"], + "access": { "level": "read", "partial": true, "allow": ["Areas/Gone", "Projects"], "missingAllow": ["Areas/Gone"] } + }, + "absentKeys": ["vaultRoot", "inboxAbsolutePath", "vaultPath"], + "excludes": ["$VAULT", "Clients", "Journal", "blocked"] + } + }, + { "tool": "read_note", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md", "excludes": ["$VAULT"] } } + ] + }, + { + "id": "a-stale-deny-entry-shares-nothing", + "vault": "root", + "flags": { "access": "read", "deny": ["Diary Folder"] }, + "calls": [ + { + "tool": "list_notes", + "args": {}, + "expect": { + "error": "This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed." + } + }, + { + "tool": "read_note", + "args": { "path": "Projects/Plan.md" }, + "expect": { + "error": "This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed." + } + }, + { + "tool": "vault_info", + "args": {}, + "expect": { + "match": { + "subfolders": [], + "access": { + "level": "read", + "partial": true, + "allow": [], + "missingAllow": [], + "blocked": "This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed." + } + }, + "excludes": ["Diary Folder"] + } + } + ] + }, + { + "id": "deny-matches-across-case-and-accents", + "vault": "accents", + "flags": { "access": "read", "deny": ["cafe"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Cafeteria/Lunch.md", "Notes/Ideas.md"] } }, + { "tool": "list_folders", "args": {}, "expect": { "folders": ["inbox:Cafeteria", "inbox:Notes"] } }, + { "tool": "read_note", "args": { "path": "Café/Menu.md" }, "expect": { "error": "Note not found: Café/Menu.md" } }, + { "tool": "read_note", "args": { "path": "CAFÉ/menu.md" }, "expect": { "error": "Note not found: CAFÉ/menu.md" } }, + { "tool": "search_text", "args": { "query": "croissant" }, "expect": { "paths": [] } } + ] + }, + { + "id": "deny-matches-a-decomposed-spelling", + "vault": "accents", + "flags": { "access": "read", "deny": ["CAFÉ"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Cafeteria/Lunch.md", "Notes/Ideas.md"] } } + ] + }, + { + "id": "allow-takes-the-vault-spelling", + "vault": "accents", + "flags": { "access": "read", "allow": ["cafe"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Café/Menu.md"] } }, + { "tool": "read_note", "args": { "path": "cafe/menu.md" }, "expect": { "match": { "path": "Café/Menu.md" } } }, + { "tool": "read_note", "args": { "path": "Cafeteria/Lunch.md" }, "expect": { "error": "Note not found: Cafeteria/Lunch.md" } }, + { "tool": "vault_info", "args": {}, "expect": { "match": { "access": { "allow": ["Café"], "missingAllow": [] } } } } + ] + }, + { + "id": "remapped-system-folders-need-no-translation", + "vault": "remapped", + "flags": { "access": "write", "allow": ["01 Inbox/Projects", "99 Archive/Projects"] }, + "calls": [ + { "tool": "list_notes", "args": { "folder": "archive" }, "expect": { "paths": ["99 Archive/Projects/Old.md"] } }, + { "tool": "list_notes", "args": {}, "expect": { "paths": ["01 Inbox/Projects/A.md", "99 Archive/Projects/Old.md"] } }, + { "tool": "list_folders", "args": {}, "expect": { "folders": ["archive:Projects", "inbox:Projects"] } }, + { "tool": "read_note", "args": { "path": "01 Inbox/Private/B.md" }, "expect": { "error": "Note not found: 01 Inbox/Private/B.md" } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects", "title": "C" }, "expect": { "match": { "path": "01 Inbox/Projects/C.md" } } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Private", "title": "D" }, "expect": { "error": "This assistant can create and move notes only inside: 01 Inbox/Projects, 99 Archive/Projects." } }, + { "tool": "archive_note", "args": { "path": "01 Inbox/Projects/A.md" }, "expect": { "match": { "path": "99 Archive/Projects/A.md", "folder": "archive" } } }, + { "tool": "unarchive_note", "args": { "path": "99 Archive/Projects/A.md" }, "expect": { "match": { "path": "01 Inbox/Projects/A.md", "folder": "inbox" } } }, + { "tool": "unarchive_note", "args": { "path": "99 Archive/Private/Gone.md" }, "expect": { "error": "Note not found: 99 Archive/Private/Gone.md" } } + ], + "disk": { + "exists": ["01 Inbox/Projects/A.md", "01 Inbox/Projects/C.md", "99 Archive/Private/Gone.md"], + "absent": ["01 Inbox/Private/D.md", "99 Archive/Projects/A.md"] + } + }, + { + "id": "archiving-is-an-exit-from-the-scope", + "vault": "remapped", + "flags": { "access": "write", "allow": ["01 Inbox/Projects"] }, + "calls": [ + { "tool": "archive_note", "args": { "path": "01 Inbox/Projects/A.md" }, "expect": { "ok": true } }, + { "tool": "read_note", "args": { "path": "99 Archive/Projects/A.md" }, "expect": { "error": "Note not found: 99 Archive/Projects/A.md" } }, + { "tool": "unarchive_note", "args": { "path": "99 Archive/Projects/A.md" }, "expect": { "error": "Note not found: 99 Archive/Projects/A.md" } } + ], + "disk": { + "exists": ["99 Archive/Projects/A.md"], + "absent": ["01 Inbox/Projects/A.md"] + } + } + ] +} diff --git a/packages/shared-domain/src/mcp-scope.test.ts b/packages/shared-domain/src/mcp-scope.test.ts new file mode 100644 index 000000000..88e31b191 --- /dev/null +++ b/packages/shared-domain/src/mcp-scope.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { + compareMcpScopePaths, + foldMcpScopePath, + isInsideMcpScopeEntry, + isInsideMcpScopeEntryFolded, + isMcpScopeActive, + isMcpToolOffered, + MCP_TOOL_ACCESS, + mcpAccessRefusal, + mcpScopeAccessInfo, + mcpScopeFromFlags, + mcpScopeInstructions, + mcpToolAnnotations, + newMcpScope +} from './mcp-scope' + +// The flag refusals, tool lists and listings are held to the Go `zn mcp` by +// the shared fixture (bridge-contract/fixtures/mcp-scope.json); these cover +// the pure rules underneath it. + +describe('MCP scope matching', () => { + it('matches whole segments only', () => { + expect(isInsideMcpScopeEntry('Projects/x.md', 'Projects')).toBe(true) + expect(isInsideMcpScopeEntry('Projects', 'Projects')).toBe(true) + expect(isInsideMcpScopeEntry('Projects2/x.md', 'Projects')).toBe(false) + expect(isInsideMcpScopeEntry('projects/x.md', 'Projects')).toBe(false) + }) + + it('folds case and accents for deny entries, the way zn does', () => { + expect(foldMcpScopePath('Café/Menu.md')).toBe('cafe/menu.md') + expect(foldMcpScopePath('CAFÉ')).toBe('cafe') + // Lower-cased one code point at a time, so no final sigma: Go's + // strings.ToLower has none. + expect(foldMcpScopePath('ΟΔΟΣ')).toBe('οδοσ') + expect(isInsideMcpScopeEntryFolded('café/MENU.md', 'CAFE')).toBe(true) + expect(isInsideMcpScopeEntryFolded('Cafeteria/x.md', 'Café')).toBe(false) + }) + + it('sorts by code point, as Go sorts strings', () => { + // UTF-16 order puts the emoji's surrogate pair first; byte order does not. + expect(['😀', '~'].sort(compareMcpScopePaths)).toEqual(['~', '😀']) + expect(['b', 'B', 'a'].sort(compareMcpScopePaths)).toEqual(['B', 'a', 'b']) + }) +}) + +describe('MCP scope flags', () => { + it('means full access when no flag is given', () => { + expect(mcpScopeFromFlags({ allow: [], deny: [] })).toBeNull() + expect(isMcpScopeActive(mcpScopeFromFlags({ access: 'write' }))).toBe(false) + expect(isMcpScopeActive(mcpScopeFromFlags({ readOnly: true }))).toBe(true) + }) + + it('refuses rather than widen', () => { + expect(() => newMcpScope({ allow: ['Projects', '.git'] })).toThrow('names a hidden folder') + expect(() => newMcpScope({ access: 'owner' })).toThrow('--access must be') + }) +}) + +describe('MCP tools under a scope', () => { + const read = newMcpScope({ access: 'read' }) + const comment = newMcpScope({ access: 'comment' }) + const write = newMcpScope({ access: 'write', allow: ['Projects'] }) + + it('offers each tool by its access class', () => { + expect(isMcpToolOffered('read_note', read)).toBe(true) + expect(isMcpToolOffered('add_comment', read)).toBe(false) + expect(isMcpToolOffered('add_comment', comment)).toBe(true) + expect(isMcpToolOffered('toggle_task', comment)).toBe(false) + expect(isMcpToolOffered('toggle_task', write)).toBe(true) + expect(isMcpToolOffered('anything', null)).toBe(true) + }) + + it('never offers a tool that acts on every path to a client that sees some', () => { + expect(isMcpToolOffered('empty_trash', write)).toBe(false) + expect(isMcpToolOffered('empty_trash', newMcpScope({ access: 'write' }))).toBe(true) + }) + + it('treats a tool nobody classified as acting on every path', () => { + expect(MCP_TOOL_ACCESS).not.toHaveProperty('new_tool') + expect(isMcpToolOffered('new_tool', write)).toBe(false) + expect(isMcpToolOffered('new_tool', read)).toBe(false) + expect(mcpToolAnnotations('new_tool')).toMatchObject({ readOnlyHint: false, destructiveHint: false }) + }) + + it('says what a tool does to the vault', () => { + expect(mcpToolAnnotations('search_text')).toEqual({ + readOnlyHint: true, + idempotentHint: false, + openWorldHint: false + }) + expect(mcpToolAnnotations('delete_folder')).toMatchObject({ destructiveHint: true }) + expect(mcpToolAnnotations('append_to_note')).toMatchObject({ destructiveHint: false }) + }) + + it('refuses a tool above the access level without naming a path', () => { + expect(mcpAccessRefusal(read)).toBe('This assistant can only read this vault.') + expect(mcpAccessRefusal(comment)).toBe( + 'This assistant can only read this vault and comment on notes.' + ) + }) +}) + +describe('what a scoped client is told', () => { + it('asks the model not to guess at hidden notes only when paths are hidden', () => { + const narrowed = mcpScopeInstructions(newMcpScope({ access: 'read', allow: ['Projects'] })) + expect(narrowed).toContain('do not guess') + expect(narrowed).toContain('You can read but not change anything.') + expect(narrowed).toContain('`vault_info`') + expect(mcpScopeInstructions(newMcpScope({ access: 'comment' }))).not.toContain('do not guess') + }) + + it('reports allow entries sorted and never the deny entries', () => { + const scope = newMcpScope({ access: 'read', allow: ['Zeta', 'Alpha'], deny: ['Secret'] }) + const info = mcpScopeAccessInfo(scope, ['Zeta', 'Alpha'], ['Zeta']) + expect(info).toEqual({ level: 'read', partial: true, allow: ['Alpha', 'Zeta'], missingAllow: ['Zeta'] }) + expect(JSON.stringify(info)).not.toContain('Secret') + expect(mcpScopeAccessInfo(scope, [], [], 'why').blocked).toBe('why') + }) +}) diff --git a/packages/shared-domain/src/mcp-scope.ts b/packages/shared-domain/src/mcp-scope.ts new file mode 100644 index 000000000..573b17e8d --- /dev/null +++ b/packages/shared-domain/src/mcp-scope.ts @@ -0,0 +1,364 @@ +// Scoped access for the MCP servers (ZenNotes/tui#21): what an MCP client may +// see and change, fixed when the server starts from its own launch flags. No +// tool argument can name or widen a scope; tools only narrow inside it. +// +// This module is the normative copy of the rules. The desktop's bundled MCP +// server imports it (apps/desktop/src/mcp/scoped-backend.ts), and the Go +// `zn mcp` mirrors it in internal/mcp/scope.go in ZenNotes/tui, so a client +// gets the same answers whichever binary its config launches. The shared +// fixture packages/bridge-contract/fixtures/mcp-scope.json holds both to the +// same bytes; change the rules here, there and in the fixture together. + +/** How much a client may do to what it can see. */ +export type McpAccess = 'read' | 'comment' | 'write' + +const ACCESS_RANK: Record<McpAccess, number> = { read: 0, comment: 1, write: 2 } + +/** Whether `have` allows what `need` asks for. */ +export function mcpAccessAtLeast(have: McpAccess, need: McpAccess): boolean { + return ACCESS_RANK[have] >= ACCESS_RANK[need] +} + +/** + * An access level plus the paths a client may see. An empty `allow` means + * the whole vault; a non-empty one shows only what it names (default-deny), + * and `deny` hides paths even inside it. Entries are vault-relative paths + * exactly as the tools report them, so remapped system folders need no + * translation. + */ +export interface McpScope { + access: McpAccess + allow: string[] + deny: string[] +} + +/** The `zn mcp` flags a scope is built from, as parsed off the command line. */ +export interface McpScopeFlags { + allow?: readonly string[] + deny?: readonly string[] + access?: string + readOnly?: boolean +} + +/** Reads `read`, `comment` or `write`, and the spellings people reach for. */ +export function parseMcpAccess(raw: string): McpAccess { + switch (raw.trim().toLowerCase()) { + case 'read': + case 'read-only': + case 'readonly': + return 'read' + case 'comment': + return 'comment' + case 'write': + case 'edit': + return 'write' + } + throw new Error(`--access must be read, comment or write, not ${goQuote(raw)}`) +} + +/** + * Builds a scope from `zn mcp` flags. `readOnly` lowers access to read + * whatever `access` says; no access at all is write, the behavior without a + * scope. A bad entry throws: it never silently widens what a client sees. + */ +export function newMcpScope(flags: McpScopeFlags): McpScope { + let access: McpAccess = 'write' + if ((flags.access ?? '').trim()) access = parseMcpAccess(flags.access ?? '') + if (flags.readOnly) access = 'read' + return { + access, + allow: normalizeMcpScopeEntries('--allow-path', flags.allow ?? []), + deny: normalizeMcpScopeEntries('--deny-path', flags.deny ?? []) + } +} + +/** The scope the flags ask for, or null when none were given: full access, + * exactly as before scopes existed. */ +export function mcpScopeFromFlags(flags: McpScopeFlags): McpScope | null { + if ( + (flags.allow ?? []).length === 0 && + (flags.deny ?? []).length === 0 && + !flags.access && + !flags.readOnly + ) { + return null + } + return newMcpScope(flags) +} + +/** + * Validates entries the way the Tasks folder lists are validated + * (`normalizeTasksExcludedFolder`: forward slashes, trimmed segments, no `.` + * or `..`, at most 512 characters, duplicates dropped, order kept), and + * refuses hidden entries, which never hold anything a client can see. A + * leading slash is accepted: an entry is always vault-relative. + */ +export function normalizeMcpScopeEntries(flag: string, values: readonly string[]): string[] { + const out: string[] = [] + const seen = new Set<string>() + for (const raw of values) { + const parts: string[] = [] + for (const seg of raw.replace(/\\/g, '/').split('/')) { + const s = seg.trim() + if (!s) continue + if (s === '.' || s === '..') { + throw new Error( + `${flag} ${goQuote(raw)}: . and .. are not allowed; name a folder or note inside the vault` + ) + } + if (s.startsWith('.')) { + throw new Error(`${flag} ${goQuote(raw)} names a hidden folder, which an assistant never sees`) + } + if (s.includes('\u0000')) throw new Error(`${flag} ${goQuote(raw)} is not a valid path`) + parts.push(s) + } + if (parts.length === 0) { + throw new Error(`${flag} ${goQuote(raw)} is empty; name a folder or note inside the vault`) + } + const joined = parts.join('/') + if (joined.length > 512) throw new Error(`${flag} ${goQuote(raw)} is longer than 512 characters`) + if (seen.has(joined)) continue + seen.add(joined) + out.push(joined) + } + return out +} + +/** Whether the scope narrows the paths at all. */ +export function isMcpScopeRestricted(scope: McpScope): boolean { + return scope.allow.length > 0 || scope.deny.length > 0 +} + +/** Whether the scope changes anything compared with full access. */ +export function isMcpScopeActive(scope: McpScope | null | undefined): scope is McpScope { + return !!scope && (isMcpScopeRestricted(scope) || scope.access !== 'write') +} + +/* ---------- Matching ---------------------------------------------------- */ + +/** + * Case and accent folding: decompose, drop the nonspacing marks, recompose, + * lower-case one code point at a time. The Go side is `search.Fold`; lowering + * per code point keeps JavaScript's context-sensitive final sigma out of it, + * so both sides fold `ΟΔΟΣ` the same way. + */ +export function foldMcpScopePath(value: string): string { + const stripped = value.normalize('NFD').replace(/\p{Mn}/gu, '').normalize('NFC') + let out = '' + for (const ch of stripped) out += ch.toLowerCase() + return out +} + +/** The segment-prefix rule of the Tasks folder lists: `Projects` covers + * `Projects/x.md`, never `Projects2/x.md`. */ +export function isInsideMcpScopeEntry(path: string, entry: string): boolean { + return path === entry || path.startsWith(`${entry}/`) +} + +/** The same rule after case and accent folding. Deny entries match this way + * on purpose: on a case- or normalization-insensitive volume a near miss + * must over-hide, never leak. */ +export function isInsideMcpScopeEntryFolded(path: string, entry: string): boolean { + return isInsideMcpScopeEntry(foldMcpScopePath(path), foldMcpScopePath(entry)) +} + +/** Sorts by code point, which is UTF-8 byte order, as Go's sort.Strings does. */ +export function compareMcpScopePaths(a: string, b: string): number { + const ia = a[Symbol.iterator]() + const ib = b[Symbol.iterator]() + for (;;) { + const ca = ia.next() + const cb = ib.next() + if (ca.done || cb.done) return ca.done === cb.done ? 0 : ca.done ? -1 : 1 + const diff = (ca.value.codePointAt(0) ?? 0) - (cb.value.codePointAt(0) ?? 0) + if (diff !== 0) return diff + } +} + +/* ---------- Tools ------------------------------------------------------- */ + +/** + * What a tool needs. `unscopedWrite` acts on content regardless of path + * (empty_trash deletes every trashed note), so no path-restricted client gets + * it. + */ +export type McpToolClass = 'read' | 'comment' | 'write' | 'unscopedWrite' + +/** Every MCP tool and what it needs. A tool missing here is treated as + * `unscopedWrite`, and a test in each engine fails until it is classified. */ +export const MCP_TOOL_ACCESS: Readonly<Record<string, McpToolClass>> = { + vault_info: 'read', + list_notes: 'read', + list_folders: 'read', + list_assets: 'read', + read_note: 'read', + search_text: 'read', + search_by_title: 'read', + search_by_tag: 'read', + list_tags: 'read', + backlinks: 'read', + list_tasks: 'read', + list_comments: 'read', + add_comment: 'comment', + reply_to_comment: 'comment', + resolve_comment: 'comment', + toggle_task: 'write', + write_note: 'write', + append_to_note: 'write', + prepend_to_note: 'write', + insert_at_line: 'write', + replace_in_note: 'write', + create_note: 'write', + rename_note: 'write', + move_note: 'write', + duplicate_note: 'write', + archive_note: 'write', + unarchive_note: 'write', + move_to_trash: 'write', + restore_from_trash: 'write', + delete_note: 'write', + create_folder: 'write', + rename_folder: 'write', + delete_folder: 'write', + empty_trash: 'unscopedWrite' +} + +/** Tools that may destroy content rather than add to it or move it. */ +export const MCP_DESTRUCTIVE_TOOLS: ReadonlySet<string> = new Set([ + 'write_note', + 'replace_in_note', + 'delete_note', + 'delete_folder', + 'empty_trash' +]) + +export function mcpToolClass(name: string): McpToolClass { + return Object.prototype.hasOwnProperty.call(MCP_TOOL_ACCESS, name) + ? (MCP_TOOL_ACCESS[name] as McpToolClass) + : 'unscopedWrite' +} + +/** Whether a scope offers a tool. No scope is full access. A tool a scope + * does not offer is not listed at all, so the model never plans around it. */ +export function isMcpToolOffered(name: string, scope: McpScope | null | undefined): boolean { + if (!scope) return true + switch (mcpToolClass(name)) { + case 'read': + return true + case 'comment': + return mcpAccessAtLeast(scope.access, 'comment') + case 'write': + return scope.access === 'write' + default: + return scope.access === 'write' && !isMcpScopeRestricted(scope) + } +} + +export interface McpToolAnnotations { + readOnlyHint: boolean + destructiveHint?: boolean + idempotentHint: boolean + openWorldHint: boolean +} + +/** Tells clients which tools only read and which can destroy, so a client + * that confirms destructive calls can, scoped or not. The vault is a closed + * world. `readOnlyHint` and `idempotentHint` are spelled out even when false, + * as the Go MCP SDK always sends them, so both engines list the same bytes. */ +export function mcpToolAnnotations(name: string): McpToolAnnotations { + if (mcpToolClass(name) === 'read') { + return { readOnlyHint: true, idempotentHint: false, openWorldHint: false } + } + return { + readOnlyHint: false, + destructiveHint: MCP_DESTRUCTIVE_TOOLS.has(name), + idempotentHint: false, + openWorldHint: false + } +} + +/** The answer to a tool above the scope's access level. It names no path, so + * it says nothing about the vault. */ +export function mcpAccessRefusal(scope: McpScope): string { + switch (scope.access) { + case 'read': + return 'This assistant can only read this vault.' + case 'comment': + return 'This assistant can only read this vault and comment on notes.' + } + return 'This assistant cannot use that tool on a vault it only partly sees.' +} + +/** Appended to the server instructions while a scope is active, so the model + * neither guesses at hidden notes nor tries tools it does not have. */ +export function mcpScopeInstructions(scope: McpScope): string { + let text = '\n\n## Access\n\n' + if (isMcpScopeRestricted(scope)) { + text += + "This server shows part of the user's vault. Paths and notes you are not shown do not exist for you: do not guess at them, and treat a [[link]] that does not resolve as pointing outside what you can see. " + } + if (scope.access === 'read') text += 'You can read but not change anything. ' + else if (scope.access === 'comment') text += 'You can read and comment on notes, but not change them. ' + return `${text}Call \`vault_info\` to see what you can reach.` +} + +/** The sentence vault_info adds to its notes when the paths are narrowed. */ +export const MCP_SCOPE_VAULT_INFO_NOTE = + ' You see part of this vault: `access.allow` lists what was shared (empty means everything except some hidden paths). Anything else does not exist for you.' + +export interface McpScopeAccessInfo { + level: McpAccess + partial: boolean + allow: string[] + missingAllow: string[] + blocked?: string +} + +/** + * The scope as vault_info reports it: allow entries in the vault's spelling + * and the ones that match nothing. Deny entries are never named, since the + * names alone can say too much. `blocked` is why the scope shares nothing, + * when it does not. + */ +export function mcpScopeAccessInfo( + scope: McpScope, + allow: readonly string[], + missingAllow: readonly string[], + blocked?: string | null +): McpScopeAccessInfo { + const info: McpScopeAccessInfo = { + level: scope.access, + partial: isMcpScopeRestricted(scope), + allow: [...allow].sort(compareMcpScopePaths), + missingAllow: [...missingAllow].sort(compareMcpScopePaths) + } + if (blocked) info.blocked = blocked + return info +} + +/* ---------- Messages ---------------------------------------------------- */ + +/** Go's `%q` for the strings a flag can hold, so a refusal reads byte for + * byte the same from either engine: printable text as is, ASCII controls + * escaped the way strconv.Quote escapes them. */ +function goQuote(value: string): string { + const named: Record<string, string> = { + '\u0007': '\\a', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\u000b': '\\v', + '\\': '\\\\', + '"': '\\"' + } + let out = '"' + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0 + if (named[ch]) out += named[ch] + else if (code < 0x20 || code === 0x7f) out += `\\x${code.toString(16).padStart(2, '0')}` + else out += ch + } + return `${out}"` +} diff --git a/tooling/scripts/sync-contract-fixtures.mjs b/tooling/scripts/sync-contract-fixtures.mjs index b847c7b9d..c00be881a 100644 --- a/tooling/scripts/sync-contract-fixtures.mjs +++ b/tooling/scripts/sync-contract-fixtures.mjs @@ -4,18 +4,40 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') -// The Go consumer of these fixtures is ZenNotes/znserver. Point at a checkout -// with ZENNOTES_SERVER_DIR (or the first non-flag argument); --write copies the -// fixtures and their provenance in, and without it the copies are compared. -const serverDir = process.argv.slice(2).find((arg) => !arg.startsWith('--')) ?? process.env.ZENNOTES_SERVER_DIR -if (!serverDir) throw new Error('Pass a ZenNotes/znserver checkout path or set ZENNOTES_SERVER_DIR') +// The Go consumers of these fixtures are ZenNotes/znserver and ZenNotes/tui. +// Point at a server checkout with ZENNOTES_SERVER_DIR, --server <dir> or the +// first non-flag argument, and at a tui checkout with ZENNOTES_TUI_DIR or +// --tui <dir>; only the fixtures of the checkouts given are touched. --write +// copies the fixtures and their provenance in, and without it the copies are +// compared. +const args = process.argv.slice(2) +const flagValue = (name) => { + const at = args.indexOf(name) + return at >= 0 ? args[at + 1] : undefined +} +const positional = args.find( + (arg, i) => !arg.startsWith('--') && args[i - 1] !== '--server' && args[i - 1] !== '--tui' +) +const dirs = { + server: flagValue('--server') ?? positional ?? process.env.ZENNOTES_SERVER_DIR, + tui: flagValue('--tui') ?? process.env.ZENNOTES_TUI_DIR +} +if (!dirs.server && !dirs.tui) { + throw new Error( + 'Pass a ZenNotes/znserver checkout (or --server <dir> / ZENNOTES_SERVER_DIR), a ZenNotes/tui checkout (--tui <dir> / ZENNOTES_TUI_DIR), or both' + ) +} const fixtures = [ - ['task-roundtrip.json', 'vault'], - ['self-hosted-http.json', 'httpserver'] + ['task-roundtrip.json', 'server', 'vault'], + ['self-hosted-http.json', 'server', 'httpserver'], + ['mcp-scope.json', 'tui', 'mcp'] ] -for (const [name, consumer] of fixtures) { +const checked = [] +for (const [name, repo, consumer] of fixtures) { + const repoDir = dirs[repo] + if (!repoDir) continue const source = `packages/bridge-contract/fixtures/${name}` - const target = resolve(serverDir, `internal/${consumer}/testdata/${name}`) + const target = resolve(repoDir, `internal/${consumer}/testdata/${name}`) const bytes = await readFile(resolve(root, source)) const provenance = JSON.stringify( @@ -32,15 +54,20 @@ for (const [name, consumer] of fixtures) { [target, bytes], [`${target}.source.json`, Buffer.from(provenance)] ]) { - if (process.argv.includes('--write')) { + if (args.includes('--write')) { await mkdir(dirname(path), { recursive: true }) await writeFile(path, content) } else { const current = await readFile(path) if (!current.equals(content)) { - throw new Error(`Contract fixture differs: ${path}. Run npm run sync:contract-fixtures -- <znserver checkout>.`) + throw new Error( + `Contract fixture differs: ${path}. Run npm run sync:contract-fixtures -- --${repo} <checkout>.` + ) } } } + checked.push(repoDir) } -process.stdout.write(`Go fixtures in ${serverDir} match the shared contract bytes.\n`) +process.stdout.write( + `Go fixtures in ${[...new Set(checked)].join(' and ')} match the shared contract bytes.\n` +) From df340cb5f3eaef78d8c2cbe32e8157881758c155 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 10:19:39 -0500 Subject: [PATCH 32/53] Test(mcp): the scope contract finds a Windows vault path however a tool spells it The contract fixture writes the vault's location as $VAULT, and the test put the raw root in its place. vault_info answers in JSON, which doubles a Windows root's backslashes, so the full-access case that expects the path would fail on a Windows runner, and the two cases that must never show it could not catch a leak there. zn's copy of this test failed that way on tui CI first (reported by the session shipping zn 0.13.0). $VAULT now stands for both spellings: the raw root and its JSON-escaped form. A contains check passes on either, an excludes check fails on either. The fixture is unchanged. --- apps/desktop/src/mcp/scope-contract.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/mcp/scope-contract.test.ts b/apps/desktop/src/mcp/scope-contract.test.ts index 904629631..0af84e82a 100644 --- a/apps/desktop/src/mcp/scope-contract.test.ts +++ b/apps/desktop/src/mcp/scope-contract.test.ts @@ -207,11 +207,23 @@ describe('shared scoped-access contract: tools on a real vault', () => { const object = JSON.parse(text) as Record<string, unknown> for (const key of e.absentKeys) expect(object, where).not.toHaveProperty(key) } + // A tool answering in JSON spells a Windows root with its backslashes + // doubled, so `$VAULT` is the root either way: one spelling must show + // where the fixture expects it, and neither may leak where it must not. + const spellings = (needle: string): string[] => + needle.includes('$VAULT') + ? [...new Set([root, JSON.stringify(root).slice(1, -1)])].map((r) => + needle.replace('$VAULT', r) + ) + : [needle] for (const needle of e.contains ?? []) { - expect(text, where).toContain(needle.replace('$VAULT', root)) + expect( + spellings(needle).some((s) => text.includes(s)), + `${where}: contains ${needle}` + ).toBe(true) } for (const needle of e.excludes ?? []) { - expect(text, where).not.toContain(needle.replace('$VAULT', root)) + for (const s of spellings(needle)) expect(text, where).not.toContain(s) } } From 6a07ef898cd4c4be9e3f450a601a863df99dbe5c Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 10:33:48 -0500 Subject: [PATCH 33/53] Release(cli): bundle the verified 0.13.0 terminal release The bundled CLI is the floor a desktop build guarantees, and 0.13.0 is the newest release (ZenNotes/tui#24). It answers ZenNotes/tui#21: zn mcp takes --allow-path, --deny-path, --read-only and --access and enforces them on every tool, the same contract this app's bundled MCP server follows since 969f50e3. It also keeps every assistant out of hidden and system folders through moves, renames and subpaths (ZenNotes/tui#23), and puts a row zn base add makes into the picked rows of the view the database opens on, as + Row does in the grid. The pin is the release's terminal-release.json copied byte for byte after it verified against the zn-release-1 key this app trusts (cli-update-core.ts), naming source commit cae76aff (the v0.13.0 tag), integration protocol 1, and four archives that match both the manifest and checksums.txt. Staged through `npm run terminal:stage`; the native darwin-arm64 binary reports zn v0.13.0. The in-app manual's CLI card says 0.13.0 and what it adds. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 05d2d1ca1..c1e46fbfc 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.12.2", - "commit": "3f1f638a555450891d378b4b67ab7d9eae2f5226", + "version": "0.13.0", + "commit": "cae76aff84741cd6208c58a3a94fea90df4ac771", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_darwin_arm64.tar.gz", - "sha256": "7e7b3be038dfa63dc4a79bc6068cb459462cf81e809e5b070d73bfbe3e7bd5ac" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_darwin_arm64.tar.gz", + "sha256": "7f36d799066abba575d58cd1821a9cfbdd997003203de28aa884d92692453c50" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_darwin_amd64.tar.gz", - "sha256": "091f8b2a2f5ceb8f03acfd90f4337b085551b36058b7326b9ee64d4b1a330a12" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_darwin_amd64.tar.gz", + "sha256": "28ff881df056a7911674162e0f9739ce1b0405623b694746adf0619229bb9ff8" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_linux_arm64.tar.gz", - "sha256": "882ec3fb8f988069c2208412e3ce72bfe8be8dfff461c0ec503f5734c7417786" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_linux_arm64.tar.gz", + "sha256": "848163c70d7aa8228936e8ee876e858af4fba67d62e4d1909a985e9f8e5da730" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_linux_amd64.tar.gz", - "sha256": "2617004481aee2e4f9677cc362d76ed9c0c414e6ec7858fa34db3d09156a33fe" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_linux_amd64.tar.gz", + "sha256": "01ec73fdd4637490bd9d053f103bc49fe18e6657ac1945737b80ed0504032b74" } } } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 46935ac80..6f4a32d97 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1415,9 +1415,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.12.2 in desktop 2.78.0', + title: 'CLI 0.13.0 in desktop 2.80.0', body: - 'On macOS and Linux, desktop 2.78.0 bundles CLI 0.12.2. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. 0.9.1 keeps a wikilink of more than one word from widening the TUI preview’s row and scrolling the whole screen. 0.9.2 lets a second `$` on a wrapped line reach the end of the line and makes three backticks a code fence, 0.10.0 adds `zn base pages`, which gives every database record its record page so each can be linked, and 0.11.0 gives a task without a due date its own No due date group and Status board column, as this app does (#990), and orders the task list by priority with `O` or `:order priority` (#989). 0.12.0 can draw display math and TikZ in the TUI preview with `[terminal] math = "latex"`, using TeX installed on your computer (zn does not include TeX; `zn doctor` prints the install commands), 0.12.1 fits pictures in the preview to the pane as it grows or shrinks, and 0.12.2 says which TeX tool is missing and how to install it. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.80.0 bundles CLI 0.13.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. 0.9.1 keeps a wikilink of more than one word from widening the TUI preview’s row and scrolling the whole screen. 0.9.2 lets a second `$` on a wrapped line reach the end of the line and makes three backticks a code fence, 0.10.0 adds `zn base pages`, which gives every database record its record page so each can be linked, and 0.11.0 gives a task without a due date its own No due date group and Status board column, as this app does (#990), and orders the task list by priority with `O` or `:order priority` (#989). 0.12.0 can draw display math and TikZ in the TUI preview with `[terminal] math = "latex"`, using TeX installed on your computer (zn does not include TeX; `zn doctor` prints the install commands), 0.12.1 fits pictures in the preview to the pane as it grows or shrinks, 0.12.2 says which TeX tool is missing and how to install it, and 0.13.0 lets `zn mcp` share only part of a vault (`--allow-path`, `--deny-path`, `--read-only`, `--access`; see Share part of a vault with an assistant), keeps assistants out of hidden and system folders, and puts a row `zn base add` makes into the picked rows of the view the database opens on. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', From d0fe299ffc4bd73bd2f590866f2421aabc46a010 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 10:50:06 -0500 Subject: [PATCH 34/53] Fix(mcp): a scoped assistant's error names a Windows path with forward slashes PR #1019's Windows runs failed "gets errors without the vault's absolute path" (scoped-backend.test.ts:129): once the vault's location was taken out of an error, what was left read `Projects\Plan.md`. A scoped client sees vault paths the way the tools report them, with forward slashes on every platform, so an error that names `Projects\Plan.md` names a path the client never sent and cannot send back. scrubVaultRoot now writes the path that follows the root in the vault's spelling, up to the next quote or the end of the line (Node's fs errors quote the path they name), and still turns a bare root into "the vault". It takes the separator as a parameter, so the Windows case is tested on every platform: a test feeds it a C:\ root and a backslash path (the old code returned CI's exact `Projects\Plan.md`). --- apps/desktop/src/mcp/scoped-backend.test.ts | 16 +++++++++++++++- apps/desktop/src/mcp/scoped-backend.ts | 18 ++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/mcp/scoped-backend.test.ts b/apps/desktop/src/mcp/scoped-backend.test.ts index 9429696f1..478bfa1a8 100644 --- a/apps/desktop/src/mcp/scoped-backend.test.ts +++ b/apps/desktop/src/mcp/scoped-backend.test.ts @@ -8,7 +8,7 @@ import { MCP_TOOL_ACCESS, mcpScopeInstructions, newMcpScope } from '@shared/mcp- import { parse } from '../cli/args' import { createBackend, type VaultBackend } from '../cli/backend' import { mcpScopeFromArgs } from '../cli/commands/mcp' -import { ScopedBackend } from './scoped-backend' +import { ScopedBackend, scrubVaultRoot } from './scoped-backend' import { callTool, describeToolError, listToolNames, runMcpServer } from './server' // What the shared fixture (bridge-contract/fixtures/mcp-scope.json, run by @@ -129,6 +129,20 @@ describe('a local scoped client', () => { expect(scoped).toContain('Projects/Plan.md') expect(describeToolError(failure, null, local)).toContain(root) }) + + // PR #1019's Windows runs: the error named `Projects\Plan.md` once the root + // was gone, not the vault path the tools report. + it('names a Windows path the way the tools do, with forward slashes', () => { + const winRoot = 'C:\\Users\\ada\\Notes' + const failure = `EACCES: permission denied, open '${winRoot}\\Projects\\Plan.md'` + expect(scrubVaultRoot(failure, winRoot, '\\')).toBe( + "EACCES: permission denied, open 'Projects/Plan.md'" + ) + expect(scrubVaultRoot(`cannot read ${winRoot}`, winRoot, '\\')).toBe('cannot read the vault') + expect(scrubVaultRoot(`a '${winRoot}\\A\\B.md' and '${winRoot}\\C.md'`, winRoot, '\\')).toBe( + "a 'A/B.md' and 'C.md'" + ) + }) }) describe('the server under a scope', () => { diff --git a/apps/desktop/src/mcp/scoped-backend.ts b/apps/desktop/src/mcp/scoped-backend.ts index 25ea670a2..27d193a16 100644 --- a/apps/desktop/src/mcp/scoped-backend.ts +++ b/apps/desktop/src/mcp/scoped-backend.ts @@ -640,7 +640,21 @@ export function scopeBackend(backend: VaultBackend, scope: McpScope | null | und * Keeps the vault's absolute location out of a scoped client's errors: an * MCP-only client has no use for it, and it names the user's home folder. */ -export function scrubVaultRoot(message: string, root: string): string { +export function scrubVaultRoot(message: string, root: string, sep: string = path.sep): string { if (!root) return message - return message.split(root + path.sep).join('').split(root).join('the vault') + // What follows the root is a vault path, written the way the tools report + // one: forward slashes on every platform, so a Windows error names + // `Projects/Plan.md`, not `Projects\Plan.md`. It runs to the next quote or + // the end of the line, as Node's fs errors quote the path they name. + const prefix = root + sep + let out = '' + let rest = message + for (let at = rest.indexOf(prefix); at >= 0; at = rest.indexOf(prefix)) { + out += rest.slice(0, at) + rest = rest.slice(at + prefix.length) + const end = rest.search(/['"\n]|$/) + out += sep === '/' ? rest.slice(0, end) : rest.slice(0, end).split(sep).join('/') + rest = rest.slice(end) + } + return (out + rest).split(root).join('the vault') } From 0cfcb3a8df6099188fb318f94acc3b3df4f1d10f Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 10:48:40 -0500 Subject: [PATCH 35/53] Fix(editor): the last header button's tooltip is no longer cut off at the window's edge (#1020) The note header's buttons show their name on hover in a tooltip centred under the button. The last one, Move to trash (or Delete permanently for a note already in the Trash), sits at the window's right edge, so half its tooltip ran past it and the label read "Move to tras". The leftmost buttons already anchored their tooltips to their left edge for the same reason on the other side. IconBtn gains tooltipAlign="right", which anchors the tooltip to the button's right edge, and the header's last button uses it in both of its forms. The other buttons keep their centred tooltips. Reproduced in the built app first: with a note open and the Timeline panel showing, the trash tooltip ended at x=1299 in a 1280-wide window; it now ends at the button's edge, 1264. Tests: the last button's tooltip is anchored right for Move to trash and for Delete permanently, while Archive stays centred (both fail on the old header). --- .../EditorPane-header-tooltips.test.ts | 142 ++++++++++++++++++ .../app-core/src/components/EditorPane.tsx | 25 ++- 2 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 packages/app-core/src/components/EditorPane-header-tooltips.test.ts diff --git a/packages/app-core/src/components/EditorPane-header-tooltips.test.ts b/packages/app-core/src/components/EditorPane-header-tooltips.test.ts new file mode 100644 index 000000000..17cf5936d --- /dev/null +++ b/packages/app-core/src/components/EditorPane-header-tooltips.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NoteContent } from "@shared/ipc"; +import type { PaneLeaf } from "../lib/pane-layout"; +import type { PaneMode } from "../lib/pane-mode"; +import { useStore } from "../store"; +import { EditorPane } from "./EditorPane"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const note: NoteContent = { + path: "inbox/Today.md", + title: "Today", + folder: "inbox", + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 11, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: "Hello there", + body: "Hello there", +}; + +const pane: PaneLeaf = { + kind: "leaf", + id: "pane-1", + tabs: [note.path], + pinnedTabs: [], + activeTab: note.path, +}; + +describe("EditorPane header tooltips (#1020)", () => { + let host: HTMLDivElement; + let root: Root; + let originalStore: ReturnType<typeof useStore.getState>; + + beforeEach(() => { + // jsdom has no layout; CodeMirror still measures. + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(), + }); + for (const method of ["scrollIntoView", "scrollTo"] as const) { + Object.defineProperty(HTMLElement.prototype, method, { + configurable: true, + value: () => {}, + }); + } + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (media: string) => ({ + matches: false, + media, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }), + }); + // Anything the pane asks the host for on mount answers "nothing". + (window as unknown as { zen: unknown }).zen = new Proxy( + { + getCapabilities: () => ({ supportsCloudSync: true }), + getAppInfo: () => ({ runtime: "desktop" }), + } as Record<string, unknown>, + { + get: (target, key: string) => target[key] ?? (async () => undefined), + }, + ); + originalStore = useStore.getState(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + useStore.setState(originalStore, true); + delete (window as unknown as { zen?: unknown }).zen; + }); + + async function mountIn(mode: PaneMode, open: NoteContent = note): Promise<void> { + const leaf: PaneLeaf = { ...pane, tabs: [open.path], activeTab: open.path }; + useStore.setState({ + vault: { root: "/vault", name: "Vault" }, + activePaneId: leaf.id, + noteContents: { [open.path]: open }, + noteDirty: {}, + paneModes: { [leaf.id]: { [open.path]: mode } }, + vimMode: false, + keymapOverrides: {}, + }); + await act(async () => root.render(createElement(EditorPane, { pane: leaf }))); + } + + // The header's last button sits at the pane's right edge. A tooltip centred + // under it ran past the window and was cut off ("Move to tras"), so it is + // anchored to the button's right edge instead, as the leftmost buttons are + // anchored to their left edge. + const tooltipOf = (label: string): HTMLElement => { + const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`); + expect(button, label).not.toBeNull(); + const tip = [...button!.querySelectorAll<HTMLElement>("span")].find( + (span) => span.textContent === label, + ); + expect(tip, `${label} tooltip`).toBeDefined(); + return tip!; + }; + + it("anchors the last button's tooltip to its right edge", async () => { + await mountIn("edit"); + const tip = tooltipOf("Move to trash"); + expect(tip.className).toContain("right-0"); + expect(tip.className).not.toContain("-translate-x-1/2"); + // The buttons before it keep their centred tooltips. + expect(tooltipOf("Archive").className).toContain("-translate-x-1/2"); + }); + + it("does the same for Delete permanently on a note in the Trash", async () => { + const trashed = { ...note, folder: "trash" as const, path: "trash/Today.md" }; + useStore.setState({ noteContents: { [trashed.path]: trashed } }); + await mountIn("edit", trashed); + const tip = tooltipOf("Delete permanently"); + expect(tip.className).toContain("right-0"); + expect(tip.className).not.toContain("-translate-x-1/2"); + }); +}); diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 2738de656..7eeae33f5 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -4309,11 +4309,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {folder === 'trash' ? ( // A trashed note cannot be trashed again; here the bin icon means the // only thing left that it can mean (#712). - <IconBtn title="Delete permanently" onClick={() => void deleteActivePermanently()}> + <IconBtn + title="Delete permanently" + onClick={() => void deleteActivePermanently()} + tooltipAlign="right" + > <TrashIcon /> </IconBtn> ) : ( - <IconBtn title={`Move to ${folderLabels.trash.toLowerCase()}`} onClick={() => void trashActive()}> + <IconBtn + title={`Move to ${folderLabels.trash.toLowerCase()}`} + onClick={() => void trashActive()} + tooltipAlign="right" + > <TrashIcon /> </IconBtn> )} @@ -5523,9 +5531,10 @@ function IconBtn({ title: string active?: boolean disabled?: boolean - /** 'left' anchors the tooltip to the button's left edge so it never spills - * off the left of the window (used by the leftmost toolbar buttons). */ - tooltipAlign?: 'center' | 'left' + /** 'left' / 'right' anchor the tooltip to that edge of the button so it + * never spills off that side of the window: the leftmost toolbar buttons + * use 'left', the last one 'right' (#1020). */ + tooltipAlign?: 'center' | 'left' | 'right' }): JSX.Element { return ( <button @@ -5547,7 +5556,11 @@ function IconBtn({ <span className={[ 'pointer-events-none absolute top-full z-30 mt-1.5 hidden whitespace-nowrap rounded-md border border-paper-300 bg-paper-50 px-2 py-1 text-xs font-medium text-ink-800 shadow-panel group-hover:block group-focus-visible:block', - tooltipAlign === 'left' ? 'left-0' : 'left-1/2 -translate-x-1/2' + tooltipAlign === 'left' + ? 'left-0' + : tooltipAlign === 'right' + ? 'right-0' + : 'left-1/2 -translate-x-1/2' ].join(' ')} > {title} From c3bd22e04d2be00203d24e5a889b0a475cfd4413 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 10:57:00 -0500 Subject: [PATCH 36/53] Test(editor): the header tooltip test no longer imports vi it does not use CodeQL flagged it on PR #1019: the import came along with the setup copied from EditorPane-cloud-conflict.test.ts, and this test never mocks anything. --- .../app-core/src/components/EditorPane-header-tooltips.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-core/src/components/EditorPane-header-tooltips.test.ts b/packages/app-core/src/components/EditorPane-header-tooltips.test.ts index 17cf5936d..f228eef7e 100644 --- a/packages/app-core/src/components/EditorPane-header-tooltips.test.ts +++ b/packages/app-core/src/components/EditorPane-header-tooltips.test.ts @@ -2,7 +2,7 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { NoteContent } from "@shared/ipc"; import type { PaneLeaf } from "../lib/pane-layout"; import type { PaneMode } from "../lib/pane-mode"; From 8b42b542c481bfe19506033d56ce955cac6734d1 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 11:56:07 -0500 Subject: [PATCH 37/53] Feat(templates): a folder of your vault can hold your templates (#1024) Obsidian keeps templates as ordinary notes in a folder the vault names, and someone moving over expects to point ZenNotes at that folder and find them in the picker. ZenNotes only read templates from `.zennotes/templates/`, a hidden folder with its own frontmatter, so a vault's Templates folder was just more notes, and their `- [ ]` placeholders filled Tasks with work nobody had. The vault's settings gain `templates.folder`, a vault-relative path as it exists on disk, the shape the Tasks folder lists already use, so a remapped system folder needs no translation. Every note in it, subfolders included, is a template next to the built-ins and `.zennotes/templates/`, and the picker (`Space t`, `:template`, New Note from Template) marks each one with the folder it sits in. A plain note is the template as it is, named after its file, and a frontmatter of its own (`tags:`, `created: {{date}}`) is copied into each new note. A frontmatter holding only the keys a custom template uses is read as that template's settings instead, so a template moves between the two places unchanged. A folder note never shadows a built-in. When a folder note and a custom template share a name, both stay, the custom one first, so turning the folder on never changes what an existing `:template NAME` picks. The templates load in app-core from the note listing and `readNote`, which every host already has: a local vault, a server vault, the web client and the phones show them with no host change. A note is read again only when its listing stamp moves; a save in the editor, the watcher's echo of an edit made elsewhere, and a settings change all reach the picker without a restart, and creating a note reads the template once more so it never starts from a stale copy. The folder's checkboxes are placeholders, so `tasksFolderListsForSettings` in shared-domain adds the folder to the excluded ones (inside a Task folder too), and desktop main and MCP's `list_tasks` scan through it. The renderer also drops the folder's tasks from whatever a host sends, which covers a server or a phone that does not know the setting yet. Its notes leave Search Notes and Search Text in Vault: desktop main drops them before ranking so the result limit goes to real notes, and a Search Notes query that starts with the folder's path (`Templates/`) finds them again, which is how a template is found to be edited. Settings, Templates gets a Template folder picker over the vault's folders (found by Settings search), and the sidebar folder menu, which `m` opens in Vim mode, gains Use as Template Folder / Stop Using as Template Folder. The template list shows folder templates with an Open button, since they are edited as notes. A folder rename or move repoints the setting in main, the renderer and MCP. The setting lives in vault.json, not config.toml: it names a folder of this vault, and the same path in another vault means nothing, so PORTABLE_PREF_KEYS is unchanged. A self-hosted server keeps it from 2.80 (`supportsTemplateFolder`, the matching znserver change); on an older server the row stays hidden, since its settings save would drop the key. Left alone: Save Current Note as Template still writes `.zennotes/templates/`; the tag list still counts template notes' tags; the Go zn CLI and the phones' own task scanners do not read the folder yet (the renderer's filter keeps the phones' Tasks right). Tests, written red first: the folder rules and the Tasks lists in shared-domain, the file format, the source label and the name-clash order, Search Notes with and without the folder path, the renderer's normalizer, the store (folder notes listed after the custom ones, a created note's body, an edit seen without a restart, an unchanged note not read twice, Tasks filtered from any host, clearing and setting the folder), Settings search and the picker's folder list, and desktop main and MCP scans, settings round trip, rename and text search. Driven over CDP on a built app with both stores isolated: picking Templates in Settings wrote vault.json and took Tasks from 3 to 1; the picker showed Meeting twice (custom, then Templates) and Retro (Templates/Work); creating from the folder Meeting wrote its body with the title and date filled and its frontmatter kept; Search Notes hid the templates until the query started with `Templates/`; Search Text in Vault showed only the new note; an edit from another editor and an edit in ZenNotes both renamed the template in the picker without a restart; Space t opened the same picker; None removed them from the picker and brought their tasks back. Reported by @williamcheuk03. --- apps/desktop/src/main/tasks.test.ts | 49 +++ apps/desktop/src/main/tasks.ts | 25 +- apps/desktop/src/main/vault.test.ts | 59 +++ apps/desktop/src/main/vault.ts | 29 +- apps/desktop/src/mcp/instructions.ts | 4 +- apps/desktop/src/mcp/server.ts | 2 +- apps/desktop/src/mcp/vault-ops.test.ts | 32 ++ apps/desktop/src/mcp/vault-ops.ts | 43 ++- apps/desktop/src/preload/index.ts | 1 + apps/web/src/bridge/http-bridge.ts | 6 +- .../app-core/src/components/SearchPalette.tsx | 12 +- .../src/components/SettingsModal.test.ts | 61 +++ .../app-core/src/components/SettingsModal.tsx | 365 ++++++++++++++++-- packages/app-core/src/components/Sidebar.tsx | 19 + .../src/components/TemplatePalette.tsx | 74 ++-- .../src/components/VaultTextSearchPalette.tsx | 14 +- packages/app-core/src/lib/help.ts | 8 +- packages/app-core/src/lib/note-search.test.ts | 29 ++ packages/app-core/src/lib/note-search.ts | 20 + .../app-core/src/lib/template-files.test.ts | 103 ++++- .../app-core/src/lib/vault-layout.test.ts | 15 + packages/app-core/src/lib/vault-layout.ts | 3 + .../src/lib/vault-settings-conflict.ts | 1 + packages/app-core/src/store.test.ts | 171 ++++++++ packages/app-core/src/store.ts | 171 +++++++- packages/bridge-contract/src/bridge.ts | 5 + packages/bridge-contract/src/ipc.ts | 16 + packages/bridge-contract/src/templates.ts | 27 +- .../src/tasks-excluded-folders.test.ts | 54 ++- .../src/tasks-excluded-folders.ts | 25 ++ packages/shared-domain/src/template-files.ts | 102 ++++- .../shared-domain/src/template-folder.test.ts | 132 +++++++ packages/shared-domain/src/template-folder.ts | 107 +++++ .../src/vault-settings-conflict.test.ts | 1 + .../src/vault-settings-conflict.ts | 1 + 35 files changed, 1664 insertions(+), 122 deletions(-) create mode 100644 apps/desktop/src/main/tasks.test.ts create mode 100644 packages/shared-domain/src/template-folder.test.ts create mode 100644 packages/shared-domain/src/template-folder.ts diff --git a/apps/desktop/src/main/tasks.test.ts b/apps/desktop/src/main/tasks.test.ts new file mode 100644 index 000000000..1fd2c06d5 --- /dev/null +++ b/apps/desktop/src/main/tasks.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAllTasks, scanTasksForPath } from './tasks' +import { getVaultSettings, setVaultSettings } from './vault' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +/** A flat (root-primary) vault with a template folder of placeholders and an + * ordinary note holding real work. */ +async function seedVault(templates: { folder?: string } | undefined): Promise<string> { + const root = await mkdtemp(path.join(os.tmpdir(), 'zennotes-template-folder-tasks-')) + tempDirs.push(root) + await mkdir(path.join(root, 'Templates', 'Work'), { recursive: true }) + await mkdir(path.join(root, 'Notes'), { recursive: true }) + await writeFile(path.join(root, 'Templates', 'Meeting.md'), '# {{title}}\n\n- [ ] agenda item\n') + await writeFile(path.join(root, 'Templates', 'Work', 'Retro.md'), '- [ ] action item\n') + await writeFile(path.join(root, 'Notes', 'Plan.md'), '# Plan\n\n- [ ] real work\n') + const settings = await getVaultSettings(root) + await setVaultSettings(root, { ...settings, primaryNotesLocation: 'root', templates }) + return root +} + +const contents = (tasks: { content: string }[]): string[] => tasks.map((t) => t.content).sort() + +// #1024: a template's `- [ ]` lines are placeholders for the note it will +// make, so the template folder never feeds Tasks. +describe('tasks and the template folder (#1024)', () => { + it('leaves every note in the template folder out of the scan', async () => { + const root = await seedVault({ folder: 'Templates' }) + expect(contents(await scanAllTasks(root))).toEqual(['real work']) + }) + + it('returns nothing for a single template note, so an edit never brings it back', async () => { + const root = await seedVault({ folder: 'Templates' }) + expect(await scanTasksForPath(root, 'Templates/Meeting.md')).toEqual([]) + expect(contents(await scanTasksForPath(root, 'Notes/Plan.md'))).toEqual(['real work']) + }) + + it('counts those notes again once the setting is cleared', async () => { + const root = await seedVault(undefined) + expect(contents(await scanAllTasks(root))).toEqual(['action item', 'agenda item', 'real work']) + }) +}) diff --git a/apps/desktop/src/main/tasks.ts b/apps/desktop/src/main/tasks.ts index 02d3ef555..e451f7f6a 100644 --- a/apps/desktop/src/main/tasks.ts +++ b/apps/desktop/src/main/tasks.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import type { NoteFolder, NoteMeta } from '@shared/ipc' import { parseTaskFile, parseTasksFromBody, type VaultTask } from '@shared/tasks' -import { isPathExcludedFromTasks } from '@shared/tasks-excluded-folders' +import { isPathExcludedFromTasks, tasksFolderListsForSettings } from '@shared/tasks-excluded-folders' import { folderForRelativePath, getVaultSettings, listNotes } from './vault' /** Emit a note's file-task (if its frontmatter tags it `#task`) plus every @@ -43,13 +43,11 @@ async function readOne( /** Walk the whole vault and parse every task out of every live (non-trash) * note. Parallelized with `Promise.all` so a 500-note vault is IO-bound, * not sequentially latent. Folders on the vault's `tasks.excludedFolders` - * list (#458), and every folder outside its Task folders once - * `tasks.includedFolders` names any (#962), are skipped before any file is - * read. */ + * list (#458), every folder outside its Task folders once + * `tasks.includedFolders` names any (#962), and the template folder, whose + * checkboxes are placeholders (#1024), are skipped before any file is read. */ export async function scanAllTasks(root: string): Promise<VaultTask[]> { - const tasksSettings = (await getVaultSettings(root)).tasks - const excluded = tasksSettings?.excludedFolders ?? [] - const included = tasksSettings?.includedFolders ?? [] + const { excluded, included } = tasksFolderListsForSettings(await getVaultSettings(root)) const metas = (await listNotes(root)).filter( (m) => includesFolder(m.folder) && !isPathExcludedFromTasks(m.path, excluded, included) ) @@ -76,15 +74,10 @@ export async function scanTasksForPath( const settings = await getVaultSettings(root) const folder = folderForRelativePath(posix, settings) if (!folder || !LIVE_FOLDERS.has(folder)) return [] - // Same folder lists the full scan applies (#458, #962), or a single-note - // rescan would resurrect a left-out folder's tasks on every edit. - if ( - isPathExcludedFromTasks( - posix, - settings.tasks?.excludedFolders ?? [], - settings.tasks?.includedFolders ?? [] - ) - ) { + // Same folder lists the full scan applies (#458, #962, #1024), or a + // single-note rescan would resurrect a left-out folder's tasks on every edit. + const { excluded, included } = tasksFolderListsForSettings(settings) + if (isPathExcludedFromTasks(posix, excluded, included)) { return [] } diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index a4b3bb1fd..1fc0ce812 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -217,6 +217,65 @@ describe('file-location settings round-trip (#446)', () => { }) }) +// #1024: the template folder is a vault setting. It survives a save, it +// follows its folder through a rename, and its notes stay out of text search. +describe('template folder (#1024)', () => { + async function rootVault(prefix: string): Promise<string> { + const root = await makeTempDir(prefix) + await ensureVaultLayout(root) + const settings = await getVaultSettings(root) + await setVaultSettings(root, { ...settings, primaryNotesLocation: 'root' }) + return root + } + + it('keeps the folder through set/get, cleaned, and drops an unusable one', async () => { + const root = await rootVault('zennotes-template-folder-settings-') + const base = await getVaultSettings(root) + expect(base.templates).toBeUndefined() + + const returned = await setVaultSettings(root, { ...base, templates: { folder: ' /Templates/ ' } }) + expect(returned.templates).toEqual({ folder: 'Templates' }) + invalidateVaultSettingsCache(root) + expect((await getVaultSettings(root)).templates).toEqual({ folder: 'Templates' }) + const onDisk = JSON.parse(await readFile(path.join(root, '.zennotes', 'vault.json'), 'utf8')) + expect(onDisk.templates).toEqual({ folder: 'Templates' }) + + await setVaultSettings(root, { ...base, templates: { folder: '../outside' } }) + invalidateVaultSettingsCache(root) + expect((await getVaultSettings(root)).templates).toBeUndefined() + }) + + it('follows the folder when it is renamed', async () => { + const root = await rootVault('zennotes-template-folder-rename-') + await mkdir(path.join(root, 'Templates'), { recursive: true }) + await writeFile(path.join(root, 'Templates', 'Meeting.md'), '# {{title}}\n', 'utf8') + const settings = await getVaultSettings(root) + await setVaultSettings(root, { ...settings, templates: { folder: 'Templates' } }) + + await renameFolder(root, 'inbox', 'Templates', 'Blueprints') + + expect((await getVaultSettings(root)).templates).toEqual({ folder: 'Blueprints' }) + }) + + it('leaves template notes out of text search, with every backend', async () => { + const root = await rootVault('zennotes-template-folder-search-') + await mkdir(path.join(root, 'Templates'), { recursive: true }) + await mkdir(path.join(root, 'Notes'), { recursive: true }) + // A word the seeded Welcome note cannot fuzzy-match. + await writeFile(path.join(root, 'Templates', 'Meeting.md'), '## zqxagenda item\n', 'utf8') + await writeFile(path.join(root, 'Notes', 'Sync.md'), '## zqxagenda item\n', 'utf8') + const settings = await getVaultSettings(root) + const paths = async (backend: 'builtin' | 'auto'): Promise<string[]> => + (await searchVaultText(root, 'zqxagenda', backend)).map((m) => m.path).sort() + + expect(await paths('builtin')).toEqual(['Notes/Sync.md', 'Templates/Meeting.md']) + + await setVaultSettings(root, { ...settings, templates: { folder: 'Templates' } }) + expect(await paths('builtin')).toEqual(['Notes/Sync.md']) + expect(await paths('auto')).toEqual(['Notes/Sync.md']) + }) +}) + describe('remapped system folders (#398)', () => { it('creates the REMAPPED inbox on save, not a literal inbox/', async () => { const root = await makeTempDir('zennotes-vault-remap-mkdir-') diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 924fff664..e251b6a21 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -105,6 +105,12 @@ import { normalizeTypstPreambleSettings, resolveTypstPreambleFolder } from '@shared/typst-preamble-folder' +import { + isPathInTemplateFolder, + normalizeTemplateFolderSettings, + resolveTemplateFolder, + rewriteTemplateFolderForRename +} from '@shared/template-folder' import { normalizeHarperVaultState } from '@shared/harper-settings' const CONFIG_FILE = 'zennotes.config.json' @@ -925,6 +931,7 @@ function cloneVaultSettings(settings: VaultSettings): VaultSettings { } : {}), ...(settings.typstPreambles ? { typstPreambles: { ...settings.typstPreambles } } : {}), + ...(settings.templates ? { templates: { ...settings.templates } } : {}), ...(settings.harper ? { harper: { @@ -1178,6 +1185,7 @@ function normalizeVaultSettings( systemFolderPaths?: unknown tasks?: unknown typstPreambles?: unknown + templates?: unknown harper?: unknown } const folderIcons: Record<string, FolderIconId> = {} @@ -1240,6 +1248,9 @@ function normalizeVaultSettings( systemFolderPaths: normalizeSystemFolderPaths(candidate.systemFolderPaths), tasks: normalizeTasksSettings(candidate.tasks), typstPreambles: normalizeTypstPreambleSettings(candidate.typstPreambles), + // The template folder (#1024): undefined unless a usable path is set, so + // JSON.stringify leaves the key out of an untouched vault.json. + templates: normalizeTemplateFolderSettings(candidate.templates), harper: normalizeHarperVaultState(candidate.harper) } } @@ -3183,12 +3194,20 @@ export async function searchVaultText( const paths = normalizeVaultTextSearchToolPaths(rawPaths) const capabilities = await searchVaultTextCapabilities(paths) const backend = resolveSearchBackend(preferredBackend, capabilities) + // Template notes (#1024) are placeholders nobody is searching for. They are + // dropped before ranking, after the cache, so the cached candidates stay + // valid when the setting moves and the limit is spent on real notes. + const templateFolder = resolveTemplateFolder(await getVaultSettings(root)) + const searchable = (candidates: VaultTextSearchCandidate[]): VaultTextSearchCandidate[] => + templateFolder + ? candidates.filter((candidate) => !isPathInTemplateFolder(candidate.path, templateFolder)) + : candidates if (backend === 'builtin') { const candidates = await getCachedVaultTextSearchCandidates(root, 'builtin', paths, () => collectBuiltinSearchCandidates(root) ) - const ranked = rankSearchCandidates(trimmed, candidates, limit) + const ranked = rankSearchCandidates(trimmed, searchable(candidates), limit) return await hydrateSearchOffsets(root, trimmed, ranked) } @@ -3198,7 +3217,7 @@ export async function searchVaultText( ) const ranked = rankSearchCandidates( trimmed, - candidates, + searchable(candidates), limit ) return await hydrateSearchOffsets(root, trimmed, ranked) @@ -3211,7 +3230,7 @@ export async function searchVaultText( : await getCachedVaultTextSearchCandidates(root, 'builtin', paths, () => collectBuiltinSearchCandidates(root) ) - const ranked = await runFzfSearch(trimmed, candidates, limit, paths) + const ranked = await runFzfSearch(trimmed, searchable(candidates), limit, paths) return await hydrateSearchOffsets(root, trimmed, ranked) } @@ -4166,7 +4185,9 @@ export async function renameFolder( folderIcons: rewriteFolderIconsForRename(settings.folderIcons, topFolder, oldClean, newClean), folderColors: rewriteFolderColorsForRename(settings.folderColors, topFolder, oldClean, newClean), // The Tasks folder lists name on-disk paths, so they follow the folder too. - tasks: rewriteTasksFoldersForRename(settings.tasks, oldRel, newRel) + tasks: rewriteTasksFoldersForRename(settings.tasks, oldRel, newRel), + // So does the template folder (#1024). + templates: rewriteTemplateFolderForRename(settings.templates, oldRel, newRel) } await renameFolderTrees(root, oldRel, newRel, () => setVaultSettings(root, nextSettings)) return newClean diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index 695bea03c..9a2bf50dc 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -218,8 +218,8 @@ starts it expanded; use \`-\` for long blocks a reader opens on demand. - Task ids from list_tasks (\`path#index\`) are stable, so pass them to toggle_task. - list_tasks omits notes opted out of Tasks (frontmatter - \`tasks: false\`/\`note\`, excluded folders, and notes outside - the vault's Task folders when it has any). Pass + \`tasks: false\`/\`note\`, excluded folders, the template folder, + and notes outside the vault's Task folders when it has any). Pass includeExcluded: true only when the user asks for everything. ## Comments: reviewing a note together diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 6c348f5f2..c3b28d3b6 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -735,7 +735,7 @@ const TOOLS: ToolDef[] = [ includeExcluded: { type: 'boolean', description: - 'Also scan notes opted out of Tasks (frontmatter `tasks: false` / `tasks: note`), folders on the vault\'s excluded list, and notes outside its Task folders. Default false: excluded tasks are invisible.' + 'Also scan notes opted out of Tasks (frontmatter `tasks: false` / `tasks: note`), folders on the vault\'s excluded list, its template folder, and notes outside its Task folders. Default false: excluded tasks are invisible.' } } } diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index be47a40b9..9865db9c4 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -391,3 +391,35 @@ describe('mcp tasks honor the Task folders (#962)', () => { expect(all.some((p) => p.includes('Rename'))).toBe(true) }) }) + +// #1024: an assistant's list_tasks agrees with the app that a template's +// `- [ ]` lines are placeholders, not work. +describe('mcp tasks leave the template folder out (#1024)', () => { + async function seed(settings: Record<string, unknown>): Promise<void> { + await mkdir(path.join(root, 'Templates'), { recursive: true }) + await mkdir(path.join(root, 'Tasks'), { recursive: true }) + await writeFile(path.join(root, 'Templates', 'Meeting.md'), '# {{title}}\n\n- [ ] agenda item\n') + await writeFile(path.join(root, 'Tasks', 'Today.md'), '# Today\n\n- [ ] ship it\n') + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile(path.join(root, '.zennotes', 'vault.json'), JSON.stringify(settings)) + } + const sources = (tasks: { sourcePath: string }[]): string[] => + [...new Set(tasks.map((t) => t.sourcePath))].sort() + + it('skips the template folder, next to the Task folders', async () => { + await seed({ templates: { folder: 'Templates' } }) + const listed = sources(await scanAllTasks(root)) + expect(listed).toContain('Tasks/Today.md') + expect(listed).not.toContain('Templates/Meeting.md') + + await seed({ templates: { folder: 'Templates' }, tasks: { includedFolders: ['Tasks'] } }) + expect(sources(await scanAllTasks(root))).toEqual(['Tasks/Today.md']) + }) + + it('lists them with includeExcluded, which scans every note', async () => { + await seed({ templates: { folder: 'Templates' } }) + expect(sources(await scanAllTasks(root, { includeExcluded: true }))).toContain( + 'Templates/Meeting.md' + ) + }) +}) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 75b900fc1..fa38a53c7 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -27,8 +27,13 @@ import { isPathExcludedFromTasks, normalizeTasksExcludedFolders, normalizeTasksIncludedFolders, - rewriteTasksFoldersForRename + rewriteTasksFoldersForRename, + tasksFolderListsForSettings } from '@shared/tasks-excluded-folders' +import { + normalizeTemplateFolderSettings, + rewriteTemplateFolderForRename +} from '@shared/template-folder' import { DEFAULT_TYPST_PREAMBLE_FOLDER, isTypstPreamblePath, @@ -1306,9 +1311,10 @@ const isRecord = (value: unknown): value is Record<string, unknown> => * Keep vault.json's path-keyed settings naming the same folders after a * rename or move, as desktop main, the server and zn do: the Tasks folder * lists, which hold vault-relative paths (a renamed Task folder left behind - * would empty Tasks, #962), and the folder icons and colors, keyed by bucket - * and subpath. Every other key is written back as read, and vault.json is - * written only when an entry moves. + * would empty Tasks, #962), the template folder, another such path (#1024), + * and the folder icons and colors, keyed by bucket and subpath. Every other + * key is written back as read, and vault.json is written only when an entry + * moves. */ async function rewriteSettingsForFolderRename( root: string, @@ -1345,6 +1351,13 @@ async function rewriteSettingsForFolderRename( changed = true } } + // The template folder (#1024) is an on-disk path too. + const templates = normalizeTemplateFolderSettings(raw['templates']) + const movedTemplates = rewriteTemplateFolderForRename(templates, oldRel, newRel) + if (movedTemplates && movedTemplates !== templates) { + raw['templates'] = movedTemplates + changed = true + } for (const key of ['folderIcons', 'folderColors']) { const map = raw[key] const next = isRecord(map) ? rewriteFolderKeyedMap(map, folder, oldSub, newSub) : null @@ -1881,27 +1894,21 @@ async function readTypstPreambleFolder(root: string): Promise<string> { } /** The vault's Tasks folder lists, `tasks.excludedFolders` (#458) and the - * Task folders in `tasks.includedFolders` (#962), read straight off - * vault.json like readSystemFolderPaths above; validation comes from the - * shared normalizers, so the rules cannot drift from the other runtimes. */ + * Task folders in `tasks.includedFolders` (#962), with the template folder + * (#1024) left out as well, read straight off vault.json like + * readSystemFolderPaths above; the rules come from the shared helper, so + * they cannot drift from the other runtimes. */ async function readTasksFolderLists( root: string ): Promise<{ excluded: string[]; included: string[] }> { - const none = { excluded: [], included: [] } const settingsPath = path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE) - let raw: Record<string, unknown> + let raw: unknown try { - raw = JSON.parse(await fs.readFile(settingsPath, 'utf8')) as Record<string, unknown> + raw = JSON.parse(await fs.readFile(settingsPath, 'utf8')) } catch { - return none - } - const tasks = raw['tasks'] - if (!tasks || typeof tasks !== 'object') return none - const lists = tasks as { excludedFolders?: unknown; includedFolders?: unknown } - return { - excluded: normalizeTasksExcludedFolders(lists.excludedFolders), - included: normalizeTasksIncludedFolders(lists.includedFolders) + return { excluded: [], included: [] } } + return tasksFolderListsForSettings(isRecord(raw) ? raw : null) } export async function scanAllTasks( diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a91ed2582..3cbae3178 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -137,6 +137,7 @@ const DESKTOP_CAPABILITIES: ZenCapabilities = { // and is gated to a follow-up. supportsCliInstall: process.platform === 'darwin' || process.platform === 'linux', supportsCustomTemplates: true, + supportsTemplateFolder: true, supportsCustomCodeLanguages: true, supportsWorkflows: true, supportsUndoFile: true, diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 2dbe18db7..47fbfb54b 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -103,6 +103,7 @@ const WEB_CAPABILITIES: ZenCapabilities = { supportsCloudSync: false, supportsCliInstall: false, supportsCustomTemplates: false, + supportsTemplateFolder: false, supportsCustomCodeLanguages: false, supportsWorkflows: false } @@ -1514,7 +1515,10 @@ export const httpBridge: ZenBridge = { getCapabilities: (): ZenCapabilities => ({ ...WEB_CAPABILITIES, supportsWorkflows: lastServerCapabilities?.supportsWorkflows === true, - supportsCustomTemplates: lastServerCapabilities?.supportsCustomTemplates === true + supportsCustomTemplates: lastServerCapabilities?.supportsCustomTemplates === true, + // The server keeps vault.json's template folder from 2.80 (#1024); an + // older one drops it on every settings save, so Settings hides the row. + supportsTemplateFolder: lastServerCapabilities?.supportsTemplateFolder === true }), getAppInfo: (): ZenAppInfo => WEB_APP_INFO, platform, diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 1ac93ad74..ad6b446cf 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -22,6 +22,7 @@ import { } from '../lib/search-create' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPrimaryNotesAtRoot, noteFolderLabel } from '../lib/vault-layout' +import { isPathInTemplateFolder, resolveTemplateFolder } from '@shared/template-folder' import { useToastStore } from '../lib/toast' import { openTaggedBlock, useTagBlockIndex } from '../lib/tag-block-index' import { buildSearchPaletteRows, type SearchPaletteRow } from '../lib/search-palette-rows' @@ -66,9 +67,13 @@ export function SearchPalette(): JSX.Element { // free text) still work — in that case we just list matching notes. const { freeText, tagTokens } = useMemo(() => parseNoteSearchQuery(query), [query]) + // Notes in the template folder are placeholders (#1024): out of the results + // unless the query starts with the folder's path. + const templateFolder = useMemo(() => resolveTemplateFolder(vaultSettings), [vaultSettings]) + const results = useMemo(() => { - return searchNoteIndex(searchIndex, query, { limit: RESULT_LIMIT }) - }, [query, searchIndex]) + return searchNoteIndex(searchIndex, query, { limit: RESULT_LIMIT, templateFolder }) + }, [query, searchIndex, templateFolder]) // Tagged blocks under the notes (#837), only for a query with a #tag and // the setting on. Free text can match a block anywhere in a tagged note, so @@ -80,9 +85,10 @@ export function SearchPalette(): JSX.Element { return notes.filter( (n) => n.folder !== 'trash' && + !isPathInTemplateFolder(n.path, templateFolder) && tagTokens.every((tag) => n.tags.some((t) => t.toLowerCase() === tag)) ) - }, [showBlocks, notes, tagTokens]) + }, [showBlocks, notes, tagTokens, templateFolder]) const tagBlocks = useTagBlockIndex(showBlocks ? (freeText ? taggedNotes : results) : NO_NOTES) const rows = useMemo( () => diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 11fd51354..97683fbd0 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -36,6 +36,16 @@ const mocks = vi.hoisted(() => { calendarShowWeekNumbers: true, calendarWeekStart: "monday", customTemplates: [], + // The template folder picker (#1024) lists the vault's folders. + folders: [ + { folder: "inbox", subpath: "", siblingOrder: 0 }, + { folder: "inbox", subpath: "Templates", siblingOrder: 1 }, + { folder: "inbox", subpath: "Templates/Work", siblingOrder: 2 }, + { folder: "archive", subpath: "Old", siblingOrder: 0 }, + { folder: "trash", subpath: "Gone", siblingOrder: 0 }, + ], + folderTemplates: [], + setTemplateFolder: vi.fn(), externalApplicationSchemes: [], setExternalApplicationSchemes: vi.fn(), darkSidebar: false, @@ -394,6 +404,57 @@ describe("SettingsModal date note directories", () => { expect(await sharesRowFound()).toBe(true); }); + it("finds the template folder and picks it from the vault's folders (#1024)", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "template folder")); + const result = [...host.querySelectorAll<HTMLButtonElement>("aside nav button")].find( + (button) => button.textContent?.includes("Template folder"), + ); + expect(result).toBeTruthy(); + await act(async () => result!.click()); + const row = host.querySelector('[data-settings-search-id="templates-folder"]'); + expect(row?.textContent).toContain("stay out of Tasks"); + const picker = row!.querySelector<HTMLButtonElement>("[data-template-folder-picker]")!; + expect(picker.textContent).toContain("None"); + await act(async () => picker.click()); + // Folders inside an area, by their path on disk: never an area itself, + // never the Trash. + const options = [...document.querySelectorAll<HTMLButtonElement>("[data-template-folder-option]")]; + expect(options.map((o) => o.dataset.templateFolderOption)).toEqual([ + "", + "archive/Old", + "inbox/Templates", + "inbox/Templates/Work", + ]); + await act(async () => options[2].click()); + expect(mocks.state.setTemplateFolder).toHaveBeenCalledWith("inbox/Templates"); + }); + + it("offers the template folder on a server vault only when the server keeps it", async () => { + mocks.state.workspaceMode = "remote"; + const serverWith = (capabilities: Record<string, unknown>) => ({ + mode: "remote", + baseUrl: "http://localhost:7878", + authConfigured: true, + capabilities, + profileId: null, + bootError: null, + }); + const rowFound = async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "template folder")); + return [...host.querySelectorAll<HTMLButtonElement>("aside nav button")].some( + (button) => button.textContent?.includes("Template folder"), + ); + }; + mocks.state.remoteWorkspaceInfo = serverWith({ supportsCustomTemplates: true }); + expect(await rowFound()).toBe(false); + mocks.state.remoteWorkspaceInfo = serverWith({ supportsTemplateFolder: true }); + expect(await rowFound()).toBe(true); + }); + it("names the Unsplash key in the Covers section only where the key row exists", async () => { const coversDescription = async (): Promise<string> => { await act(async () => root.render(createElement(SettingsModal))); diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 00f8f793a..cde50afde 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -132,6 +132,7 @@ import { normalizeMonthlyNoteTitlePattern, normalizeMonthlyNotesDirectory, specificFolderDestination, + vaultRelativeFolderPath, } from "../lib/vault-layout"; import { BUILTIN_TEMPLATES } from "@shared/builtin-templates"; import { @@ -139,7 +140,10 @@ import { findTemplate, mergeTemplates, templateSettingId, + templateSource, + templateSourceLabel, } from "@shared/template-files"; +import { resolveTemplateFolder } from "@shared/template-folder"; import { TemplateEditorModal } from "./TemplateEditorModal"; import type { NoteTemplate } from "@bridge-contract/templates"; import { @@ -691,6 +695,7 @@ export function SettingsModal(): JSX.Element { const supportsCloudSync = !!zenBridge.getCapabilities().supportsCloudSync; const setSettingsOpen = useStore((s) => s.setSettingsOpen); const openHelpView = useStore((s) => s.openHelpView); + const selectNote = useStore((s) => s.selectNote); const vimMode = useStore((s) => s.vimMode); const setVimMode = useStore((s) => s.setVimMode); const vimInsertEscape = useStore((s) => s.vimInsertEscape); @@ -887,6 +892,20 @@ export function SettingsModal(): JSX.Element { workspaceMode === "remote" ? remoteWorkspaceInfo?.capabilities?.supportsCustomTemplates === true : zenBridge.getCapabilities().supportsCustomTemplates === true; + // The template folder lives in vault.json (#1024). A self-hosted server + // keeps it from 2.80 and an older one drops it on every settings save, so + // the row stays hidden there; the web client asks its server the same way. + const supportsTemplateFolder = + workspaceMode === "remote" + ? remoteWorkspaceInfo?.capabilities?.supportsTemplateFolder === true + : zenBridge.getCapabilities().supportsTemplateFolder !== false; + // A template-folder template is an ordinary note: it is edited in the + // editor, not in the template editor, which writes to .zennotes/templates. + const openTemplateNote = (template: NoteTemplate): void => { + if (!template.sourcePath) return; + setSettingsOpen(false); + void selectNote(template.sourcePath); + }; const supportsCustomCodeLanguages = !!zenBridge.getCapabilities().supportsCustomCodeLanguages; // Undo history between launches needs somewhere machine-local that is not @@ -5746,8 +5765,24 @@ export function SettingsModal(): JSX.Element { "custom", "scaffold", "boilerplate", + "folder", + "obsidian", ], searchItems: [ + { + id: "templates-folder", + title: "Template folder", + description: "Notes in a folder become templates.", + keywords: [ + "template folder", + "templates folder", + "folder", + "obsidian", + "notes as templates", + "placeholder", + ], + available: supportsTemplateFolder, + }, { id: "templates-list", title: "Template library", @@ -5764,6 +5799,11 @@ export function SettingsModal(): JSX.Element { ], content: ( <div className="space-y-6"> + {supportsTemplateFolder && ( + <Section title="Template folder"> + <TemplateFolderRow settingId="templates-folder" /> + </Section> + )} <Section title="Templates" description="Pick any of these from the template picker (`Space t`, `:template`). Built-in templates are read-only; your custom ones can be edited or deleted." @@ -5828,6 +5868,7 @@ export function SettingsModal(): JSX.Element { {allTemplates.map((template) => ( <div key={template.id} + data-template-source={templateSource(template)} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 px-5 py-3" > <div className="min-w-0 flex-1 basis-56"> @@ -5835,47 +5876,69 @@ export function SettingsModal(): JSX.Element { {template.name} </div> <div className="mt-0.5 truncate text-xs text-ink-500"> - {template.category} + {templateSource(template) === "folder" + ? template.sourcePath + : template.category} {template.description ? ` · ${template.description}` : ""} </div> </div> <div className="flex max-w-full flex-wrap items-center gap-2"> - {template.builtin && ( - <span className="rounded-md bg-paper-200 px-2 py-1 text-xs uppercase tracking-wide text-ink-400"> - Built-in - </span> - )} - {!template.builtin && template.builtinId && ( - <span className="rounded-md bg-paper-200 px-2 py-1 text-xs uppercase tracking-wide text-ink-400"> - Customized - </span> - )} - {template.builtin ? ( - supportsCustomTemplates && ( + {templateSource(template) === "folder" ? ( + <> + <span + className="max-w-xs truncate rounded-md bg-paper-200 px-2 py-1 text-xs text-ink-500" + title="Template folder" + > + {templateSourceLabel(template)} + </span> <button type="button" - onClick={() => editBuiltinTemplate(template)} + onClick={() => openTemplateNote(template)} className="rounded-lg border border-paper-300/70 bg-paper-100/80 px-3 py-1.5 text-xs font-medium text-ink-800 hover:bg-paper-200" > - Edit + Open </button> - ) + </> ) : ( <> - <button - type="button" - onClick={() => void openTemplateEditor(template)} - className="rounded-lg border border-paper-300/70 bg-paper-100/80 px-3 py-1.5 text-xs font-medium text-ink-800 hover:bg-paper-200" - > - Edit - </button> - <button - type="button" - onClick={() => void removeTemplate(template)} - className="rounded-lg border border-red-500/30 bg-paper-100/80 px-3 py-1.5 text-xs font-medium text-[rgb(var(--z-red))] hover:bg-red-500/10" - > - {template.builtinId ? "Reset" : "Delete"} - </button> + {template.builtin && ( + <span className="rounded-md bg-paper-200 px-2 py-1 text-xs uppercase tracking-wide text-ink-400"> + Built-in + </span> + )} + {!template.builtin && template.builtinId && ( + <span className="rounded-md bg-paper-200 px-2 py-1 text-xs uppercase tracking-wide text-ink-400"> + Customized + </span> + )} + {template.builtin ? ( + supportsCustomTemplates && ( + <button + type="button" + onClick={() => editBuiltinTemplate(template)} + className="rounded-lg border border-paper-300/70 bg-paper-100/80 px-3 py-1.5 text-xs font-medium text-ink-800 hover:bg-paper-200" + > + Edit + </button> + ) + ) : ( + <> + <button + type="button" + onClick={() => void openTemplateEditor(template)} + className="rounded-lg border border-paper-300/70 bg-paper-100/80 px-3 py-1.5 text-xs font-medium text-ink-800 hover:bg-paper-200" + > + Edit + </button> + <button + type="button" + onClick={() => void removeTemplate(template)} + className="rounded-lg border border-red-500/30 bg-paper-100/80 px-3 py-1.5 text-xs font-medium text-[rgb(var(--z-red))] hover:bg-red-500/10" + > + {template.builtinId ? "Reset" : "Delete"} + </button> + </> + )} </> )} </div> @@ -7710,7 +7773,7 @@ function TemplateSelectRow({ const triggerLabel = missing ? "[Missing template]" : selected - ? `${selected.category} · ${selected.name}` + ? `${templateSourceLabel(selected)} · ${selected.name}` : "None (blank note)"; // Custom dropdown (not a native <select>): native popups don't reliably @@ -7879,7 +7942,7 @@ function TemplateSelectRow({ {items.map((tpl, idx) => { const isSelected = tpl ? tpl === selected : !value && !missing; const text = tpl - ? `${tpl.category} · ${tpl.name}` + ? `${templateSourceLabel(tpl)} · ${tpl.name}` : "None (blank note)"; return ( <button @@ -7911,6 +7974,244 @@ function TemplateSelectRow({ ); } +const NO_TEMPLATE_FOLDER = "\0none"; + +/** + * Settings editor for the vault's template folder (#1024): a picker over the + * vault's folders (by their path on disk, which is what vault.json stores), + * plus None. The sidebar folder menu ("Use as Template Folder", `m` in Vim + * mode) is the other way in. Saved to vault.json, so it travels with the vault + * and every runtime agrees on which notes are templates. + */ +function TemplateFolderRow({ settingId }: { settingId?: string }): JSX.Element { + const vaultSettings = useStore((s) => s.vaultSettings); + const folders = useStore((s) => s.folders); + const folderTemplates = useStore((s) => s.folderTemplates); + const setTemplateFolder = useStore((s) => s.setTemplateFolder); + const current = resolveTemplateFolder(vaultSettings); + + // Every folder inside an area, as its path on disk. The areas themselves + // (a whole Inbox or Archive of templates) and the Trash are not offered. + const paths = useMemo(() => { + const seen = new Set<string>(); + for (const entry of folders) { + if (entry.folder === "trash" || !entry.subpath) continue; + const rel = vaultRelativeFolderPath(entry.folder, entry.subpath, vaultSettings); + if (rel) seen.add(rel); + } + return [...seen].sort((a, b) => a.localeCompare(b)); + }, [folders, vaultSettings]); + const missing = !!current && !paths.includes(current); + const items = useMemo( + () => [ + NO_TEMPLATE_FOLDER, + ...(missing && current ? [current] : []), + ...paths, + ], + [paths, missing, current], + ); + const labelFor = (item: string): string => + item === NO_TEMPLATE_FOLDER + ? "None" + : item === current && missing + ? `${item} (missing)` + : item; + + const [open, setOpen] = useState(false); + const [activeIdx, setActiveIdx] = useState(0); + const buttonRef = useRef<HTMLButtonElement | null>(null); + const listRef = useRef<HTMLDivElement | null>(null); + const [rect, setRect] = useState<DropdownRect | null>(null); + + useEffect(() => { + if (!open) return; + const at = items.indexOf(current ?? NO_TEMPLATE_FOLDER); + setActiveIdx(at >= 0 ? at : 0); + }, [open, items, current]); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent): void => { + const target = e.target as Node; + if (buttonRef.current?.contains(target)) return; + if ( + document.getElementById("zen-template-folder-portal")?.contains(target) + ) + return; + setOpen(false); + }; + window.addEventListener("mousedown", onDown); + return () => window.removeEventListener("mousedown", onDown); + }, [open]); + + useLayoutEffect(() => { + if (!open) return; + const update = (): void => { + const el = buttonRef.current; + if (!el) return; + setRect(dropdownRectForElement(el, { estHeight: items.length * 32 + 8 })); + }; + update(); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + }; + }, [open, items.length]); + + useEffect(() => { + if (!open || !listRef.current) return; + listRef.current + .querySelector<HTMLElement>(`[data-idx="${activeIdx}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [activeIdx, open]); + + const commit = (item: string): void => { + setOpen(false); + buttonRef.current?.focus(); + void setTemplateFolder(item === NO_TEMPLATE_FOLDER ? null : item); + }; + + const onTriggerKeyDown = ( + e: React.KeyboardEvent<HTMLButtonElement>, + ): void => { + if (!open) { + if ( + e.key === "ArrowDown" || + e.key === "ArrowUp" || + e.key === "Enter" || + e.key === " " + ) { + e.preventDefault(); + setOpen(true); + } + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIdx((i) => Math.min(items.length - 1, i + 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIdx((i) => Math.max(0, i - 1)); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + const picked = items[activeIdx]; + if (picked !== undefined) commit(picked); + } else if (e.key === "Escape") { + e.preventDefault(); + // Close the list only; Escape reaching Settings would close it too. + e.stopPropagation(); + setOpen(false); + } else if (e.key === "Home") { + e.preventDefault(); + setActiveIdx(0); + } else if (e.key === "End") { + e.preventDefault(); + setActiveIdx(items.length - 1); + } + }; + + const count = folderTemplates.length; + return ( + <div + className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 px-5 py-4" + {...settingsSearchTargetProps(settingId)} + > + <div className="min-w-0 flex-1 basis-56"> + <div className="text-sm font-medium text-ink-900">Template folder</div> + <div className="mt-1 text-xs leading-5 text-ink-500"> + Notes in this folder become templates. They stay out of Tasks and + search. + </div> + {current && ( + <div className="mt-1 text-xs leading-5 text-ink-500" data-template-folder-count={count}> + {missing ? "Folder not found." : count === 1 ? "1 template" : `${count} templates`} + </div> + )} + </div> + <button + ref={buttonRef} + type="button" + aria-haspopup="listbox" + aria-expanded={open} + aria-label="Template folder" + data-template-folder-picker="" + onClick={() => setOpen((o) => !o)} + onKeyDown={onTriggerKeyDown} + className="flex w-[23rem] max-w-full shrink-0 items-center justify-between gap-2 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-left text-sm text-ink-900 outline-none transition-colors hover:bg-paper-200 focus:border-accent/45" + > + <span className={`truncate ${current ? "" : "text-ink-500"}`}> + {current ? labelFor(current) : "None"} + </span> + <svg + width="12" + height="12" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="shrink-0 text-ink-500" + aria-hidden="true" + > + <path d="m6 9 6 6 6-6" /> + </svg> + </button> + + {open && + rect && + createPortal( + <div + id="zen-template-folder-portal" + role="listbox" + aria-label="Template folder" + className="fixed z-popover flex flex-col overflow-hidden rounded-xl border border-paper-300 bg-paper-100 shadow-float" + style={{ + left: rect.left, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + maxHeight: rect.maxHeight, + }} + > + <div ref={listRef} className="min-h-0 flex-1 overflow-y-auto py-1"> + {items.map((item, idx) => { + const isSelected = item === (current ?? NO_TEMPLATE_FOLDER); + return ( + <button + key={item} + type="button" + role="option" + aria-selected={isSelected} + data-idx={idx} + data-template-folder-option={ + item === NO_TEMPLATE_FOLDER ? "" : item + } + onClick={() => commit(item)} + onMouseMove={() => setActiveIdx(idx)} + className={[ + "flex w-full items-center px-3 py-1.5 text-left text-sm", + activeIdx === idx + ? "bg-paper-200 text-ink-900" + : isSelected + ? "text-ink-900" + : "text-ink-700", + ].join(" ")} + > + <span className="truncate">{labelFor(item)}</span> + </button> + ); + })} + </div> + </div>, + document.body, + )} + </div> + ); +} + function FontRow({ label, description, diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index a637b5f1c..85beb4ded 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -35,6 +35,7 @@ import { isArchiveTabPath } from "@shared/archive"; import { DENSITY, densityFromTweaks } from "@shared/overrides"; import { isTrashTabPath } from "@shared/trash"; import { isQuickNotesTabPath } from "@shared/quick-notes"; +import { resolveTemplateFolder } from "@shared/template-folder"; import { ArchiveIcon, ArrowUpRightIcon, @@ -524,6 +525,7 @@ export function Sidebar(): JSX.Element { const toggleFavorite = useStore((s) => s.toggleFavorite); const toggleTasksExcludedFolder = useStore((s) => s.toggleTasksExcludedFolder); const toggleTasksIncludedFolder = useStore((s) => s.toggleTasksIncludedFolder); + const setTemplateFolder = useStore((s) => s.setTemplateFolder); const createDatabase = useStore((s) => s.createDatabase); const createNoteInChosenFolder = useStore((s) => s.createNoteInChosenFolder); const openTemplatePaletteForFolder = useStore((s) => s.openTemplatePaletteForFolder); @@ -2120,6 +2122,22 @@ export function Sidebar(): JSX.Element { await toggleTasksExcludedFolder(tasksExcludeRelDir); }, }); + // The template folder (#1024): its notes become templates in the + // picker and leave Tasks and search. Also in Settings, Templates. Only + // a folder inside an area: a whole Inbox or Archive of templates is + // never what was meant, and a folder in the Trash is never offered. + if (!isTop && folder !== "trash") { + const templateFolder = + resolveTemplateFolder(vaultSettings) === tasksExcludeRelDir; + items.push({ + label: templateFolder + ? "Stop Using as Template Folder" + : "Use as Template Folder", + onSelect: async () => { + await setTemplateFolder(templateFolder ? null : tasksExcludeRelDir); + }, + }); + } } if (!isTop) { @@ -2209,6 +2227,7 @@ export function Sidebar(): JSX.Element { toggleFavorite, toggleTasksExcludedFolder, toggleTasksIncludedFolder, + setTemplateFolder, bulkSelectionMenuItems, selectedSidebarKeys, ]); diff --git a/packages/app-core/src/components/TemplatePalette.tsx b/packages/app-core/src/components/TemplatePalette.tsx index 748f4f7f6..4160735cf 100644 --- a/packages/app-core/src/components/TemplatePalette.tsx +++ b/packages/app-core/src/components/TemplatePalette.tsx @@ -1,7 +1,8 @@ /** * Space-t / :template / :tmpl → a searchable list of every note template - * (built-in + custom). Styled to match BufferPalette/SearchPalette so the - * template picker feels like the same overlay with a different source. + * (built-in, custom, and the notes in the vault's template folder, #1024). + * Styled to match BufferPalette/SearchPalette so the template picker feels + * like the same overlay with a different source. * * Selecting an entry creates a new note from that template, substituting * variables and placing the caret at the template's `{{cursor}}` marker. @@ -13,9 +14,10 @@ import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' import { focusEditorAfterPaletteClose } from '../lib/editor-focus' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' -import { mergeTemplates } from '@shared/template-files' +import { mergeTemplates, templateSource, templateSourceLabel } from '@shared/template-files' import type { NoteTemplate } from '@bridge-contract/templates' import { Modal } from './ui/Modal' +import { FolderGlyphIcon } from './FolderIcons' export function TemplatePalette(): JSX.Element { const setOpen = useStore((s) => s.setTemplatePaletteOpen) @@ -39,7 +41,10 @@ export function TemplatePalette(): JSX.Element { rankItems(templates, query, [ { get: (t) => t.name, weight: 1 }, { get: (t) => t.description, weight: 0.8 }, - { get: (t) => t.category, weight: 0.5 } + { get: (t) => t.category, weight: 0.5 }, + // A template-folder note's folder (#1024), so `Templates` or `Work` + // narrows the list to that folder's templates. + { get: (t) => (templateSource(t) === 'folder' ? templateSourceLabel(t) : ''), weight: 0.5 } ]), [templates, query] ) @@ -109,28 +114,47 @@ export function TemplatePalette(): JSX.Element { {results.length === 0 ? ( <div className="px-4 py-6 text-center text-sm text-ink-400">No matching templates.</div> ) : ( - results.map((template, i) => ( - <button - key={template.id} - data-tmpl-idx={i} - onClick={() => choose(template)} - onMouseMove={() => setActive(i)} - className={[ - 'flex w-full min-w-0 items-center gap-3 px-4 py-2 text-left', - i === active ? 'bg-paper-200' : 'hover:bg-paper-200/70' - ].join(' ')} - > - <span className="flex min-w-0 flex-1 flex-col"> - <span className="truncate text-sm font-medium text-ink-900">{template.name}</span> - {template.description && ( - <span className="truncate text-xs text-ink-400">{template.description}</span> + results.map((template, i) => { + // Where the template lives, so two of one name can be told apart + // (#1024): the category for a built-in or a custom one, the + // folder for a note in the template folder. + const source = templateSource(template) + return ( + <button + key={template.id} + data-tmpl-idx={i} + data-tmpl-source={source} + onClick={() => choose(template)} + onMouseMove={() => setActive(i)} + className={[ + 'flex w-full min-w-0 items-center gap-3 px-4 py-2 text-left', + i === active ? 'bg-paper-200' : 'hover:bg-paper-200/70' + ].join(' ')} + > + <span className="flex min-w-0 flex-1 flex-col"> + <span className="truncate text-sm font-medium text-ink-900">{template.name}</span> + {template.description && ( + <span className="truncate text-xs text-ink-400">{template.description}</span> + )} + </span> + {source === 'folder' ? ( + <span + className="flex min-w-0 max-w-xs shrink items-center gap-1 text-xs text-ink-400" + title={template.sourcePath} + > + <span className="inline-flex shrink-0 scale-75" aria-hidden="true"> + <FolderGlyphIcon /> + </span> + <span className="truncate">{templateSourceLabel(template)}</span> + </span> + ) : ( + <span className="shrink-0 text-xs uppercase tracking-wide text-ink-400"> + {template.category} + </span> )} - </span> - <span className="shrink-0 text-xs uppercase tracking-wide text-ink-400"> - {template.category} - </span> - </button> - )) + </button> + ) + }) )} </div> <div className="flex items-center justify-end gap-4 border-t border-paper-300/70 bg-paper-100 px-4 py-2 text-xs text-ink-500"> diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index f9901641b..7d805451c 100644 --- a/packages/app-core/src/components/VaultTextSearchPalette.tsx +++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx @@ -6,6 +6,7 @@ import type { } from '@shared/ipc' import { useStore } from '../store' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' +import { isPathInTemplateFolder, resolveTemplateFolder } from '@shared/template-folder' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' import { recordRendererPerf } from '../lib/perf' @@ -162,6 +163,12 @@ export function VaultTextSearchPalette(): JSX.Element { const bodyCacheRef = useRef(new Map<string, string>()) const notesRef = useRef(notes) const noteContentsRef = useRef(noteContents) + // Template notes (#1024) are placeholders nobody is searching for. The hosts + // leave them out themselves (desktop 2.80, server 2.80); this catches an + // older server and the renderer's own fallback. + const vaultSettings = useStore((s) => s.vaultSettings) + const templateFolderRef = useRef<string | null>(null) + templateFolderRef.current = resolveTemplateFolder(vaultSettings) const resultRefs = useRef<Array<HTMLButtonElement | null>>([]) useEffect(() => { @@ -241,7 +248,9 @@ export function VaultTextSearchPalette(): JSX.Element { type ScoredMatch = VaultTextSearchMatch & { score: number } const results: ScoredMatch[] = [] const searchableNotes = notesRef.current.filter( - (note) => note.folder === 'inbox' || note.folder === 'quick' || note.folder === 'archive' + (note) => + (note.folder === 'inbox' || note.folder === 'quick' || note.folder === 'archive') && + !isPathInTemplateFolder(note.path, templateFolderRef.current) ) const bodies = await Promise.all( @@ -312,13 +321,14 @@ export function VaultTextSearchPalette(): JSX.Element { const runSearch = async (): Promise<void> => { const startedAt = performance.now() try { - const matches = + const matches = ( typeof window.zen.searchVaultText === 'function' ? await window.zen.searchVaultText(trimmed, backend, { ripgrepPath: ripgrepBinaryPath, fzfPath: fzfBinaryPath }) : await fallbackSearchVaultText(trimmed) + ).filter((match) => !isPathInTemplateFolder(match.path, templateFolderRef.current)) if (requestIdRef.current !== requestId) return setResults(matches) setLoading(false) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 6f4a32d97..ecd75d9f5 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -116,6 +116,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Open Settings → Templates. Press "New template" to author one: a template is just markdown with optional YAML frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and a body. Use the variables `{{title}}`, `{{date}}`, `{{date:FORMAT}}` (e.g. `{{date:YYYY-MM-DD}}`), `{{time}}`, `{{week}}`, and `{{cursor}}` (where the caret lands). Three more stay live instead of being substituted: `{{modified_date}}`, `{{modified_time}}` and `{{modified_datetime}}` are left in the note and always show when the file was last saved, so an `Updated:` line keeps itself current; `{{modified_date:FORMAT}}` takes the same date tokens. The date format accepts the same tokens as the daily/weekly note directory and title patterns: `yyyy`/`yy`, `MMMM`/`MMM`/`MM`/`M`, `dd`/`d`, `EEEE`/`EEE` (weekday), `ww`/`w` (ISO week), as well as moment-style `YYYY`/`DD`/`dddd`; wrap literal letters in `[brackets]`. Custom templates are saved as plain `.md` files under `.zennotes/templates/`. You can also fork a built-in by pressing Edit on it: that creates an editable copy that shadows the original, and Reset restores the built-in. From any note, the "Save Current Note as Template…" command captures it as a new template.' }, + { + title: 'Keep templates in a folder of your vault', + body: + 'Pick a folder under Settings → Templates → Template folder, or choose “Use as Template Folder” from a folder’s menu in the sidebar (`m` in Vim mode). Every note in it, subfolders included, is a template in the picker, marked with its folder. The whole note is the template, named after its file, and its own frontmatter is copied into each new note. A frontmatter with only custom-template keys (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) is read as the template’s settings instead. A custom template with the same name shows too, listed first. The folder’s checkboxes stay out of Tasks and its notes out of search; start a Search Notes query with the folder’s path (`Templates/`) to find one. Saved in the vault; a self-hosted server keeps it from 2.80.' + }, { title: 'Draw diagrams with Excalidraw', body: @@ -477,7 +482,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Templates scaffold new notes', body: - 'Templates turn a repeated note shape into one keystroke. ZenNotes ships built-in templates for engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do), and you can author your own under Settings → Templates. A template is plain markdown with optional frontmatter and variables (`{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, and `{{cursor}}`) substituted at creation time, plus the live `{{modified_date}}`, `{{modified_time}}` and `{{modified_datetime}}`, which stay in the note and show its last-saved time. Custom templates are stored as `.md` files in `.zennotes/templates/`, so they stay portable like everything else. Daily and weekly notes can each be assigned a template so dated notes start pre-filled.' + 'Templates turn a repeated note shape into one keystroke. ZenNotes ships built-in templates for engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do), and you can author your own under Settings → Templates. A template is plain markdown with optional frontmatter and variables (`{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, and `{{cursor}}`) substituted at creation time, plus the live `{{modified_date}}`, `{{modified_time}}` and `{{modified_datetime}}`, which stay in the note and show its last-saved time. Custom templates are stored as `.md` files in `.zennotes/templates/`, so they stay portable like everything else; a template folder makes the notes in it templates too. Daily and weekly notes can each be assigned a template so dated notes start pre-filled.' }, { title: 'Reference and connections support research-heavy work', @@ -1345,6 +1350,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { title: 'Templates', items: [ + { label: 'Template folder', detail: 'Notes in this folder are templates, kept out of Tasks and search. Saved in the vault.' }, { label: 'Template library', detail: 'Browse every template, built-in and custom. Built-ins cover engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do).' }, { label: 'Create a custom template', detail: 'Author a new template as markdown with optional frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and variables like `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, `{{cursor}}`, and the live `{{modified_date}}` / `{{modified_time}}` / `{{modified_datetime}}` (rendered from the note’s last-saved time rather than substituted). It is saved as a `.md` file in `.zennotes/templates/`.' }, { label: 'Edit or reset built-ins', detail: 'Press Edit on a built-in to fork an editable copy that shadows the original everywhere; Reset removes the copy and restores the built-in. Custom templates can be edited or deleted directly.' }, diff --git a/packages/app-core/src/lib/note-search.test.ts b/packages/app-core/src/lib/note-search.test.ts index 454465790..6d9c3f263 100644 --- a/packages/app-core/src/lib/note-search.test.ts +++ b/packages/app-core/src/lib/note-search.test.ts @@ -28,6 +28,35 @@ function note( } } +// #1024: notes in the template folder are placeholders, not notes anyone is +// looking for, so Search notes leaves them out until the query names the folder. +describe('note search and the template folder', () => { + const entries = buildNoteSearchIndex([ + note('Templates/Meeting.md', 'Meeting', { excerpt: 'Agenda' }), + note('Templates/Work/Standup.md', 'Standup'), + note('Notes/Meeting with Ana.md', 'Meeting with Ana', { excerpt: 'Agenda' }), + note('Templates Old/Meeting.md', 'Meeting old') + ]) + const titles = (query: string, templateFolder: string | null = 'Templates'): string[] => + searchNoteIndex(entries, query, { limit: 10, templateFolder }).map((n) => n.title).sort() + + it('leaves template notes out of matches and of the empty-query list', () => { + expect(titles('meeting')).toEqual(['Meeting old', 'Meeting with Ana']) + expect(titles('agenda')).toEqual(['Meeting with Ana']) + expect(titles('')).toEqual(['Meeting old', 'Meeting with Ana']) + }) + + it('finds them again when the query starts with the folder path', () => { + expect(titles('templates/')).toEqual(['Meeting', 'Standup']) + expect(titles('Templates/meet')).toEqual(['Meeting']) + expect(titles('Templates/Work/st')).toEqual(['Standup']) + }) + + it('shows everything when the vault has no template folder', () => { + expect(titles('meeting', null)).toEqual(['Meeting', 'Meeting old', 'Meeting with Ana']) + }) +}) + describe('note search', () => { it('parses free text and inline tag filters', () => { expect(parseNoteSearchQuery('#ops migration #prod')).toEqual({ diff --git a/packages/app-core/src/lib/note-search.ts b/packages/app-core/src/lib/note-search.ts index 79df9ee99..0092d842d 100644 --- a/packages/app-core/src/lib/note-search.ts +++ b/packages/app-core/src/lib/note-search.ts @@ -144,17 +144,37 @@ function insertTopMatch<T extends { score: number }>( if (next.score > matches[lowestIndex].score) matches[lowestIndex] = next } +/** + * Whether a query reaches into the template folder (#1024). Template notes are + * placeholders nobody is searching for, so they stay out of the results until + * the query starts with the folder's path and a slash (`Templates/meet`), + * which is how a template is found to be edited. + */ +function queryNamesTemplateFolder(freeText: string, folder: string): boolean { + return freeText.toLowerCase().startsWith(`${folder.toLowerCase()}/`) +} + export function searchNoteIndex( entries: NoteSearchEntry[], query: string, options: { limit: number defaultOrder?: NoteSearchDefaultOrder + /** The vault's template folder (#1024); its notes are left out unless + * the query names it. */ + templateFolder?: string | null } ): NoteMeta[] { const { freeText, tagTokens } = parseNoteSearchQuery(query) const limit = Math.max(0, options.limit) if (limit === 0) return [] + const hidden = + options.templateFolder && !queryNamesTemplateFolder(freeText, options.templateFolder) + ? `${options.templateFolder}/` + : null + if (hidden) { + entries = entries.filter((entry) => !entry.path.startsWith(hidden)) + } if (!freeText) { const live = entries.filter( diff --git a/packages/app-core/src/lib/template-files.test.ts b/packages/app-core/src/lib/template-files.test.ts index 5a34514c8..3cdf65fb9 100644 --- a/packages/app-core/src/lib/template-files.test.ts +++ b/packages/app-core/src/lib/template-files.test.ts @@ -4,9 +4,12 @@ import { findTemplate, mergeTemplates, parseCustomTemplate, + parseFolderTemplate, parseFrontmatter, slugifyTemplateName, - templateSettingId + templateSettingId, + templateSource, + templateSourceLabel } from '@shared/template-files' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' import type { NoteTemplate } from '@bridge-contract/templates' @@ -191,3 +194,101 @@ describe('findTemplate', () => { }) }) +// #1024: a note in the vault's template folder is a template. A plain note is +// the Obsidian shape (the whole file is the template, named after the file); +// a file headed the `.zennotes/templates` way is read the way that folder +// reads it, so moving a template between the two keeps it working. +describe('parseFolderTemplate (#1024)', () => { + it('reads a plain note as a template named after its file', () => { + const raw = '# {{title}}\n\n## Agenda\n\n- [ ] {{cursor}}\n' + const t = parseFolderTemplate(raw, 'Templates/Meeting.md', 'Templates') + expect(t).toMatchObject({ + id: 'folder:Meeting', + name: 'Meeting', + description: '', + category: 'Custom', + body: raw, + builtin: false, + source: 'folder', + sourcePath: 'Templates/Meeting.md' + }) + expect(t.builtinId).toBeUndefined() + expect(t.titleTemplate).toBeUndefined() + }) + + it("keeps a note's own frontmatter in the body, for the new note to carry", () => { + const raw = '---\ntags: meeting\ncreated: {{date}}\n---\n# {{title}}\n' + const t = parseFolderTemplate(raw, 'Templates/Meeting.md', 'Templates') + expect(t.body).toBe(raw) + expect(t.name).toBe('Meeting') + expect(t.category).toBe('Custom') + }) + + it('reads a .zennotes/templates header as template settings, not note content', () => { + const raw = + '---\nname: Standup\ndescription: Daily sync\ncategory: Engineering\ntitleTemplate: "{{date:YYYY-MM-DD}} Standup"\ntargetSubpath: Meetings\n---\n## Yesterday\n' + const t = parseFolderTemplate(raw, 'Templates/Work/standup.md', 'Templates') + expect(t).toMatchObject({ + id: 'folder:Work/standup', + name: 'Standup', + description: 'Daily sync', + category: 'Engineering', + titleTemplate: '{{date:YYYY-MM-DD}} Standup', + targetSubpath: 'Meetings', + body: '## Yesterday\n', + source: 'folder' + }) + }) + + it('never lets a template-folder note shadow a built-in', () => { + const raw = '---\nname: Daily Note\ncategory: Personal\nbuiltinId: builtin.daily\n---\nbody\n' + const t = parseFolderTemplate(raw, 'Templates/Daily Note.md', 'Templates') + expect(t.builtinId).toBeUndefined() + expect(t.body).toBe('body\n') + const merged = mergeTemplates(BUILTIN_TEMPLATES, [t]) + expect(merged.filter((m) => m.name === 'Daily Note').map((m) => m.id)).toEqual([ + 'builtin.daily', + 'folder:Daily Note' + ]) + }) + + it('treats a frontmatter with any key of its own as note content', () => { + const raw = '---\nname: Standup\naliases: sync\n---\nbody\n' + expect(parseFolderTemplate(raw, 'Templates/standup.md', 'Templates').body).toBe(raw) + }) +}) + +describe('template sources (#1024)', () => { + const stored = parseCustomTemplate( + '---\nname: Meeting\n---\nstored\n', + '.zennotes/templates/meeting.md' + ) + const fromFolder = parseFolderTemplate('folder\n', 'Templates/Meeting.md', 'Templates') + const nested = parseFolderTemplate('nested\n', 'Templates/Work/Retro.md', 'Templates') + + it('says where each template comes from', () => { + expect(templateSource(BUILTIN_TEMPLATES[0])).toBe('builtin') + expect(templateSource(stored)).toBe('custom') + expect(templateSource(fromFolder)).toBe('folder') + }) + + it('labels a template-folder entry with the folder it sits in', () => { + expect(templateSourceLabel(fromFolder)).toBe('Templates') + expect(templateSourceLabel(nested)).toBe('Templates/Work') + expect(templateSourceLabel(stored)).toBe('Custom') + expect(templateSourceLabel(BUILTIN_TEMPLATES[0])).toBe(BUILTIN_TEMPLATES[0].category) + }) + + it('keeps both sides of a name clash, the .zennotes/templates file first', () => { + // Either order in, same order out: the clash is settled by source, not by + // whichever list happened to load first. + for (const custom of [ + [stored, fromFolder], + [fromFolder, stored] + ]) { + const merged = mergeTemplates([], custom) + expect(merged.map((t) => t.id)).toEqual(['custom:meeting', 'folder:Meeting']) + } + }) +}) + diff --git a/packages/app-core/src/lib/vault-layout.test.ts b/packages/app-core/src/lib/vault-layout.test.ts index d12c2ee94..7023fd84b 100644 --- a/packages/app-core/src/lib/vault-layout.test.ts +++ b/packages/app-core/src/lib/vault-layout.test.ts @@ -872,6 +872,21 @@ describe('normalizeVaultSettings drawings/databases location (#362)', () => { }) }) +describe('normalizeVaultSettings template folder (#1024)', () => { + it('keeps a valid folder and leaves the key out otherwise', () => { + const set = normalizeVaultSettings({ + templates: { folder: ' /Templates/ ' } + } as unknown as VaultSettings) + expect(set.templates).toEqual({ folder: 'Templates' }) + expect(normalizeVaultSettings(null).templates).toBeUndefined() + for (const templates of [{ folder: '' }, { folder: '../x' }, { folder: '.zennotes' }, 'x']) { + const n = normalizeVaultSettings({ templates } as unknown as VaultSettings) + expect(n.templates).toBeUndefined() + expect('templates' in n).toBe(false) + } + }) +}) + describe('isCalendarToggleAvailable (#413)', () => { const daily = { dailyNotes: { enabled: true } } as unknown as VaultSettings const weekly = { weeklyNotes: { enabled: true } } as unknown as VaultSettings diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index 275f8b5df..287c1c4be 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -30,6 +30,7 @@ import { normalizeTasksIncludedFolders } from '@shared/tasks-excluded-folders' import { normalizeTypstPreambleSettings } from '@shared/typst-preamble-folder' +import { normalizeTemplateFolderSettings } from '@shared/template-folder' import { normalizeHarperVaultState } from '@shared/harper-settings' import { normalizeShareLocation } from '@shared/share-location' import { normalizeVaultDisplayName } from '@shared/vault-display-name' @@ -625,6 +626,7 @@ export function normalizeVaultSettings( settings?.tasks?.includedFolders ) const normalizedTypstPreambles = normalizeTypstPreambleSettings(settings?.typstPreambles) + const normalizedTemplates = normalizeTemplateFolderSettings(settings?.templates) const normalizedHarper = normalizeHarperVaultState(settings?.harper) const displayName = normalizeVaultDisplayName(settings?.displayName) const primaryNotesLocation = @@ -707,6 +709,7 @@ export function normalizeVaultSettings( } : {}), ...(normalizedTypstPreambles ? { typstPreambles: normalizedTypstPreambles } : {}), + ...(normalizedTemplates ? { templates: normalizedTemplates } : {}), ...(normalizedHarper ? { harper: normalizedHarper } : {}), ...(displayName ? { displayName } : {}) } diff --git a/packages/app-core/src/lib/vault-settings-conflict.ts b/packages/app-core/src/lib/vault-settings-conflict.ts index 1f4b052ad..86be8f51c 100644 --- a/packages/app-core/src/lib/vault-settings-conflict.ts +++ b/packages/app-core/src/lib/vault-settings-conflict.ts @@ -32,6 +32,7 @@ export const VAULT_SETTINGS_SECTION_LABELS: Record<VaultSettingsSection, string> systemFolderPaths: "Built-in folder locations", tasks: "Tasks", typstPreambles: "Typst preambles", + templates: "Template folder", harper: "Harper dictionary", }; diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 6c9b6e588..8ac9a82dd 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -3158,6 +3158,177 @@ describe('custom templates on the change feed (#723)', () => { }) }) +// #1024: the vault's template folder. Its notes are templates, read through +// the same note bridge every runtime has (desktop, server, web, phones), and +// they are placeholders rather than work: no Tasks, no search hits. +describe('template folder (#1024)', () => { + const MEETING = 'Templates/Meeting.md' + const MEETING_BODY = '# {{title}}\n\n- [ ] agenda item\n' + const noteAt = (path: string, body: string, updatedAt = 1) => ({ + ...makeNote(body, path), + updatedAt + }) + const taskIn = (path: string, content: string): VaultTask => ({ + ...makeTask(content), + id: `${path}#0`, + sourcePath: path + }) + + function installVault(files: Record<string, string>, settings: object = {}) { + const disk = new Map(Object.entries(files)) + const stamps = new Map<string, number>() + const listNotes = vi.fn(async () => + [...disk.entries()].map(([path, body]) => noteAt(path, body, stamps.get(path) ?? 1)) + ) + const readNote = vi.fn(async (path: string) => { + const body = disk.get(path) + if (body === undefined) throw missing(path) + return noteAt(path, body, stamps.get(path) ?? 1) + }) + let vaultSettings: object = { templates: { folder: 'Templates' }, ...settings } + const getVaultSettings = vi.fn(async () => vaultSettings) + const setVaultSettings = vi.fn(async (next: object) => { + vaultSettings = next + return next + }) + installZen({ + listNotes, + readNote, + getVaultSettings, + setVaultSettings, + listTemplates: vi.fn().mockResolvedValue([ + { sourcePath: '.zennotes/templates/meeting.md', raw: '---\nname: Meeting\n---\nstored\n' } + ]) + }) + return { + disk, + readNote, + setVaultSettings, + edit(path: string, body: string) { + disk.set(path, body) + stamps.set(path, (stamps.get(path) ?? 1) + 1) + } + } + } + + async function openVault(settings: object = {}) { + const { useStore } = await loadStore() + const { normalizeVaultSettings } = await import('./lib/vault-layout') + useStore.setState({ + vaultSettings: normalizeVaultSettings({ + ...(await window.zen.getVaultSettings()), + ...settings + } as never) + }) + await useStore.getState().loadCustomTemplates() + await useStore.getState().refreshNotes() + await useStore.getState().loadFolderTemplates() + return useStore + } + + it('lists the notes in the folder as templates, after the .zennotes/templates ones', async () => { + installVault({ [MEETING]: MEETING_BODY, 'Templates/Work/Retro.md': '## Went well\n', 'Notes/Plan.md': '# Plan\n' }) + const useStore = await openVault() + + expect(useStore.getState().customTemplates.map((t) => [t.id, t.source ?? 'custom'])).toEqual([ + ['custom:meeting', 'custom'], + ['folder:Meeting', 'folder'], + ['folder:Work/Retro', 'folder'] + ]) + expect(useStore.getState().customTemplates.find((t) => t.id === 'folder:Meeting')?.body).toBe( + MEETING_BODY + ) + }) + + it('creates a note whose body is the template, variables filled in', async () => { + installVault({ [MEETING]: MEETING_BODY }) + const createNote = vi.fn(async () => noteAt('Weekly sync.md', '')) + const writeNote = vi.fn(async () => noteAt('Weekly sync.md', '')) + Object.assign(window.zen, { createNote, writeNote }) + const useStore = await openVault() + const template = useStore.getState().customTemplates.find((t) => t.id === 'folder:Meeting')! + + await useStore.getState().createFromTemplate(template, { + folder: 'inbox', + subpath: '', + title: 'Weekly sync' + }) + + expect(writeNote).toHaveBeenCalledWith('Weekly sync.md', '# Weekly sync\n\n- [ ] agenda item\n') + }) + + it('picks up an edit to the template note without a restart', async () => { + const vault = installVault({ [MEETING]: MEETING_BODY }) + const useStore = await openVault() + + vault.edit(MEETING, '# {{title}}\n\nedited\n') + await useStore.getState().applyChange({ kind: 'change', path: MEETING, folder: 'inbox' }) + await flushAsyncWork() + + expect(useStore.getState().customTemplates.find((t) => t.id === 'folder:Meeting')?.body).toBe( + '# {{title}}\n\nedited\n' + ) + }) + + it('reads an unchanged template note once, not on every refresh', async () => { + const vault = installVault({ [MEETING]: MEETING_BODY, 'Notes/Plan.md': '# Plan\n' }) + const useStore = await openVault() + const reads = vault.readNote.mock.calls.filter(([p]) => p === MEETING).length + + await useStore.getState().refreshNotes() + await useStore.getState().loadFolderTemplates() + + expect(vault.readNote.mock.calls.filter(([p]) => p === MEETING).length).toBe(reads) + }) + + it('keeps the template placeholders out of Tasks, from any host', async () => { + // A server or phone that does not know the setting still hands these over. + installVault({ [MEETING]: MEETING_BODY }) + const useStore = await openVault() + const scanTasks = vi + .fn() + .mockResolvedValue([taskIn(MEETING, 'agenda item'), taskIn('Notes/Plan.md', 'real work')]) + const scanTasksForPath = vi.fn().mockResolvedValue([taskIn(MEETING, 'agenda item')]) + Object.assign(window.zen, { scanTasks, scanTasksForPath }) + + await useStore.getState().refreshTasks() + expect(useStore.getState().vaultTasks.map((t) => t.content)).toEqual(['real work']) + + await useStore.getState().rescanTasksForPath(MEETING) + expect(useStore.getState().vaultTasks.map((t) => t.content)).toEqual(['real work']) + }) + + it('drops the folder templates and lets their tasks back when the setting is cleared', async () => { + const vault = installVault({ [MEETING]: MEETING_BODY }) + const useStore = await openVault() + const scanTasks = vi.fn().mockResolvedValue([taskIn(MEETING, 'agenda item')]) + Object.assign(window.zen, { scanTasks }) + + await useStore.getState().setTemplateFolder(null) + + expect(vault.setVaultSettings).toHaveBeenCalled() + expect(useStore.getState().vaultSettings.templates).toBeUndefined() + expect(useStore.getState().customTemplates.map((t) => t.id)).toEqual(['custom:meeting']) + expect(useStore.getState().vaultTasks.map((t) => t.content)).toEqual(['agenda item']) + }) + + it('sets the folder from Settings and lists its notes straight away', async () => { + const vault = installVault({ [MEETING]: MEETING_BODY }, { templates: undefined }) + const useStore = await openVault({ templates: undefined }) + expect(useStore.getState().customTemplates.map((t) => t.id)).toEqual(['custom:meeting']) + + await useStore.getState().setTemplateFolder(' Templates/ ') + + expect(vault.setVaultSettings.mock.calls.at(-1)?.[0]).toMatchObject({ + templates: { folder: 'Templates' } + }) + expect(useStore.getState().customTemplates.map((t) => t.id)).toEqual([ + 'custom:meeting', + 'folder:Meeting' + ]) + }) +}) + describe('remote workspace capabilities after boot (#723)', () => { it('re-reads the workspace info once getCurrentVault has connected the server', async () => { const base = { diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 1d9b444bc..ce9a1bbd3 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -25,6 +25,12 @@ import { normalizeTasksIncludedFolder, rewriteTasksFoldersForRename } from '@shared/tasks-excluded-folders' +import { + isPathInTemplateFolder, + normalizeTemplateFolder, + resolveTemplateFolder, + rewriteTemplateFolderForRename +} from '@shared/template-folder' import { toggleTasksFolder } from './lib/tasks-folder-lists' import { cloudSyncPathKey } from '@zennotes/shared-domain/cloud-sync' import { useCloudSyncStatusStore } from './lib/cloud-auto-sync' @@ -201,6 +207,7 @@ import { findTemplate, mergeTemplates, parseCustomTemplate, + parseFolderTemplate, slugifyTemplateName } from '@shared/template-files' import { buildWorkflowIndex } from './lib/workflow-index' @@ -511,6 +518,40 @@ function resolveTemplate( return findTemplate(mergeTemplates(BUILTIN_TEMPLATES, customTemplates), id) } +/** Template-folder notes as parsed (#1024), keyed by vault root and path, with + * the listing stamp each was read at. Every note listing re-checks the folder + * and most listings follow an edit somewhere else, so a note whose stamp has + * not moved is not read again. */ +const folderTemplateCache = new Map<string, { stamp: string; template: NoteTemplate }>() +let folderTemplateLoad = 0 + +/** A template-folder template with its body as it reads right now; any other + * template as it is. Creating from a template reads the note once more, so a + * new note never starts from a copy the listing has not caught up with. */ +async function withCurrentFolderTemplateBody(template: NoteTemplate): Promise<NoteTemplate> { + if (template.source !== 'folder' || !template.sourcePath) return template + const state = useStore.getState() + const folder = resolveTemplateFolder(state.vaultSettings) + if (!folder || !isPathInTemplateFolder(template.sourcePath, folder)) return template + try { + const raw = + state.noteContents[template.sourcePath]?.body ?? + (await window.zen.readNote(template.sourcePath)).body + return parseFolderTemplate(raw, template.sourcePath, folder) + } catch { + return template + } +} + +/** The tasks a host handed over, less those from the template folder (#1024). + * A template's `- [ ]` lines are placeholders, and a server or phone that + * does not know the setting yet still sends them. */ +function withoutTemplateFolderTasks(tasks: VaultTask[], settings: VaultSettings): VaultTask[] { + const folder = resolveTemplateFolder(settings) + if (!folder) return tasks + return tasks.filter((task) => !isPathInTemplateFolder(task.sourcePath, folder)) +} + function isDeletedAssetRecord(value: unknown): value is DeletedAsset { if (!value || typeof value !== 'object') return false const candidate = value as Record<string, unknown> @@ -3185,8 +3226,14 @@ interface Store { /** When set, the template picker creates in this folder (set by right-click); * null means prompt the user for a destination. */ templatePaletteTarget: { folder: NoteFolder; subpath: string } | null - /** Custom templates loaded from `.zennotes/templates/` (built-ins are constants). */ + /** Every template the user has (built-ins are constants): the files in + * `.zennotes/templates/` first, then the notes in the vault's template + * folder (#1024). What every picker merges with the built-ins. */ customTemplates: NoteTemplate[] + /** The `.zennotes/templates/` half of `customTemplates`. */ + storedTemplates: NoteTemplate[] + /** The template-folder half of `customTemplates` (#1024). */ + folderTemplates: NoteTemplate[] /** * The vault's workflows, summarized for surfaces outside the workflows view. * The command palette builds its Run entries from this synchronously, which @@ -4044,6 +4091,16 @@ interface Store { insertTemplateIntoActiveNote: (template: NoteTemplate) => void /** Reload custom templates from disk (called on vault open and after CRUD). */ loadCustomTemplates: () => Promise<void> + /** Re-read the notes in the vault's template folder (#1024) as templates. + * Runs after every note listing; a note whose listing stamp has not moved + * is not read again. */ + loadFolderTemplates: () => Promise<void> + /** + * Point the vault at a template folder (#1024), a vault-relative path such + * as `Templates`, or clear it with null. Saved to vault.json; the picker, + * Tasks and search follow at once. + */ + setTemplateFolder: (folder: string | null) => Promise<void> /** Reload the workflow index (called with the vault indexes and after workflow CRUD). */ loadWorkflowIndex: () => Promise<void> saveCustomTemplate: (input: { @@ -5436,6 +5493,12 @@ export const useStore = create<Store>((set, get) => { s.vaultSettings.tasks, oldPrefix.replace(/\/$/, ''), newPrefix.replace(/\/$/, '') + ), + // So did the template folder (#1024), an on-disk path as well. + templates: rewriteTemplateFolderForRename( + s.vaultSettings.templates, + oldPrefix.replace(/\/$/, ''), + newPrefix.replace(/\/$/, '') ) } })) @@ -6186,6 +6249,8 @@ export const useStore = create<Store>((set, get) => { templatePaletteMode: 'create', templatePaletteTarget: null, customTemplates: [], + storedTemplates: [], + folderTemplates: [], workflowIndex: [], query: '', initialized: false, @@ -7289,7 +7354,10 @@ export const useStore = create<Store>((set, get) => { try { const tasks = await window.zen.scanTasks() if (!isCurrent() || revision !== taskIndexRevision) return - set({ vaultTasks: tasks, tasksLoading: false }) + set({ + vaultTasks: withoutTemplateFolderTasks(tasks, get().vaultSettings), + tasksLoading: false + }) } catch (err) { console.error('scanTasks failed', err) if (!isCurrent() || revision !== taskIndexRevision) return @@ -7305,7 +7373,9 @@ export const useStore = create<Store>((set, get) => { const fresh = await window.zen.scanTasksForPath(relPath) if (!isCurrent() || folderMutationBlocks(relPath) || version !== folderReadVersion(relPath)) return set((s) => ({ - vaultTasks: s.vaultTasks.filter((t) => t.sourcePath !== relPath).concat(fresh) + vaultTasks: s.vaultTasks + .filter((t) => t.sourcePath !== relPath) + .concat(withoutTemplateFolderTasks(fresh, s.vaultSettings)) })) } catch (err) { console.error('scanTasksForPath failed', err) @@ -8171,6 +8241,10 @@ export const useStore = create<Store>((set, get) => { // The note list is where preamble notes are discovered, so keep them in // step with it (no-op unless the setting is on). (#486) if (get().typstTagPreambles) void get().refreshTypstPreambles() + // Template-folder notes too (#1024): a note added, removed or edited in + // the folder, here or on another device, reaches the picker this way. + // Unchanged notes are not read again. + void get().loadFolderTemplates() } catch (err) { console.error('refresh failed', err) } @@ -8369,6 +8443,9 @@ export const useStore = create<Store>((set, get) => { // So do agent edit records (scope 'agent-edits'). get().refreshAgentEdits() ]) + // The listing and the settings landed in either order; read the + // template folder (#1024) against both. + await get().loadFolderTemplates() const stateAfter = get() const openTabs = [...new Set(allLeaves(stateAfter.paneLayout).flatMap((leaf) => leaf.tabs))] // Databases and comment threads have their own feed scopes ('database', @@ -8488,6 +8565,7 @@ export const useStore = create<Store>((set, get) => { .getVaultSettings() .then((settings) => { const normalized = normalizeVaultSettings(settings) + const templateFolderBefore = resolveTemplateFolder(get().vaultSettings) // Re-overlay view overrides if vault.json changed externally — only // in per-vault scope. (#292) set({ @@ -8499,6 +8577,12 @@ export const useStore = create<Store>((set, get) => { const nameBefore = get().vault?.name applyVaultNameFromSettings(normalized) if (get().vault?.name !== nameBefore) void get().refreshLocalVaults() + // A template folder set on another device or by hand (#1024) + // changes the picker and which checkboxes count as tasks. + if (resolveTemplateFolder(normalized) !== templateFolderBefore) { + void get().loadFolderTemplates() + if (tasksSurfaceVisible(get())) void get().refreshTasks() + } }) .catch((err) => { console.error('refresh vault settings failed', err) @@ -8700,6 +8784,12 @@ export const useStore = create<Store>((set, get) => { ...activeFieldsFrom(cur.paneLayout, cur.activePaneId, cur.noteContents, dirty) } }) + // A template edited in the editor (#1024) reaches the picker with the + // save, without waiting for the watcher's echo: the listing stamp just + // moved, so the folder load reads this note again. + if (isPathInTemplateFolder(path, resolveTemplateFolder(get().vaultSettings))) { + void get().loadFolderTemplates() + } // This app saved the note, which is what the workflow event triggers // listen for. A tag the note did not carry before is its own event. emitNoteEvent('note-saved', path) @@ -10459,13 +10549,81 @@ export const useStore = create<Store>((set, get) => { }, loadCustomTemplates: async () => { + let stored: NoteTemplate[] = [] try { const files = await window.zen.listTemplates() - set({ customTemplates: files.map((f) => parseCustomTemplate(f.raw, f.sourcePath)) }) + stored = files.map((f) => parseCustomTemplate(f.raw, f.sourcePath)) } catch (err) { console.error('loadCustomTemplates failed', err) - set({ customTemplates: [] }) } + set((s) => ({ storedTemplates: stored, customTemplates: [...stored, ...s.folderTemplates] })) + }, + + loadFolderTemplates: async () => { + // The template folder's notes are ordinary notes (#1024): they arrive with + // the note listing and are read through readNote, which every host has, so + // a self-hosted server, the web client and the phones need nothing of + // their own to show them. + const load = ++folderTemplateLoad + const state = get() + const root = state.vault?.root ?? '' + const folder = resolveTemplateFolder(state.vaultSettings) + const candidates = folder + ? state.notes.filter( + (note) => + note.folder !== 'trash' && + /\.md$/i.test(note.path) && + // An Obsidian drawing is a `.excalidraw.md` file, not a template. + !isObsidianExcalidrawPath(note.path) && + isPathInTemplateFolder(note.path, folder) + ) + : [] + const present = new Set<string>() + const read = await Promise.all( + candidates.map(async (note): Promise<NoteTemplate | null> => { + const key = `${root}\0${note.path}` + const stamp = `${folder}\0${note.updatedAt}\0${note.size}` + present.add(key) + const cached = folderTemplateCache.get(key) + if (cached && cached.stamp === stamp) return cached.template + try { + const { body } = await window.zen.readNote(note.path) + const template = parseFolderTemplate(body, note.path, folder!) + folderTemplateCache.set(key, { stamp, template }) + return template + } catch (err) { + console.error('template folder read failed', note.path, err) + return null + } + }) + ) + if (load !== folderTemplateLoad) return // a newer listing is loading them + for (const key of folderTemplateCache.keys()) { + if (!present.has(key)) folderTemplateCache.delete(key) + } + const loaded = read + .filter((t): t is NoteTemplate => t !== null) + .sort((a, b) => (a.sourcePath ?? '').localeCompare(b.sourcePath ?? '')) + const previous = get().folderTemplates + if (loaded.length === previous.length && loaded.every((t, i) => t === previous[i])) return + set((s) => ({ folderTemplates: loaded, customTemplates: [...s.storedTemplates, ...loaded] })) + }, + + setTemplateFolder: async (folder) => { + const settings = get().vaultSettings + const cleaned = folder === null ? null : normalizeTemplateFolder(folder) + if (folder !== null && !cleaned) return + if (cleaned === resolveTemplateFolder(settings)) return + const { templates: _previous, ...rest } = settings + void _previous + // setVaultSettings re-lists the notes, which re-reads the folder. + const saved = await get().setVaultSettings( + cleaned ? { ...rest, templates: { folder: cleaned } } : rest + ) + if (!saved) return + await get().loadFolderTemplates() + // The old folder's checkboxes come back to Tasks, the new one's leave. + await get().refreshTasks() }, loadWorkflowIndex: async () => { @@ -10534,7 +10692,8 @@ export const useStore = create<Store>((set, get) => { title = entered.trim() } if (!title) title = template.name - const { body, cursorOffset } = renderTemplate(template.body, { title, now: opts?.date }) + const current = await withCurrentFolderTemplateBody(template) + const { body, cursorOffset } = renderTemplate(current.body, { title, now: opts?.date }) const meta = await window.zen.createNote(folder, title, subpath) rememberEditModeForCreatedNote(meta.path) // In the list at once, as createAndOpen does: the listing below is diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 312ae52cd..d5bda6bf3 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -122,6 +122,11 @@ export interface ZenCapabilities { supportsCliInstall: boolean /** Custom templates require local-filesystem CRUD; false on web/remote. */ supportsCustomTemplates: boolean + /** The host keeps vault.json's `templates.folder` (#1024) through a + * settings save. The web client says what its server says (2.80 and + * later); a host that leaves it out keeps unknown vault.json keys, as the + * phones do, and shows the setting. Only an explicit false hides it. */ + supportsTemplateFolder?: boolean supportsCustomCodeLanguages?: boolean /** Local desktop support, or a web client paired with a server that owns * workflow files and journalled apply/undo. */ diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index a06bcd3a2..4772d859b 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -654,6 +654,17 @@ export interface VaultSettings { * string so later preamble settings land beside it without another migration. */ typstPreambles?: { folder?: string } + /** + * The template folder (#1024). `folder` is a vault-relative directory path + * exactly as it exists on disk (the Tasks folder lists' shape), such as + * `Templates`. Every note inside it, at any depth, is a template in the + * picker, beside the built-ins and `.zennotes/templates/`, and is left out + * of Tasks and of search. Absent means no template folder. Normalized by + * `normalizeTemplateFolderSettings` in shared-domain; a self-hosted server + * keeps it from 2.80 (`supportsTemplateFolder`). An object rather than a + * bare string so later template settings land beside it. + */ + templates?: { folder?: string } /** * Harper grammar checker data that belongs to the vault: the words added to * its dictionary and the context hashes of ignored suggestions (digit @@ -995,6 +1006,11 @@ export interface ServerCapabilities { * keeps Default shares location hidden and the Share sheet says Quick * Notes for a vault on such a server. */ supportsSharesLocation?: boolean + /** vault.json's `templates.folder` survives the server's settings round + * trip, and the server leaves that folder out of its task scan and text + * search. Absent before 2.80, whose settings save drops it, so Settings + * keeps the Template folder row hidden for a vault on such a server. */ + supportsTemplateFolder?: boolean } export interface ServerSessionStatus { diff --git a/packages/bridge-contract/src/templates.ts b/packages/bridge-contract/src/templates.ts index d441b89ca..1839f4926 100644 --- a/packages/bridge-contract/src/templates.ts +++ b/packages/bridge-contract/src/templates.ts @@ -6,13 +6,24 @@ import type { NoteFolder } from './ipc.js' export type TemplateCategory = 'Engineering' | 'Personal' | 'Custom' /** - * A note template — either a built-in (shipped as a code constant) or a custom - * one authored by the user and stored as a `.md` file under - * `.zennotes/templates/`. The `body` has had any frontmatter stripped; it may - * still contain `{{...}}` substitution tokens. + * Where a template comes from: shipped with the app (`builtin`), a file in + * `.zennotes/templates/` (`custom`), or a note in the vault's template folder + * (`folder`, #1024). + */ +export type TemplateSource = 'builtin' | 'custom' | 'folder' + +/** + * A note template: a built-in (shipped as a code constant), a custom one + * authored by the user and stored as a `.md` file under `.zennotes/templates/`, + * or a note in the vault's template folder (#1024). A custom template's `body` + * has had its frontmatter stripped; a template-folder note keeps a frontmatter + * of its own, for the new note to carry. Either may still contain `{{...}}` + * substitution tokens. */ export interface NoteTemplate { - /** `builtin.adr` for built-ins, `custom:<slug>` for user templates. */ + /** `builtin.adr` for built-ins, `custom:<slug>` for user templates, + * `folder:<path inside the template folder, no extension>` for notes in + * the template folder. */ id: string /** Display name. */ name: string @@ -27,7 +38,11 @@ export interface NoteTemplate { /** Default subpath within the target folder. */ targetSubpath?: string builtin: boolean - /** Custom templates only: vault-relative path to the source `.md`. */ + /** Where the template lives. Absent means the `builtin` flag decides + * (`builtin` or `custom`); read it through `templateSource`. */ + source?: TemplateSource + /** Custom and template-folder templates: vault-relative path to the + * source `.md`. */ sourcePath?: string /** * When a custom template is an edited copy of a built-in, this is the diff --git a/packages/shared-domain/src/tasks-excluded-folders.test.ts b/packages/shared-domain/src/tasks-excluded-folders.test.ts index ddc9ec11d..fdb767f2a 100644 --- a/packages/shared-domain/src/tasks-excluded-folders.test.ts +++ b/packages/shared-domain/src/tasks-excluded-folders.test.ts @@ -4,9 +4,61 @@ import { normalizeTasksExcludedFolder, normalizeTasksExcludedFolders, normalizeTasksIncludedFolders, - rewriteTasksFoldersForRename + rewriteTasksFoldersForRename, + tasksFolderListsForSettings } from './tasks-excluded-folders' +// #1024: a template folder holds placeholders, not work. Its `- [ ]` lines are +// left out of Tasks on every runtime, which every scanner gets by reading its +// lists through this one helper. +describe('tasksFolderListsForSettings (#1024)', () => { + it('reads both Tasks folder lists off vault settings', () => { + expect( + tasksFolderListsForSettings({ + tasks: { excludedFolders: ['inbox/Books', '../x'], includedFolders: ['Projects'] } + }) + ).toEqual({ excluded: ['inbox/Books'], included: ['Projects'] }) + expect(tasksFolderListsForSettings({})).toEqual({ excluded: [], included: [] }) + expect(tasksFolderListsForSettings(null)).toEqual({ excluded: [], included: [] }) + }) + + it('leaves the template folder out of Tasks', () => { + const lists = tasksFolderListsForSettings({ + tasks: { excludedFolders: ['inbox/Books'] }, + templates: { folder: 'Templates' } + }) + expect(lists).toEqual({ excluded: ['inbox/Books', 'Templates'], included: [] }) + expect(isPathExcludedFromTasks('Templates/Meeting.md', lists.excluded, lists.included)).toBe(true) + expect(isPathExcludedFromTasks('Templates/Work/1on1.md', lists.excluded, lists.included)).toBe( + true + ) + expect(isPathExcludedFromTasks('Notes/Meeting.md', lists.excluded, lists.included)).toBe(false) + }) + + it('keeps the template folder out even inside a Task folder', () => { + const lists = tasksFolderListsForSettings({ + tasks: { includedFolders: ['Work'] }, + templates: { folder: 'Work/Templates' } + }) + expect(isPathExcludedFromTasks('Work/Plan.md', lists.excluded, lists.included)).toBe(false) + expect(isPathExcludedFromTasks('Work/Templates/Plan.md', lists.excluded, lists.included)).toBe( + true + ) + }) + + it('does not list the folder twice, and ignores an unusable one', () => { + expect( + tasksFolderListsForSettings({ + tasks: { excludedFolders: ['Templates'] }, + templates: { folder: 'Templates/' } + }).excluded + ).toEqual(['Templates']) + expect( + tasksFolderListsForSettings({ templates: { folder: '.zennotes/templates' } }).excluded + ).toEqual([]) + }) +}) + describe('normalizeTasksExcludedFolder (#458)', () => { it('cleans slashes and whitespace', () => { expect(normalizeTasksExcludedFolder('inbox/Books')).toBe('inbox/Books') diff --git a/packages/shared-domain/src/tasks-excluded-folders.ts b/packages/shared-domain/src/tasks-excluded-folders.ts index 82144c311..c86e17f5c 100644 --- a/packages/shared-domain/src/tasks-excluded-folders.ts +++ b/packages/shared-domain/src/tasks-excluded-folders.ts @@ -9,6 +9,8 @@ // internal/vault/settings.go: change them together and keep the rules // byte-compatible. +import { resolveTemplateFolder } from './template-folder' + /** Validate one excluded-folder entry: forward slashes, no empty or dot * segments, no traversal. Returns the cleaned path or null when invalid. */ export function normalizeTasksExcludedFolder(value: unknown): string | null { @@ -60,6 +62,29 @@ export function isPathExcludedFromTasks( return isInsideAnyFolder(relPath, excluded) } +/** + * The folder lists every Tasks scanner applies, read off vault settings (raw + * or normalized): the excluded folders and the Task folders, validated, with + * the template folder (#1024) added to the excluded ones. A template's + * `- [ ]` lines are placeholders for the note it will make, never work, so the + * template folder is left out even inside a Task folder. Callers that scan + * past the lists on request (MCP's `includeExcluded`) skip this helper and so + * reach the template folder as well. + */ +export function tasksFolderListsForSettings( + settings: { tasks?: unknown; templates?: unknown } | null | undefined +): { excluded: string[]; included: string[] } { + const tasks = + settings?.tasks && typeof settings.tasks === 'object' + ? (settings.tasks as { excludedFolders?: unknown; includedFolders?: unknown }) + : undefined + const excluded = normalizeTasksExcludedFolders(tasks?.excludedFolders) + const included = normalizeTasksIncludedFolders(tasks?.includedFolders) + const templateFolder = resolveTemplateFolder(settings) + if (templateFolder && !excluded.includes(templateFolder)) excluded.push(templateFolder) + return { excluded, included } +} + function isInsideAnyFolder(relPath: string, folders: readonly string[]): boolean { for (const folder of folders) { if (relPath === folder || relPath.startsWith(folder + '/')) return true diff --git a/packages/shared-domain/src/template-files.ts b/packages/shared-domain/src/template-files.ts index 8b1537086..296eacf50 100644 --- a/packages/shared-domain/src/template-files.ts +++ b/packages/shared-domain/src/template-files.ts @@ -3,7 +3,12 @@ // caller share one frontmatter implementation. The main process only does file // I/O and never parses, so this is the single source of truth for the format. import type { NoteFolder } from '@zennotes/bridge-contract/ipc' -import type { NoteTemplate, TemplateCategory } from '@zennotes/bridge-contract/templates' +import type { + NoteTemplate, + TemplateCategory, + TemplateSource +} from '@zennotes/bridge-contract/templates' +import { templateFolderEntryKey } from './template-folder' const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/ @@ -85,12 +90,98 @@ export function parseCustomTemplate(raw: string, sourcePath: string): NoteTempla } } +/** The frontmatter keys a `.zennotes/templates` file uses to describe itself + * (`builtinId` included: composeTemplateFile writes it for an edited copy of + * a built-in). */ +const TEMPLATE_HEADER_KEYS = new Set([ + 'name', + 'description', + 'category', + 'titleTemplate', + 'targetFolder', + 'targetSubpath', + 'builtinId' +]) + +/** + * Parse a note in the vault's template folder (#1024) into a NoteTemplate. + * Never throws. + * + * A plain note is the Obsidian shape: the whole file is the template, named + * after the file, and a frontmatter of its own (`tags:`, `created: {{date}}`) + * belongs to the notes it makes, so it stays in the body. A file whose + * frontmatter holds nothing but the keys a `.zennotes/templates` file uses + * (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, + * `targetSubpath`) is read the way that folder reads it instead, so a + * template moved from `.zennotes/templates` into the folder keeps its name and + * settings and does not paste them into every new note. One key of any other + * kind makes the whole frontmatter note content. + * + * A template-folder note never shadows a built-in, even with a `builtinId`: + * the edited copy of a built-in lives in `.zennotes/templates`, where Reset + * can find it. The id is the note's path inside the folder (`folder:Work/ + * Standup`), which survives renaming the folder itself. + */ +export function parseFolderTemplate( + raw: string, + sourcePath: string, + folder: string +): NoteTemplate { + const stem = filenameStem(sourcePath) + const key = templateFolderEntryKey(sourcePath, folder) ?? stem + const base = { + id: `folder:${key}`, + builtin: false, + source: 'folder' as const, + sourcePath + } + const fence = FRONTMATTER_RE.exec(raw) + const { data, body } = parseFrontmatter(raw) + const keys = Object.keys(data) + const isHeader = !!fence && keys.length > 0 && keys.every((k) => TEMPLATE_HEADER_KEYS.has(k)) + if (!isHeader) { + return { ...base, name: stem, description: '', category: 'Custom', body: raw } + } + return { + ...base, + name: data.name?.trim() || stem, + description: data.description?.trim() ?? '', + category: normalizeCategory(data.category), + body, + titleTemplate: data.titleTemplate?.trim() || undefined, + targetFolder: normalizeTargetFolder(data.targetFolder), + targetSubpath: data.targetSubpath?.trim() || undefined + } +} + +/** Where a template comes from; templates parsed before 2.80 carry no + * `source`, and their `builtin` flag decides. */ +export function templateSource(template: NoteTemplate): TemplateSource { + return template.source ?? (template.builtin ? 'builtin' : 'custom') +} + +/** + * The short label the template picker and Settings show beside a template, so + * two templates of one name can be told apart (#1024): the category for a + * built-in or a `.zennotes/templates` file, as before, and for a note in the + * template folder, the folder it sits in (`Templates`, `Templates/Work`). + */ +export function templateSourceLabel(template: NoteTemplate): string { + if (templateSource(template) !== 'folder' || !template.sourcePath) return template.category + const end = template.sourcePath.lastIndexOf('/') + return end === -1 ? template.category : template.sourcePath.slice(0, end) +} + const CATEGORY_ORDER: TemplateCategory[] = ['Engineering', 'Personal', 'Custom'] +const SOURCE_ORDER: TemplateSource[] = ['builtin', 'custom', 'folder'] /** * Combine built-in and custom templates into one display list, grouped by * category (Engineering → Personal → Custom), built-ins before custom within a - * group, then alphabetical by name. + * group, then alphabetical by name. Two templates of one name both stay + * (#1024): a built-in first, then a `.zennotes/templates` file, then a note in + * the template folder, so adding a template folder never changes which + * template an existing `:template NAME` picks. */ export function mergeTemplates( builtins: NoteTemplate[], @@ -110,7 +201,12 @@ export function mergeTemplates( return [...effectiveBuiltins, ...custom].sort((a, b) => { if (a.category !== b.category) return rank(a.category) - rank(b.category) if (isPrimary(a) !== isPrimary(b)) return isPrimary(a) ? -1 : 1 - return a.name.localeCompare(b.name) + const byName = a.name.localeCompare(b.name) + if (byName !== 0) return byName + return ( + SOURCE_ORDER.indexOf(templateSource(a)) - SOURCE_ORDER.indexOf(templateSource(b)) || + (a.sourcePath ?? '').localeCompare(b.sourcePath ?? '') + ) }) } diff --git a/packages/shared-domain/src/template-folder.test.ts b/packages/shared-domain/src/template-folder.test.ts new file mode 100644 index 000000000..834d83a8e --- /dev/null +++ b/packages/shared-domain/src/template-folder.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { + isPathInTemplateFolder, + normalizeTemplateFolder, + normalizeTemplateFolderSettings, + resolveTemplateFolder, + rewriteTemplateFolderForRename, + templateFolderEntryKey +} from './template-folder' + +describe('normalizeTemplateFolder', () => { + it('keeps a vault-relative directory path as it exists on disk', () => { + expect(normalizeTemplateFolder('Templates')).toBe('Templates') + expect(normalizeTemplateFolder('inbox/Templates')).toBe('inbox/Templates') + expect(normalizeTemplateFolder(' Meta / My Templates ')).toBe('Meta/My Templates') + expect(normalizeTemplateFolder('/Templates/')).toBe('Templates') + expect(normalizeTemplateFolder('Meta\\Templates')).toBe('Meta/Templates') + expect(normalizeTemplateFolder('Meta//Templates')).toBe('Meta/Templates') + }) + + it('refuses traversal, hidden folders, and anything that is not a path', () => { + for (const bad of [ + '', + ' ', + '/', + '.', + '..', + '../Templates', + 'Templates/..', + '.zennotes/templates', + 'Meta/.hidden', + 42, + null, + undefined, + ['Templates'], + { folder: 'Templates' } + ]) { + expect(normalizeTemplateFolder(bad)).toBeNull() + } + }) + + it('refuses a path longer than the vault layer accepts', () => { + expect(normalizeTemplateFolder('a'.repeat(512))).toBe('a'.repeat(512)) + expect(normalizeTemplateFolder('a'.repeat(513))).toBeNull() + }) +}) + +describe('normalizeTemplateFolderSettings', () => { + it('carries a valid folder and drops the section otherwise', () => { + expect(normalizeTemplateFolderSettings({ folder: ' Templates/ ' })).toEqual({ + folder: 'Templates' + }) + expect(normalizeTemplateFolderSettings({ folder: '' })).toBeUndefined() + expect(normalizeTemplateFolderSettings({ folder: '../x' })).toBeUndefined() + expect(normalizeTemplateFolderSettings({})).toBeUndefined() + expect(normalizeTemplateFolderSettings('Templates')).toBeUndefined() + expect(normalizeTemplateFolderSettings(['Templates'])).toBeUndefined() + expect(normalizeTemplateFolderSettings(null)).toBeUndefined() + }) + + it('keeps only the folder, so a stray key never reaches vault.json', () => { + expect(normalizeTemplateFolderSettings({ folder: 'Templates', extra: 1 })).toEqual({ + folder: 'Templates' + }) + }) +}) + +describe('resolveTemplateFolder', () => { + it('reads the folder off vault settings, or null when none is set', () => { + expect(resolveTemplateFolder({ templates: { folder: 'Templates' } })).toBe('Templates') + expect(resolveTemplateFolder({ templates: { folder: '.zennotes' } })).toBeNull() + expect(resolveTemplateFolder({ templates: undefined })).toBeNull() + expect(resolveTemplateFolder({})).toBeNull() + expect(resolveTemplateFolder(null)).toBeNull() + expect(resolveTemplateFolder(undefined)).toBeNull() + }) +}) + +describe('isPathInTemplateFolder', () => { + it('matches the folder and everything below it, by whole segments', () => { + expect(isPathInTemplateFolder('Templates/Meeting.md', 'Templates')).toBe(true) + expect(isPathInTemplateFolder('Templates/Work/Standup.md', 'Templates')).toBe(true) + expect(isPathInTemplateFolder('Templates Old/Meeting.md', 'Templates')).toBe(false) + expect(isPathInTemplateFolder('TemplatesX.md', 'Templates')).toBe(false) + expect(isPathInTemplateFolder('inbox/Templates/Meeting.md', 'Templates')).toBe(false) + expect(isPathInTemplateFolder('inbox/Templates/Meeting.md', 'inbox/Templates')).toBe(true) + }) + + it('is case-sensitive like the rest of the vault layer', () => { + expect(isPathInTemplateFolder('templates/Meeting.md', 'Templates')).toBe(false) + }) + + it('matches nothing without a folder', () => { + expect(isPathInTemplateFolder('Templates/Meeting.md', null)).toBe(false) + expect(isPathInTemplateFolder('Templates/Meeting.md', undefined)).toBe(false) + expect(isPathInTemplateFolder('Templates/Meeting.md', '')).toBe(false) + }) +}) + +describe('templateFolderEntryKey', () => { + it('names a template by its path inside the folder, without the extension', () => { + expect(templateFolderEntryKey('Templates/Meeting.md', 'Templates')).toBe('Meeting') + expect(templateFolderEntryKey('Templates/Work/Standup.MD', 'Templates')).toBe('Work/Standup') + expect(templateFolderEntryKey('inbox/Templates/Daily.md', 'inbox/Templates')).toBe('Daily') + }) + + it('returns null for a path outside the folder', () => { + expect(templateFolderEntryKey('Notes/Meeting.md', 'Templates')).toBeNull() + expect(templateFolderEntryKey('Templates', 'Templates')).toBeNull() + }) +}) + +describe('rewriteTemplateFolderForRename', () => { + it('follows the folder, or a folder above it, to its new path', () => { + expect(rewriteTemplateFolderForRename({ folder: 'Templates' }, 'Templates', 'Blueprints')).toEqual( + { folder: 'Blueprints' } + ) + expect( + rewriteTemplateFolderForRename({ folder: 'Meta/Templates' }, 'Meta', 'Archive/Meta') + ).toEqual({ folder: 'Archive/Meta/Templates' }) + }) + + it('leaves the setting alone when the rename is somewhere else', () => { + const settings = { folder: 'Templates' } + expect(rewriteTemplateFolderForRename(settings, 'Templates Old', 'Old')).toBe(settings) + expect(rewriteTemplateFolderForRename(settings, 'Templates/Work', 'Templates/Jobs')).toBe( + settings + ) + expect(rewriteTemplateFolderForRename(undefined, 'Templates', 'X')).toBeUndefined() + expect(rewriteTemplateFolderForRename(settings, 'Templates', 'Templates')).toBe(settings) + }) +}) diff --git a/packages/shared-domain/src/template-folder.ts b/packages/shared-domain/src/template-folder.ts new file mode 100644 index 000000000..acdff5204 --- /dev/null +++ b/packages/shared-domain/src/template-folder.ts @@ -0,0 +1,107 @@ +// The vault's template folder (#1024): a folder of ordinary notes whose every +// note is a template, the way Obsidian's core Templates plugin reads one. It +// is stored in `.zennotes/vault.json` as `templates.folder`, a vault-relative +// directory path exactly as it exists on disk (the shape of the Tasks folder +// lists), so remapped system folders (#115) need no translation and a vault +// that moves from Obsidian keeps the path it already had. +// +// Its notes are templates IN ADDITION to the built-ins and the files in +// `.zennotes/templates/`, and three things follow from what they are: +// - a template is a placeholder for the note it will make, so its `- [ ]` +// lines are not work and never feed Tasks (every scanner adds the folder +// to its excluded list through `tasksFolderListsForSettings`); +// - nobody searching their notes is looking for one, so search leaves them +// out (Search notes brings them back once the query names the folder); +// - they stay ordinary notes otherwise: they sync, they show in the sidebar, +// and they are edited in the editor like any other note. +// +// The folder is a path rather than a name matched at any depth (the Typst +// preamble folder's shape) because a template folder is one place, and an +// Obsidian vault names it by path. A segment that starts with a dot is +// refused: hidden directories are not listed as notes on any runtime, so such +// a folder would hold no templates, and `.zennotes` is the app's own. +// +// Desktop main, the MCP server and the renderer import these helpers; the Go +// server mirrors them in internal/vault/template_folder.go. Change them +// together and keep the rules byte-compatible. + +/** Longest folder path accepted, in UTF-16 code units (the Tasks lists' cap). */ +export const MAX_TEMPLATE_FOLDER_LENGTH = 512 + +/** Validate a configured template folder: forward slashes, no empty segments + * kept, no traversal, no hidden segment. Returns the cleaned path, or null + * when the value is unusable (the vault then has no template folder). */ +export function normalizeTemplateFolder(value: unknown): string | null { + if (typeof value !== 'string') return null + const parts: string[] = [] + for (const seg of value.replace(/\\/g, '/').split('/')) { + const s = seg.trim() + if (!s) continue + // Covers `.` and `..` as well as hidden directories such as `.zennotes`. + if (s.startsWith('.')) return null + parts.push(s) + } + if (parts.length === 0) return null + const joined = parts.join('/') + return joined.length > MAX_TEMPLATE_FOLDER_LENGTH ? null : joined +} + +/** + * Carry `templates` through a vault.json round trip. Returns undefined when no + * usable folder is set, so an untouched vault.json never grows an empty stub + * (the Tasks and Typst settings normalize the same way). Only the folder is + * kept; the object exists so later template settings can land beside it. + */ +export function normalizeTemplateFolderSettings(raw: unknown): { folder: string } | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const folder = normalizeTemplateFolder((raw as { folder?: unknown }).folder) + return folder ? { folder } : undefined +} + +/** The template folder in effect for a vault's settings (as read off + * vault.json, normalized or not), or null when it has none. */ +export function resolveTemplateFolder( + settings: { templates?: unknown } | null | undefined +): string | null { + const templates = settings?.templates + if (!templates || typeof templates !== 'object') return null + return normalizeTemplateFolder((templates as { folder?: unknown }).folder) +} + +/** Whether a vault-relative POSIX path is inside the template folder. A + * whole-segment prefix match, case-sensitive like the rest of the vault + * layer: `Templates` holds `Templates/a.md` and `Templates/Work/b.md`, never + * `Templates Old/c.md`. No folder matches nothing. */ +export function isPathInTemplateFolder( + relPath: string, + folder: string | null | undefined +): boolean { + if (!folder) return false + return relPath === folder || relPath.startsWith(folder + '/') +} + +/** What names a note inside the template folder: its path below the folder + * without the `.md` extension (`Work/Standup`). Stable when the folder itself + * is renamed, so it is what a template-folder template's id is built from. + * Null for a path outside the folder. */ +export function templateFolderEntryKey(relPath: string, folder: string): string | null { + if (!folder || !relPath.startsWith(folder + '/')) return null + const inside = relPath.slice(folder.length + 1) + return inside ? inside.replace(/\.md$/i, '') : null +} + +/** The setting after a folder rename or move: a template folder named by the + * renamed folder, or sitting inside it, follows it to the new path. Anything + * else comes back as it was (the same object). */ +export function rewriteTemplateFolderForRename<T extends { folder?: string }>( + templates: T | undefined, + oldRel: string, + newRel: string +): T | undefined { + if (!templates?.folder || !oldRel || !newRel || oldRel === newRel) return templates + const folder = templates.folder + if (folder !== oldRel && !folder.startsWith(oldRel + '/')) return templates + const moved = normalizeTemplateFolder(newRel + folder.slice(oldRel.length)) + if (!moved) return templates + return { ...templates, folder: moved } +} diff --git a/packages/shared-domain/src/vault-settings-conflict.test.ts b/packages/shared-domain/src/vault-settings-conflict.test.ts index 089827699..ccebdbc27 100644 --- a/packages/shared-domain/src/vault-settings-conflict.test.ts +++ b/packages/shared-domain/src/vault-settings-conflict.test.ts @@ -112,6 +112,7 @@ describe('diffVaultSettings', () => { systemFolderPaths: true, tasks: true, typstPreambles: true, + templates: true, harper: true } expect([...VAULT_SETTINGS_SECTIONS].sort()).toEqual(Object.keys(declared).sort()) diff --git a/packages/shared-domain/src/vault-settings-conflict.ts b/packages/shared-domain/src/vault-settings-conflict.ts index 14869abfb..52212c1df 100644 --- a/packages/shared-domain/src/vault-settings-conflict.ts +++ b/packages/shared-domain/src/vault-settings-conflict.ts @@ -38,6 +38,7 @@ const SECTIONS: Record<VaultSettingsSection, true> = { systemFolderPaths: true, tasks: true, typstPreambles: true, + templates: true, harper: true } From aebdeab57f4984920a679521c25d83a4f8a34742 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 11:58:57 -0500 Subject: [PATCH 38/53] Feat(tasks): a task knows which checkbox it is a subtask of (#1028) Every checkbox reached the Tasks surfaces as a record of its own, with nothing saying that ` - [ ] Build` sits under `- [ ] Ship`. The Kanban board needs that to fold subtasks into their parent's card, so the scan now sets VaultTask.parentId: the checkbox whose list item this line's list item sits directly inside. "Directly inside" is the nesting the note's own `2/5` chip counts (#512), which reads the parsed Markdown tree. The scanners only have lines, so task-nesting.ts walks them with the CommonMark rules that decide nesting: an item's content column (a one-space child of `- ` is a sibling, `10. ` needs four columns), blank lines that never close an item, a plain bullet in between that owns the task under it, a glued `- [x]glued` that is prose, blockquotes that nest by their own markers, and fences as blocks at their own indent. It does not model lazy continuation lines; one closes the item, and a task below it reads as top level. A checkbox in a task note that no other checkbox owns points at the note's file task, the subtasks parseTaskFile already promised. The relation is computed once, in parseTasksFromBody, so desktop main's scan and the phones (which call it) get it without changes. A self-hosted vault's tasks come from the Go server, which mirrors the walk on ZenNotes/znserver feat/kanban-subtasks. Both run the new task-subtasks.json contract fixture, wired into sync-contract-fixtures. The field is optional and left off a top-level task, so its record keeps its old shape, and a reader treats a parent it cannot find (an older server) as no parent. The MCP and zn copies of the task parser report to assistants and terminals, not the app, so they are unchanged. The walk costs little: about 0.15 ms more on a 1,600-line note of 1,200 tasks, and 30 ns a line of prose. --- packages/bridge-contract/fixtures/README.md | 12 ++ .../fixtures/task-subtasks.json | 152 ++++++++++++++++++ packages/bridge-contract/src/tasks.ts | 7 + packages/shared-domain/src/task-nesting.ts | 152 ++++++++++++++++++ .../shared-domain/src/task-subtasks.test.ts | 47 ++++++ packages/shared-domain/src/tasks.ts | 27 +++- tooling/scripts/sync-contract-fixtures.mjs | 1 + 7 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 packages/bridge-contract/fixtures/task-subtasks.json create mode 100644 packages/shared-domain/src/task-nesting.ts create mode 100644 packages/shared-domain/src/task-subtasks.test.ts diff --git a/packages/bridge-contract/fixtures/README.md b/packages/bridge-contract/fixtures/README.md index f6be144bf..bd6e946ce 100644 --- a/packages/bridge-contract/fixtures/README.md +++ b/packages/bridge-contract/fixtures/README.md @@ -36,6 +36,18 @@ Its client sends the edited Markdown; the server verifies storage and parsing before and after that write. The JSON and SHA-256 provenance are vendored in its `testdata` directory so `go test ./...` needs no Node or sibling checkout. +`task-subtasks.json` is which task a task is a subtask of (ZenNotes/zennotes#1028): +the `parentId` each scanned task carries, read from the note's list structure +(content columns, plain bullets that own the task under them, glued checkboxes +that are prose, blockquotes, fences) and pointing a task note's unowned +checkboxes at its file task. `expectedTasks` is the whole listing for the note, +file task first, and a task without `parentId` must carry none. The Kanban +board folds subtasks into their parent's card from these links, and its +progress bar counts what the note's `2/5` chip counts. TypeScript runs it in +`shared-domain/src/task-subtasks.test.ts` (and app-core's +`lib/kanban-subtasks.test.ts` checks the same bodies against the editor's +chip); the Go server runs it in `internal/vault/task_subtasks_contract_test.go`. + `mcp-scope.json` is scoped MCP access (ZenNotes/tui#21): what an MCP client launched with `--allow-path`, `--deny-path`, `--read-only` or `--access` may see and change. It seeds real vaults (a root-mode vault with a symbolic link to a diff --git a/packages/bridge-contract/fixtures/task-subtasks.json b/packages/bridge-contract/fixtures/task-subtasks.json new file mode 100644 index 000000000..ae362a30e --- /dev/null +++ b/packages/bridge-contract/fixtures/task-subtasks.json @@ -0,0 +1,152 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "nested checklist: each task names its direct parent", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] Launch\n - [x] Write copy\n - [ ] Ship\n - [ ] Build\n - [x] Upload\n - [-] Dropped\n- [ ] Next\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#3", "parentId": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#4", "parentId": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#5", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#6" } + ] + }, + { + "id": "a plain bullet owns the task indented under it", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n - just a note\n - [x] belongs to the note\n- Groceries\n - [ ] milk\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#2" } + ] + }, + { + "id": "a one-space sibling is not a child", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n - [x] sibling\n", + "expectedTasks": [{ "id": "inbox/Plan.md#0" }, { "id": "inbox/Plan.md#1" }] + }, + { + "id": "an ordered marker's wider content column decides nesting", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "10. [ ] parent\n - [x] separate list\n - [ ] child of the separate list\n1. [ ] numbered\n * [x] starred child\n 2) [ ] numbered child\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#2", "parentId": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#4", "parentId": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#5", "parentId": "inbox/Plan.md#3" } + ] + }, + { + "id": "blank lines keep a loose list open and a heading closes it", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n\n - [x] a\n# heading\n - [x] not a child\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2" } + ] + }, + { + "id": "indented text continues the item and a paragraph at the margin ends it", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n more about the parent\n - [ ] child\n\nA paragraph.\n - [ ] after the paragraph\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2" } + ] + }, + { + "id": "a fenced example inside the item neither counts nor ends it", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n - [x] a\n\n ~~~\n - [x] example\n ~~~\n\n - [ ] b\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2", "parentId": "inbox/Plan.md#0" } + ] + }, + { + "id": "a fence at the margin closes the list", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n```\ncode\n```\n - [ ] after the fence\n", + "expectedTasks": [{ "id": "inbox/Plan.md#0" }, { "id": "inbox/Plan.md#1" }] + }, + { + "id": "blockquoted tasks nest among themselves, not with the list around them", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "> - [ ] quoted parent\n> - [x] quoted child\n- [ ] outside\n > - [ ] quoted inside outside\n - [ ] child of outside\n> - [ ] a\n> > - [ ] deeper quote\n> - [ ] not a child of a\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#4", "parentId": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#5" }, + { "id": "inbox/Plan.md#6" }, + { "id": "inbox/Plan.md#7" } + ] + }, + { + "id": "a blank line ends a blockquote", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "> - [ ] first quote\n\n> - [ ] second quote\n", + "expectedTasks": [{ "id": "inbox/Plan.md#0" }, { "id": "inbox/Plan.md#1" }] + }, + { + "id": "a glued checkbox is prose: no parent, and it parents nothing", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n - [x]glued\n - [ ] under the glued line\n - [ ] real\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3", "parentId": "inbox/Plan.md#0" } + ] + }, + { + "id": "a tab indents to the next four-column stop", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n\t- [ ] tab child\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" } + ] + }, + { + "id": "windows line endings nest the same", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\r\n - [ ] child\r\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" } + ] + }, + { + "id": "a task note owns every checkbox that is not another checkbox's subtask", + "note": { "path": "inbox/Ship it.md", "title": "Ship it", "folder": "inbox" }, + "body": "---\ntags: [task]\nstatus: open\n---\nNotes about it.\n\n- [ ] step one\n - [x] step one a\n- plain\n - [ ] under plain\n- [x] step two\n", + "expectedTasks": [ + { "id": "inbox/Ship it.md#task" }, + { "id": "inbox/Ship it.md#0", "parentId": "inbox/Ship it.md#task" }, + { "id": "inbox/Ship it.md#1", "parentId": "inbox/Ship it.md#0" }, + { "id": "inbox/Ship it.md#2", "parentId": "inbox/Ship it.md#task" }, + { "id": "inbox/Ship it.md#3", "parentId": "inbox/Ship it.md#task" } + ] + }, + { + "id": "a note-only task note lists no checkboxes", + "note": { "path": "inbox/Ship it.md", "title": "Ship it", "folder": "inbox" }, + "body": "---\ntags: [task]\ntasks: note\n---\n- [ ] step one\n - [ ] step one a\n", + "expectedTasks": [{ "id": "inbox/Ship it.md#task" }] + } + ] +} diff --git a/packages/bridge-contract/src/tasks.ts b/packages/bridge-contract/src/tasks.ts index 3c3f171c0..6f8ee8b5e 100644 --- a/packages/bridge-contract/src/tasks.ts +++ b/packages/bridge-contract/src/tasks.ts @@ -7,6 +7,13 @@ export interface VaultTask { * tasks are added/removed above it in the same file, so this is stable * across plain content edits. */ id: string + /** The id of the task this one is a subtask of (#1028): the checkbox whose + * list item this line's list item sits directly inside, the nesting the + * note's `2/5` chip counts. A checkbox in a task note that no other + * checkbox owns points at the note's file task (`<path>#task`). Absent on + * a top-level task, and from scanners that predate it, so a reader must + * treat a parent it cannot find as no parent. */ + parentId?: string /** Vault-relative POSIX path of the note containing this task. */ sourcePath: string /** File name without extension (for display). */ diff --git a/packages/shared-domain/src/task-nesting.ts b/packages/shared-domain/src/task-nesting.ts new file mode 100644 index 000000000..16faa42a5 --- /dev/null +++ b/packages/shared-domain/src/task-nesting.ts @@ -0,0 +1,152 @@ +/** + * Which task a task line is a subtask of (#1028), read from the list + * structure Markdown gives the note. + * + * The note's own `2/5` chip (#512, app-core `task-rollup.ts`) counts a + * parent's subtasks from the parsed Markdown tree: a subtask is a task list + * item in a list that sits directly inside the parent's list item. The Tasks + * surfaces never hold that tree (they get flat task records from a scanner, + * and on a self-hosted vault that scanner is the Go server), so this walks + * the lines with the few CommonMark rules that decide nesting, and the Kanban + * card can then show the same fraction the note does: + * + * - A list item's content starts after its marker and the spaces that follow + * (one to four; more than four, or none, means one). A later line belongs + * to the item only when it is indented at least that far, so a one-space + * "child" of `- ` is a sibling, and `10. ` needs four columns. + * - Blank lines never close an item (loose lists); the next non-blank line's + * indent does. + * - A non-task bullet in between owns the task under it: it is that bullet's + * child, not the task's above, as the tree has it. + * - A glued checkbox (`- [x]glued`) is prose to Markdown, so it is a plain + * bullet here too: no parent, and nothing nests under it as a subtask. + * - Blockquotes nest by their own markers: a quoted task's parent is a task + * in the same quote, a line with fewer `>` closes the deeper quote, and a + * blank line closes them all. + * - A fence is a block at its own indent; the lines inside it are skipped + * by the caller, as the task scan already skips them. + * + * Lazy continuation (an unindented line continuing a list item's paragraph) + * is deliberately not modelled: such a line closes the item here, so a task + * below it reads as top level. Tabs advance to the next multiple of four. + * + * Mirrored by `taskNesting` in the Go server (ZenNotes/znserver, + * internal/vault/task_nesting.go). Both run the cases in + * `bridge-contract/fixtures/task-subtasks.json`; change them together. + */ + +interface OpenItem { + /** Blockquote depth the item lives at (0 outside any quote). */ + depth: number + /** Column a line must reach to stay inside the item. */ + contentCol: number + /** The task this item is, or null for a plain bullet or a glued checkbox. */ + taskId: string | null +} + +const LIST_ITEM_RE = /^([ \t]*)([-+*]|\d+[.)])([ \t]+|$)/ +const BLANK_RE = /^\s*$/ +const NO_QUOTES: number[] = [] + +function advance(col: number, ch: string): number { + return ch === '\t' ? col + 4 - (col % 4) : col + 1 +} + +/** Columns `text` spans when it starts at column `from`. */ +function columnsAfter(text: string, from = 0): number { + let col = from + for (let i = 0; i < text.length; i += 1) col = advance(col, text[i]) + return col +} + +/** The column of the first character that is not a space or tab. */ +function indentOf(text: string): number { + let col = 0 + for (let i = 0; i < text.length && (text[i] === ' ' || text[i] === '\t'); i += 1) { + col = advance(col, text[i]) + } + return col +} + +/** The `>` markers in front of a line, each marker's column inside the level + * it opens from, and what is left after them (one space or tab after each + * `>` belongs to the marker). */ +function splitQuotes(line: string): { markers: number[]; rest: string } { + let markers = NO_QUOTES + let pos = 0 + for (;;) { + let i = pos + let col = 0 + while (i < line.length && (line[i] === ' ' || line[i] === '\t')) { + col = advance(col, line[i]) + i += 1 + } + if (line[i] !== '>') break + if (markers === NO_QUOTES) markers = [] + markers.push(col) + i += 1 + if (line[i] === ' ' || line[i] === '\t') i += 1 + pos = i + } + return { markers, rest: pos === 0 ? line : line.slice(pos) } +} + +/** True when the checkbox Markdown would draw: `]` then whitespace or the end + * of the line. `tail` is TASK_LINE_RE's group 3, which starts at the `]`. */ +export function isMarkdownCheckbox(tail: string): boolean { + return /^\](?:[ \t]|$)/.test(tail) +} + +export interface TaskNesting { + /** + * Feed every line outside a fence, in order (an opening fence line too, + * the lines inside it not). `taskId` is set for a task line Markdown would + * draw as a checkbox. Returns the id of the task this line's list item sits + * directly inside, or null. + */ + visit(line: string, taskId: string | null): string | null +} + +export function createTaskNesting(): TaskNesting { + const open: OpenItem[] = [] + + const truncate = (at: number): void => { + if (at >= 0 && at < open.length) open.length = at + } + + return { + visit(line, taskId) { + const { markers, rest } = splitQuotes(line) + const depth = markers.length + + // Quotes this line is not inside are over. + while (open.length > 0 && open[open.length - 1].depth > depth) open.pop() + // Each `>` closes the items of the level it opens from that it is not + // indented into: `- [ ] a` then `> quote` at the margin ends `a`. + for (let d = 0; d < depth; d += 1) { + truncate(open.findIndex((item) => item.depth === d && item.contentCol > markers[d])) + } + if (BLANK_RE.test(rest)) return null + + const item = rest.match(LIST_ITEM_RE) + const col = item ? columnsAfter(item[1]) : indentOf(rest) + while ( + open.length > 0 && + open[open.length - 1].depth === depth && + open[open.length - 1].contentCol > col + ) { + open.pop() + } + if (!item) return null + + const top = open[open.length - 1] + const parent = top && top.depth === depth ? top.taskId : null + const afterMarker = col + item[2].length + const afterSpaces = columnsAfter(item[3], afterMarker) + const spaces = afterSpaces - afterMarker + const contentCol = spaces === 0 || spaces > 4 ? afterMarker + 1 : afterSpaces + open.push({ depth, contentCol, taskId }) + return taskId === null ? null : parent + } + } +} diff --git a/packages/shared-domain/src/task-subtasks.test.ts b/packages/shared-domain/src/task-subtasks.test.ts new file mode 100644 index 000000000..f0e11028a --- /dev/null +++ b/packages/shared-domain/src/task-subtasks.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import fixtures from '../../bridge-contract/fixtures/task-subtasks.json' +import { parseTaskFile, parseTasksFromBody, type ParseTasksContext } from './tasks' + +/** What every scanner hands the Tasks surfaces for one note: the file task + * first, then the checkboxes (desktop main, the phones, the Go server). */ +function scanNote(body: string, note: ParseTasksContext) { + const fileTask = parseTaskFile(body, note) + const inline = parseTasksFromBody(body, note) + return fileTask ? [fileTask, ...inline] : inline +} + +describe('subtask parents, shared with the Go server (#1028)', () => { + it.each(fixtures.cases)('$id', (fixture) => { + const tasks = scanNote(fixture.body, fixture.note as ParseTasksContext) + expect(tasks.map((task) => ({ id: task.id, parentId: task.parentId }))).toEqual( + fixture.expectedTasks.map((task) => ({ + id: task.id, + parentId: 'parentId' in task ? task.parentId : undefined + })) + ) + }) + + it('leaves the key off a task without a parent, so its record is unchanged', () => { + const [top, child] = parseTasksFromBody('- [ ] top\n - [ ] child', { + path: 'inbox/a.md', + title: 'a', + folder: 'inbox' + }) + expect('parentId' in top).toBe(false) + expect(child.parentId).toBe('inbox/a.md#0') + }) + + it('points at the file task even when it is scanned on its own', () => { + const body = '---\ntags: [task]\n---\n- [ ] step' + const [step] = parseTasksFromBody(body, { path: 'inbox/t.md', title: 't', folder: 'inbox' }) + expect(step.parentId).toBe('inbox/t.md#task') + }) + + it('keeps the file-task parent when an excluded note is scanned on purpose', () => { + const body = '---\ntags: [task]\ntasks: false\n---\n- [ ] step' + const ctx = { path: 'inbox/t.md', title: 't', folder: 'inbox' } as const + expect(parseTasksFromBody(body, ctx)).toEqual([]) + const [step] = parseTasksFromBody(body, ctx, { includeExcluded: true }) + expect(step.parentId).toBe('inbox/t.md#task') + }) +}) diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index f737d4270..1ddb62777 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -2,6 +2,7 @@ import type { TaskPriority, VaultTask } from '@zennotes/bridge-contract/tasks' import { parseFrontmatterFields, unquote } from './frontmatter' import type { NoteFolder } from './ipc' import { FENCE_RE, TASK_LINE_RE } from './tasklists' +import { createTaskNesting, isMarkdownCheckbox } from './task-nesting' /** * Virtual path used to identify the vault-wide Tasks view as a tab in the @@ -259,6 +260,10 @@ export function parseTasksFromBody( let taskIndex = 0 let inFence = false let fenceMarker: string | null = null + const nesting = createTaskNesting() + // A task note's checkboxes are its subtasks (see parseTaskFile): the ones + // no other checkbox owns hang off the note itself. Read once, on demand. + let fileTaskId: string | null | undefined for (let i = 0; i < lines.length; i++) { const line = lines[i] @@ -269,6 +274,7 @@ export function parseTasksFromBody( if (!inFence) { inFence = true fenceMarker = marker + nesting.visit(line, null) } else if (marker === fenceMarker) { inFence = false fenceMarker = null @@ -278,7 +284,17 @@ export function parseTasksFromBody( if (inFence) continue const taskMatch = line.match(TASK_LINE_RE) - if (!taskMatch) continue + if (!taskMatch) { + nesting.visit(line, null) + continue + } + + const id = `${ctx.path}#${taskIndex}` + const nestedIn = nesting.visit(line, isMarkdownCheckbox(taskMatch[3]) ? id : null) + if (!nestedIn && fileTaskId === undefined) { + fileTaskId = parseTaskFile(normalized, ctx, opts) ? `${ctx.path}#task` : null + } + const parentId = nestedIn ?? fileTaskId ?? undefined const checkedChar = taskMatch[2] const tail = taskMatch[3].replace(/^\]/, '') // drop the closing `]` of the checkbox @@ -289,8 +305,8 @@ export function parseTasksFromBody( const tokens = extractTokens(tail) - tasks.push({ - id: `${ctx.path}#${taskIndex}`, + const task: VaultTask = { + id, sourcePath: ctx.path, noteTitle: ctx.title, noteFolder: ctx.folder, @@ -313,7 +329,10 @@ export function parseTasksFromBody( : tokens.fields, status: tokens.fields.status ?? defaults.status, tags: tokens.tags - }) + } + // Set only when there is one, so a top-level record keeps its old shape. + if (parentId) task.parentId = parentId + tasks.push(task) taskIndex += 1 } diff --git a/tooling/scripts/sync-contract-fixtures.mjs b/tooling/scripts/sync-contract-fixtures.mjs index c00be881a..ded67fb7e 100644 --- a/tooling/scripts/sync-contract-fixtures.mjs +++ b/tooling/scripts/sync-contract-fixtures.mjs @@ -29,6 +29,7 @@ if (!dirs.server && !dirs.tui) { } const fixtures = [ ['task-roundtrip.json', 'server', 'vault'], + ['task-subtasks.json', 'server', 'vault'], ['self-hosted-http.json', 'server', 'httpserver'], ['mcp-scope.json', 'tui', 'mcp'] ] From 44a9946e221d773720d928f8fb1aeb164116ca31 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 11:59:10 -0500 Subject: [PATCH 39/53] Feat(kanban): subtasks fold into their card, with a progress bar (#1028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the board every indented subtask was a card of its own, scattered by its own date and state, so a parent with five steps read as six unrelated cards and nothing said how far along it was. Now a task's subtasks ride inside its card (from the parentId links the scan sets), and the card shows a thin bar with `3/5 · 60%`. The bar counts what the note's `2/5` chip counts, through the same task-rollup helpers: direct subtasks only, cancelled and forwarded ones out of the total, in-progress ones not yet done. So a parent reads the same fraction in the note and on the board, and a test runs a corpus of bodies through both the editor's chip and the card and wants identical numbers. The percentage rounds down, so 100% means done. A task note's card counts its body's checkboxes. The lines stay folded, so a busy board keeps its cards short; the bar row opens and folds them, deeper subtasks indented under their parent. Tab (and Shift+Tab) steps the board's cursor onto a card's subtasks, showing the lines while it is there; Space, Enter, and in Vim mode x, i, c and @ then act on that subtask, Escape returns to the card, and any card move drops back to it. Tab is not a letter, so it works with Vim off, like the calendar's Tab through a day; no letter does anything new with Vim off. Checking a subtask writes its line in the note through the same queued writer as the list. Editing, adding, deleting and reordering stay in the note: Enter or a click on a subtask's text opens the note at its line. Only cards are placed in columns, so a subtask's own due date, priority or field no longer puts it on the board, and moving a card leaves its subtasks' lines alone. The filter keeps a card when one of its subtasks matches, so a query that found a subtask still finds it. The Tasks list and the calendars are unchanged and show every subtask. Two small things from verifying in the built app. TasksView took every Escape first whenever the board had re-registered its key handler after it, so it now yields while a subtask is under the cursor. And the board's Group by and Order labels no longer wrap inside themselves when the header is tight. The header's key hint line is left as it was (a Keys popover is replacing it on another branch). How to test locally: build (`cd apps/desktop && npx electron-vite build`), launch, open Tasks, switch to Kanban (3 in Vim mode). In a note, write `- [ ] Launch` with ` - [x] a`, ` - [ ] b`, ` - [ ] c`. Before: four cards. After: one card reading 1/2 · 50%; click the bar to show the lines, check `b` and it reads 2/2 · 100% in green, and the note says ` - [x] b`. With the card focused, Tab, Tab, Tab lands on `c`; Space checks it and the bar stays 2/2. --- .../app-core/src/components/TasksKanban.tsx | 351 +++++++++++++++++- .../app-core/src/components/TasksView.tsx | 4 + packages/app-core/src/lib/help.ts | 12 +- .../app-core/src/lib/kanban-subtasks.test.ts | 203 ++++++++++ packages/app-core/src/lib/kanban-subtasks.ts | 153 ++++++++ 5 files changed, 701 insertions(+), 22 deletions(-) create mode 100644 packages/app-core/src/lib/kanban-subtasks.test.ts create mode 100644 packages/app-core/src/lib/kanban-subtasks.ts diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 32ef16ddd..af705b75c 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -25,6 +25,14 @@ import { dropMutationsFor } from '../lib/task-column-mutations' * j/k — move between cards within the active column * Enter — open the card's source note * Space / x — toggle the checkbox on the focused card + * Tab / Shift+Tab: step through the focused card's subtasks (#1028), + * so Space / x / Enter / i / c / @ act on that subtask; Escape + * or any card move returns to the card. Universal, like the + * calendar's Tab through a day, since it is not a letter. + * + * Subtasks: a task's indented subtasks fold into its card (listed under the + * title, checkable, with a progress bar) instead of standing as cards of + * their own; see `lib/kanban-subtasks.ts`. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' @@ -45,11 +53,20 @@ import { type KanbanGroupBy, type TaskMutation } from '../store' -import { filterTasks, type NoteTagsByPath } from '../lib/tasks-filter' +import type { NoteTagsByPath } from '../lib/tasks-filter' +import { + cardsMatchingFilter, + foldSubtasks, + nextSubtaskFocus, + progressPercent, + subtaskProgress, + type CardSubtask +} from '../lib/kanban-subtasks' +import { rollupLabel, type TaskRollup } from '../lib/task-rollup' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' -import { ArrowUpRightIcon, PencilIcon } from './icons' +import { ArrowUpRightIcon, ChevronRightIcon, PencilIcon } from './icons' import { InlineMarkdown } from '../lib/inline-markdown' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { TaskStateBox } from './TaskStateBox' @@ -93,6 +110,8 @@ function columnAccent(id: string): string | null { export { dropMutationsFor } from '../lib/task-column-mutations' +const NO_SUBTASKS: readonly CardSubtask[] = [] + export interface Column { id: string label: string @@ -718,6 +737,22 @@ export function TasksKanban({ null ) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) + // The subtask Tab stepped onto inside the focused card (#1028), by id so a + // rescan that rebuilds the records keeps it; null is the card itself. + const [subFocusId, setSubFocusId] = useState<string | null>(null) + // Cards whose subtask list was opened with a click. Folded by default so a + // busy board keeps its cards short: the bar is the at-a-glance part, and a + // card's list also shows while Tab has the cursor inside it. Session-only, + // like the list's collapsed groups. + const [openSubtaskCards, setOpenSubtaskCards] = useState<ReadonlySet<string>>(() => new Set()) + const toggleSubtaskList = useCallback((cardId: string) => { + setOpenSubtaskCards((current) => { + const next = new Set(current) + if (next.has(cardId)) next.delete(cardId) + else next.add(cardId) + return next + }) + }, []) const latestTasksRef = useRef(tasks) const displayTasksRef = useRef(tasks) const pendingTaskMovesRef = useRef(new Map<string, VaultTask>()) @@ -806,11 +841,15 @@ export function TasksKanban({ setDisplayTasks(mergedTasks) }, [mergeTasksWithPendingMoves, tasks]) + // Subtasks ride inside their top task's card (#1028); only cards are placed + // in columns, so a subtask's own date, priority or field moves nothing. + const folded = useMemo(() => foldSubtasks(displayTasks), [displayTasks]) + const fullColumns = useMemo( () => { const built = buildColumns( groupBy, - displayTasks, + folded.cards, today, kanbanStatuses, showArchivedTasks, @@ -840,7 +879,7 @@ export function TasksKanban({ cardSort, columnOrderVersion, groupBy, - displayTasks, + folded, kanbanColumnOrder, kanbanColumnTitles, kanbanStatuses, @@ -859,7 +898,9 @@ export function TasksKanban({ const columns = useMemo(() => { if (!filterQuery) return fullColumns return fullColumns.map((column) => { - const visible = filterTasks(column.tasks, filterQuery, noteTags) + // A card stays when one of its subtasks matches: that subtask used to + // be a card of its own, and the query should still find it. + const visible = cardsMatchingFilter(column.tasks, folded.subtasksByCard, filterQuery, noteTags) if (visible.length === column.tasks.length) return column let badge = column.badge if (badge?.kind === 'overdue') { @@ -868,7 +909,7 @@ export function TasksKanban({ } return { ...column, tasks: visible, badge } }) - }, [fullColumns, filterQuery, noteTags, today]) + }, [fullColumns, filterQuery, folded.subtasksByCard, noteTags, today]) columnsRef.current = columns fullColumnsRef.current = fullColumns @@ -1650,6 +1691,15 @@ export function TasksKanban({ ? Math.min(cardIdx, Math.max(0, focusedColumn.tasks.length - 1)) : 0 const focusedTask = focusedColumn?.tasks[safeCardIdx] + const focusedSubtasks = + (focusedTask && folded.subtasksByCard.get(focusedTask.id)) || NO_SUBTASKS + const focusedSubIdx = subFocusId + ? focusedSubtasks.findIndex((subtask) => subtask.task.id === subFocusId) + : -1 + const focusedSubtask = focusedSubIdx >= 0 ? focusedSubtasks[focusedSubIdx].task : null + // What Space / x / Enter / i / c / @ act on: the subtask Tab stepped onto, + // else the card. Shift+H/L always move the card. + const actionTask = focusedSubtask ?? focusedTask // Switching the order keeps the cursor on its card, wherever the new order // puts it: the rebuild is flushed first, the way a move is. (#889) @@ -1752,22 +1802,26 @@ export function TasksKanban({ consume() setColIdx((i) => Math.max(0, i - 1)) setCardIdx(0) + setSubFocusId(null) return case 'l': case 'ArrowRight': consume() setColIdx((i) => Math.min(columns.length - 1, i + 1)) setCardIdx(0) + setSubFocusId(null) return case 'H': if (dndEnabled && focusedTask) { consume() + setSubFocusId(null) moveFocusedCard(-1) } return case 'L': if (dndEnabled && focusedTask) { consume() + setSubFocusId(null) moveFocusedCard(1) } return @@ -1789,23 +1843,44 @@ export function TasksKanban({ setCardIdx((i) => focusedColumn ? Math.min(focusedColumn.tasks.length - 1, i + 1) : 0 ) + setSubFocusId(null) return case 'k': case 'ArrowUp': consume() setCardIdx((i) => Math.max(0, i - 1)) + setSubFocusId(null) + return + // Into the focused card's subtasks and back out (#1028). A card + // without subtasks leaves Tab to the page, as before. + case 'Tab': + if (focusedTask && focusedSubtasks.length > 0) { + consume() + const next = nextSubtaskFocus( + focusedSubIdx >= 0 ? focusedSubIdx : null, + focusedSubtasks.length, + e.shiftKey ? -1 : 1 + ) + setSubFocusId(next == null ? null : focusedSubtasks[next].task.id) + } + return + case 'Escape': + if (focusedSubtask) { + consume() + setSubFocusId(null) + } return case 'Enter': - if (focusedTask) { + if (actionTask) { consume() - onOpenTask(focusedTask) + onOpenTask(actionTask) } return case ' ': case 'x': - if (focusedTask) { + if (actionTask) { consume() - onToggleTask(focusedTask) + onToggleTask(actionTask) } return // Same task states the list and the right-click menu offer, so a card @@ -1813,22 +1888,22 @@ export function TasksKanban({ // shortcuts only exist in Vim mode; the menu hides its key hints in // step, so it never advertises a key that would do nothing here. case 'i': - if (vimMode && focusedTask) { + if (vimMode && actionTask) { consume() - void startTaskFromList(focusedTask) + void startTaskFromList(actionTask) } return case 'c': - if (vimMode && focusedTask) { + if (vimMode && actionTask) { consume() - void cancelTaskFromList(focusedTask) + void cancelTaskFromList(actionTask) } return // Any due date from the calendar `@date` opens (#991). case '@': - if (vimMode && focusedTask) { + if (vimMode && actionTask) { consume() - void chooseTaskDueDate(focusedTask) + void chooseTaskDueDate(actionTask) } return default: @@ -1842,8 +1917,12 @@ export function TasksKanban({ clearGroupByPending, columns.length, cycleGroupBy, + actionTask, dndEnabled, focusedColumn, + focusedSubIdx, + focusedSubtask, + focusedSubtasks, focusedTask, moveFocusedCard, moveFocusedColumn, @@ -1856,7 +1935,7 @@ export function TasksKanban({ <div className="flex min-h-0 flex-1 flex-col"> <div className="flex shrink-0 items-center justify-between gap-2 border-b border-paper-300/45 px-3 py-2"> <div className="flex items-center gap-1 text-xs text-current/60"> - <span>Group by</span> + <span className="whitespace-nowrap">Group by</span> <select value={groupBy} onChange={(e) => setGroupBy(e.target.value as KanbanGroupBy)} @@ -1883,7 +1962,7 @@ export function TasksKanban({ {kanbanFolderRoot ? `inside ${kanbanFolderRoot}` : 'each note’s folder'} </button> )} - <span className="ml-2">Order</span> + <span className="ml-2 whitespace-nowrap">Order</span> <select data-kanban-card-sort value={cardSort} @@ -2042,6 +2121,7 @@ export function TasksKanban({ {column.tasks.map((task, ti) => { const isFocused = isColumnFocused && ti === safeCardIdx const isDragging = draggingId === task.id + const subtasks = folded.subtasksByCard.get(task.id) ?? NO_SUBTASKS return ( <TaskCard key={task.id} @@ -2055,6 +2135,7 @@ export function TasksKanban({ onClickRow={() => { setColIdx(ci) setCardIdx(ti) + setSubFocusId(null) }} onOpen={() => onOpenTask(task)} shouldSuppressClick={shouldSuppressCardClick} @@ -2062,6 +2143,25 @@ export function TasksKanban({ onPointerDown={(e) => beginPointerDrag(task, e)} onContextMenu={openTaskMenu} toggleKey={vimMode ? 'x' : null} + subtasks={subtasks} + progress={subtaskProgress(task, subtasks)} + subtasksOpen={ + openSubtaskCards.has(task.id) || (isFocused && focusedSubtask != null) + } + onToggleSubtasks={() => { + setColIdx(ci) + setCardIdx(ti) + setSubFocusId(null) + toggleSubtaskList(task.id) + }} + focusedSubtaskId={isFocused ? (focusedSubtask?.id ?? null) : null} + onFocusSubtask={(subtask) => { + setColIdx(ci) + setCardIdx(ti) + setSubFocusId(subtask.id) + }} + onToggleSubtask={onToggleTask} + onOpenSubtask={onOpenTask} /> ) })} @@ -2101,6 +2201,12 @@ export function TasksKanban({ shouldSuppressClick={() => true} onToggle={() => {}} onPointerDown={() => {}} + subtasks={folded.subtasksByCard.get(dragPreview.task.id) ?? NO_SUBTASKS} + subtasksOpen={openSubtaskCards.has(dragPreview.task.id)} + progress={subtaskProgress( + dragPreview.task, + folded.subtasksByCard.get(dragPreview.task.id) ?? NO_SUBTASKS + )} /> </div> )} @@ -2134,6 +2240,185 @@ interface CardProps { onContextMenu?: (e: React.MouseEvent, task: VaultTask) => void /** The key the checkbox tooltip names; none with Vim mode off. */ toggleKey?: string | null + /** Subtasks folded into this card, in note order (#1028). */ + subtasks?: readonly CardSubtask[] + /** Done over total for the direct subtasks; null hides the bar. */ + progress?: TaskRollup | null + /** Whether the subtask lines show; folded, only the summary row does. */ + subtasksOpen?: boolean + /** Opens or folds the subtask lines (the summary row's click). */ + onToggleSubtasks?: () => void + /** The subtask Tab stepped onto, drawn as the cursor. */ + focusedSubtaskId?: string | null + /** Puts the keyboard cursor on a subtask a click or right-click reached. */ + onFocusSubtask?: (task: VaultTask) => void + onToggleSubtask?: (task: VaultTask) => void + /** Opens the note at the subtask's line, where its text is edited. */ + onOpenSubtask?: (task: VaultTask) => void +} + +/** The card's subtask summary, always shown: a thin bar and `3/5 · 60%` for + * the direct subtasks, worded like the note's own `3/5` chip for hover and + * screen readers (or just the count when none of them counts toward + * progress). It is also the button that opens and folds the lines. */ +function SubtaskSummary({ + progress, + count, + open, + onToggle +}: { + progress: TaskRollup | null + count: number + open: boolean + onToggle?: () => void +}): JSX.Element { + const percent = progress ? progressPercent(progress) : 0 + const complete = !!progress && progress.done === progress.total + const label = progress + ? rollupLabel(progress) + : `${count} subtask${count === 1 ? '' : 's'}` + return ( + <button + type="button" + data-kanban-progress + aria-expanded={open} + aria-label={label} + title={label} + // The board's own cursor reaches the lines (Tab); a DOM focus ring left + // here by a click would draw a second cursor. + tabIndex={-1} + draggable={false} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + onToggle?.() + }} + className="group/summary mt-1.5 flex w-full items-center gap-2 rounded text-left outline-none" + > + <span className="flex w-4 shrink-0 justify-center text-current/40 group-hover/summary:text-current/80"> + <ChevronRightIcon + width={12} + height={12} + className={['transition-transform duration-150', open ? 'rotate-90' : ''].join(' ')} + /> + </span> + {progress && ( + <span + role="progressbar" + aria-label={label} + aria-valuemin={0} + aria-valuemax={progress.total} + aria-valuenow={progress.done} + className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-paper-300/60" + > + <span + className={[ + 'block h-full rounded-full transition-all duration-200', + complete ? 'bg-success' : 'bg-accent' + ].join(' ')} + style={{ width: `${percent}%` }} + /> + </span> + )} + <span + className={[ + 'shrink-0 text-2xs tabular-nums', + complete ? 'text-success' : 'text-current/55' + ].join(' ')} + > + {progress ? `${progress.done}/${progress.total} · ${percent}%` : label} + </span> + </button> + ) +} + +interface SubtaskListProps { + subtasks: readonly CardSubtask[] + focusedSubtaskId: string | null + shouldSuppressClick: () => boolean + toggleKey: string | null + onFocusSubtask?: (task: VaultTask) => void + onToggleSubtask?: (task: VaultTask) => void + onOpenSubtask?: (task: VaultTask) => void + onContextMenu?: (e: React.MouseEvent, task: VaultTask) => void +} + +/** The card's subtasks, deeper ones indented under their own parent. A click + * on the box checks the line in its note; a click on the text opens the note + * at that line. A drag that starts on a line still moves the whole card. */ +function SubtaskList({ + subtasks, + focusedSubtaskId, + shouldSuppressClick, + toggleKey, + onFocusSubtask, + onToggleSubtask, + onOpenSubtask, + onContextMenu +}: SubtaskListProps): JSX.Element { + return ( + <ul data-kanban-subtasks aria-label="Subtasks" className="mt-1 space-y-px pl-5"> + {subtasks.map(({ task: subtask, depth }) => { + const focused = subtask.id === focusedSubtaskId + const closed = subtask.checked || subtask.cancelled + return ( + <li + key={subtask.id} + data-kanban-subtask-id={subtask.id} + data-kanban-subtask-focused={focused ? 'true' : undefined} + style={depth > 1 ? { marginLeft: `${depth - 1}rem` } : undefined} + onClick={(e) => { + e.stopPropagation() + if (shouldSuppressClick()) return + onFocusSubtask?.(subtask) + onOpenSubtask?.(subtask) + }} + onContextMenu={ + onContextMenu + ? (e) => { + e.stopPropagation() + onFocusSubtask?.(subtask) + onContextMenu(e, subtask) + } + : undefined + } + className={[ + 'flex items-start gap-1.5 rounded px-1 py-0.5 text-xs', + focused ? 'bg-paper-200/80 ring-1 ring-accent/50' : 'hover:bg-paper-200/50' + ].join(' ')} + > + <TaskStateBox + task={subtask} + onToggle={() => { + onFocusSubtask?.(subtask) + onToggleSubtask?.(subtask) + }} + idleClassName="border border-paper-400/70 hover:bg-paper-200/80" + className="mt-0" + stopPointerEvents + toggleKey={toggleKey} + /> + <span + className={[ + 'min-w-0 flex-1 break-words', + closed + ? 'text-current/45 line-through' + : subtask.forwarded + ? 'text-current/45' + : 'text-current/80' + ].join(' ')} + > + {subtask.content ? <InlineMarkdown text={subtask.content} /> : '(empty task)'} + </span> + {subtask.due && ( + <span className="shrink-0 text-2xs text-current/45">{formatDue(subtask.due)}</span> + )} + </li> + ) + })} + </ul> + ) } function formatDue(iso: string | undefined): string { @@ -2157,7 +2442,15 @@ function TaskCard({ onToggle, onPointerDown, onContextMenu, - toggleKey = null + toggleKey = null, + subtasks = NO_SUBTASKS, + progress = null, + subtasksOpen = false, + onToggleSubtasks, + focusedSubtaskId = null, + onFocusSubtask, + onToggleSubtask, + onOpenSubtask }: CardProps): JSX.Element { return ( <div @@ -2244,6 +2537,26 @@ function TaskCard({ <ArrowUpRightIcon width={12} height={12} /> </button> </div> + {subtasks.length > 0 && ( + <SubtaskSummary + progress={progress} + count={subtasks.length} + open={subtasksOpen} + onToggle={onToggleSubtasks} + /> + )} + {subtasksOpen && subtasks.length > 0 && ( + <SubtaskList + subtasks={subtasks} + focusedSubtaskId={focusedSubtaskId} + shouldSuppressClick={shouldSuppressClick} + toggleKey={toggleKey} + onFocusSubtask={onFocusSubtask} + onToggleSubtask={onToggleSubtask} + onOpenSubtask={onOpenSubtask} + onContextMenu={onContextMenu} + /> + )} <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-6 text-xs text-current/50"> <span className="max-w-full truncate">{task.noteTitle}</span> <CloudTaskConflictIndicator path={task.sourcePath} /> diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 285d3cc7f..ee8e73877 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -678,6 +678,10 @@ export function TasksView(): JSX.Element { } if (key === 'Escape') { + // On the board, Esc first takes the cursor off a card's subtask and + // back to the card (#1028). The board's handler does that; this one + // runs first whenever the board re-registered after it, so it yields. + if (rootRef.current?.querySelector('[data-kanban-subtask-focused]')) return // Tasks is a tab like a note tab — Esc clears an active filter but must // never close the tab (other tabs don't close on Esc). Close with :q, // the header ✕, or ⌘W. (#151) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 6f4a32d97..8713aba3c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -377,7 +377,12 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The Tasks Kanban board, custom statuses, and any field', body: - 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / No due date / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between No due date and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date (or on No due date to reopen it without one: that drop clears its `due:`, and a daily-note task, whose date is its note’s, cannot take it), and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Finished and cancelled tasks leave the boards, with one exception: a task note whose `status:` names one of your Kanban statuses stays in that column on the Custom status board, so a `done` column holds the task notes you finished, still checked. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' + 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / No due date / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between No due date and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date (or on No due date to reopen it without one: that drop clears its `due:`, and a daily-note task, whose date is its note’s, cannot take it), and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Finished and cancelled tasks leave the boards, with one exception: a task note whose `status:` names one of your Kanban statuses stays in that column on the Custom status board, so a `done` column holds the task notes you finished, still checked. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open (`Tab` steps onto a card’s subtasks). Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' + }, + { + title: 'Subtasks on the Kanban board', + body: + 'On the Kanban board, a task’s indented subtasks fold into its card. The card shows a bar with `3/5 · 60%`, counted like the `3/5` chip in the note: direct subtasks only, cancelled and forwarded ones left out, in-progress ones not yet done. Click the bar to show or hide the subtask lines; click a box to check that subtask off in its note. From the keyboard, `Tab` steps onto a card’s subtasks (`Shift+Tab` back, `Esc` to the card), `Space` (or `x` in Vim mode) toggles one, and `Enter` opens the note at its line, where you edit, add or reorder subtasks. A task note’s checkboxes are its card’s subtasks. Only cards sit in columns: a subtask’s own date or fields never place it, the filter finds a card by its subtasks, and the Tasks list and calendars still show every subtask.', }, { title: 'Filter the Tasks views to one project', @@ -407,7 +412,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Subtask progress on the parent', body: - 'A parent task with subtasks shows how far along they are: a `2/5` chip sits at the end of the parent line in the editor and the reading view, turning green when every child is done. The count is derived while rendering and is never written into the markdown, so it stays correct no matter which app last edited the file. Direct children only, one nesting level down, and a grandchild rolls into its own parent. Cancelled (`- [-]`) and forwarded (`- [>]`) children leave the count entirely; an in-progress child (`- [/]`) counts as not yet done.' + 'A parent task with subtasks shows how far along they are: a `2/5` chip sits at the end of the parent line in the editor and the reading view, turning green when every child is done. The count is derived while rendering and is never written into the markdown, so it stays correct no matter which app last edited the file. Direct children only, one nesting level down, and a grandchild rolls into its own parent. Cancelled (`- [-]`) and forwarded (`- [>]`) children leave the count entirely; an in-progress child (`- [/]`) counts as not yet done. The parent’s Kanban card shows the same count as a bar.' }, { title: 'Style completed tasks', @@ -774,7 +779,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'tasks-tags-trash', title: 'Tasks, tags, and trash views', - description: 'These virtual views each run their own keyboard loop in the main pane, and the Vim leader works here too (for example Space h for hint mode). The single keys below belong to Vim mode: with it off only the arrows, Enter and Escape stay live everywhere, plus Shift+J/K to reorder in the list, Tab to pick a task on the calendar and Space to toggle one on the board or the calendar; everything else sits in the right-click menu. An action you rebind to a chord with Ctrl, Alt or Cmd works with Vim mode on or off.', + description: 'These virtual views each run their own keyboard loop in the main pane, and the Vim leader works here too (for example Space h for hint mode). The single keys below belong to Vim mode: with it off only the arrows, Enter and Escape stay live everywhere, plus Shift+J/K to reorder in the list, Tab to pick a task on the calendar or to step onto a card’s subtasks on the board, and Space to toggle one on the board or the calendar; everything else sits in the right-click menu. An action you rebind to a chord with Ctrl, Alt or Cmd works with Vim mode on or off.', items: [ { keys: 'j / k', action: 'Move row cursor', detail: 'Step through task rows, tagged notes and their tagged blocks, or trashed notes.' }, { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Move to the first or last visible result.' }, @@ -785,6 +790,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'i', action: 'Mark task in progress', detail: 'Tasks list only: mark the selected task as started (`- [/]`), or set it back to open. In-progress tasks keep their group and their day on the calendar, so the row keeps its place.' }, { keys: 'c', action: 'Cancel task', detail: 'Tasks list only: mark the selected task as intentionally abandoned (`- [-]`), or un-cancel it. Cancelled tasks live under a “Cancelled” group, out of Today and Done.' }, { keys: '@', action: 'Choose due date', detail: 'Tasks list, board, and calendar: give the selected task any due date from the month calendar that typing `@date` opens in a note, the same as Choose a date… in its right-click menu. It starts on the task’s due date (today without one); arrows move, Enter sets the day, or type a date.' }, + { keys: 'Tab / Shift+Tab', action: 'Step through a card’s subtasks', detail: 'Kanban board: step onto a card’s subtasks and back. Space (or x), Enter, i, c and @ act on that subtask; Esc returns to the card. Works with Vim mode on or off.' }, { keys: 'K / J', action: 'Move task up / down', detail: 'Tasks list only: reorder the selected task within its group. Works with Vim mode on or off, in Note order (see `s`).' }, { keys: 's', action: 'Order the list', detail: 'Tasks list only: switch the order inside each group between the notes’ line order and priority (high, then medium, low, and none, ties by due date). Same as the Order menu above the list, `:order priority` / `:order note`, and `tasks_list_order` under `[view]` in config.toml.' }, { keys: 'r', action: 'Restore trashed note', detail: 'Trash view only: restore the selected trashed note.' }, diff --git a/packages/app-core/src/lib/kanban-subtasks.test.ts b/packages/app-core/src/lib/kanban-subtasks.test.ts new file mode 100644 index 000000000..c2689abff --- /dev/null +++ b/packages/app-core/src/lib/kanban-subtasks.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { ensureSyntaxTree } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { markdown } from '@codemirror/lang-markdown' +import { describe, expect, it } from 'vitest' +import type { VaultTask } from '@shared/tasks' +import { parseTaskFile, parseTasksFromBody } from '@shared/tasks' +import fixtures from '../../../bridge-contract/fixtures/task-subtasks.json' +import { computeTaskRollups } from './cm-task-rollup' +import type { TaskRollup } from './task-rollup' +import { + cardsMatchingFilter, + foldSubtasks, + nextSubtaskFocus, + progressPercent, + subtaskProgress +} from './kanban-subtasks' + +const ctx = { path: 'inbox/Plan.md', title: 'Plan', folder: 'inbox' } as const + +function parse(lines: string[]): VaultTask[] { + return parseTasksFromBody(lines.join('\n'), ctx) +} + +function contents(list: { task: VaultTask; depth: number }[] | undefined): string[] { + return (list ?? []).map(({ task, depth }) => `${depth}:${task.content}`) +} + +describe('foldSubtasks (#1028)', () => { + it('folds a subtree into its top task, in note order, with depths', () => { + const tasks = parse([ + '- [ ] Launch', + ' - [x] Write copy', + ' - [ ] Ship', + ' - [ ] Build', + ' - [-] Dropped', + '- [ ] Next' + ]) + const folded = foldSubtasks(tasks) + expect(folded.cards.map((t) => t.content)).toEqual(['Launch', 'Next']) + expect(contents(folded.subtasksByCard.get('inbox/Plan.md#0'))).toEqual([ + '1:Write copy', + '1:Ship', + '2:Build', + '1:Dropped' + ]) + expect(folded.subtasksByCard.get('inbox/Plan.md#5')).toBeUndefined() + }) + + it('keeps note order whatever order the tasks arrive in', () => { + const tasks = parse(['- [ ] p', ' - [ ] a', ' - [ ] b']).reverse() + expect(contents(foldSubtasks(tasks).subtasksByCard.get('inbox/Plan.md#0'))).toEqual([ + '1:a', + '1:b' + ]) + }) + + it('lets a task whose parent is not in the list stand as a card', () => { + const [, child] = parse(['- [ ] p', ' - [ ] orphaned']) + expect(foldSubtasks([child]).cards).toEqual([child]) + }) + + it('folds a task note’s checkboxes into the note’s card', () => { + const body = '---\ntags: [task]\n---\n- [ ] step one\n - [x] step one a\n- [x] step two' + const note = { path: 'inbox/Ship.md', title: 'Ship', folder: 'inbox' } as const + const tasks = [parseTaskFile(body, note)!, ...parseTasksFromBody(body, note)] + const folded = foldSubtasks(tasks) + expect(folded.cards.map((t) => t.id)).toEqual(['inbox/Ship.md#task']) + expect(contents(folded.subtasksByCard.get('inbox/Ship.md#task'))).toEqual([ + '1:step one', + '2:step one a', + '1:step two' + ]) + }) + + it('survives parent links that loop, standing each looped task as a card', () => { + const base = parse(['- [ ] a', '- [ ] b']) + const looped = [ + { ...base[0], parentId: base[1].id }, + { ...base[1], parentId: base[0].id } + ] + expect(foldSubtasks(looped).cards.map((t) => t.content)).toEqual(['a', 'b']) + }) +}) + +describe('subtaskProgress (#1028)', () => { + it('counts direct subtasks only, the way the note’s 2/5 chip does', () => { + const tasks = parse([ + '- [ ] top', + ' - [ ] middle', + ' - [x] leaf a', + ' - [x] leaf b', + ' - [x] sibling' + ]) + const folded = foldSubtasks(tasks) + expect(subtaskProgress(tasks[0], folded.subtasksByCard.get(tasks[0].id) ?? [])).toEqual({ + done: 1, + total: 2 + }) + }) + + it('leaves cancelled and forwarded subtasks out and counts in-progress as open', () => { + const tasks = parse([ + '- [ ] parent', + ' - [-] dropped', + ' - [>] moved', + ' - [/] going', + ' - [x] done' + ]) + const subtasks = foldSubtasks(tasks).subtasksByCard.get(tasks[0].id) ?? [] + expect(subtaskProgress(tasks[0], subtasks)).toEqual({ done: 1, total: 2 }) + }) + + it('has nothing to show when no subtask counts', () => { + const tasks = parse(['- [ ] parent', ' - [-] dropped']) + const subtasks = foldSubtasks(tasks).subtasksByCard.get(tasks[0].id) ?? [] + expect(subtaskProgress(tasks[0], subtasks)).toBeNull() + expect(subtaskProgress(tasks[0], [])).toBeNull() + }) + + it('never claims 100% while a subtask is still open', () => { + expect(progressPercent({ done: 2, total: 3 })).toBe(66) + expect(progressPercent({ done: 199, total: 200 })).toBe(99) + expect(progressPercent({ done: 5, total: 5 })).toBe(100) + expect(progressPercent({ done: 0, total: 4 })).toBe(0) + }) +}) + +describe('nextSubtaskFocus: Tab steps through a card’s subtasks (#1028)', () => { + it('walks from the card into each subtask and back to the card', () => { + expect(nextSubtaskFocus(null, 2, 1)).toBe(0) + expect(nextSubtaskFocus(0, 2, 1)).toBe(1) + expect(nextSubtaskFocus(1, 2, 1)).toBeNull() + }) + + it('walks backwards with Shift+Tab', () => { + expect(nextSubtaskFocus(null, 2, -1)).toBe(1) + expect(nextSubtaskFocus(1, 2, -1)).toBe(0) + expect(nextSubtaskFocus(0, 2, -1)).toBeNull() + }) + + it('stays on the card when it has no subtasks, and recovers a stale index', () => { + expect(nextSubtaskFocus(null, 0, 1)).toBeNull() + expect(nextSubtaskFocus(7, 2, 1)).toBeNull() + }) +}) + +describe('cardsMatchingFilter (#1028)', () => { + it('keeps a card when the filter only matches one of its subtasks', () => { + const tasks = parse(['- [ ] Launch', ' - [ ] email #urgent', '- [ ] Other']) + const folded = foldSubtasks(tasks) + expect( + cardsMatchingFilter(folded.cards, folded.subtasksByCard, '#urgent', null).map((t) => t.content) + ).toEqual(['Launch']) + expect( + cardsMatchingFilter(folded.cards, folded.subtasksByCard, 'other', null).map((t) => t.content) + ).toEqual(['Other']) + expect(cardsMatchingFilter(folded.cards, folded.subtasksByCard, '', null)).toBe(folded.cards) + }) +}) + +/** The editor's chip counts from CodeMirror's Markdown tree; the card counts + * from the parser's parent links. One body, one fraction, on both surfaces. */ +function editorRollups(source: string): Map<number, TaskRollup> { + const state = EditorState.create({ doc: source, extensions: [markdown()] }) + ensureSyntaxTree(state, source.length, 5_000) + return computeTaskRollups(state.update({}).state, 1, state.doc.lines) +} + +function cardRollups(source: string): Map<number, TaskRollup> { + const tasks = parseTasksFromBody(source, ctx) + const subtasks = new Map(tasks.map((t) => [t.id, [] as { task: VaultTask; depth: number }[]])) + for (const task of tasks) if (task.parentId) subtasks.get(task.parentId)?.push({ task, depth: 1 }) + const out = new Map<number, TaskRollup>() + for (const task of tasks) { + const progress = subtaskProgress(task, subtasks.get(task.id) ?? []) + if (progress) out.set(task.lineNumber + 1, progress) + } + return out +} + +describe('the card counts what the note’s chip counts (#512, #1028)', () => { + const corpus: string[] = [ + ...fixtures.cases + .map((c) => c.body) + .filter((body) => !body.startsWith('---')) + .map((body) => body.replace(/\r\n/g, '\n')), + '- [ ] parent\n - [x] a\n - [ ] b\n - [ ] c', + '- [ ] parent\n - [/] started\n - [x] finished', + '- [ ] parent\n - [-] dropped\n - [>] moved\n - [x] kept', + '- [ ] top\n - [ ] middle\n - [x] leaf a\n - [ ] leaf b\n - [x] sibling', + '- [ ] parent\n - just a note\n - [x] belongs to the note', + '- [ ] parent\n - [x]glued\n - [ ] real', + '- [ ]parent\n - [x] a', + '- [x] parent\n - [ ] leftover', + '- [ ] a\n - [ ] four-space child\n - [ ] eight-space grandchild', + '* [ ] star\n + [x] plus child\n- [ ] dash after star' + ] + + it.each(corpus.map((body, i) => [i, body] as const))('corpus %i', (_i, body) => { + expect(cardRollups(body)).toEqual(editorRollups(body)) + }) +}) diff --git a/packages/app-core/src/lib/kanban-subtasks.ts b/packages/app-core/src/lib/kanban-subtasks.ts new file mode 100644 index 000000000..00acdfd22 --- /dev/null +++ b/packages/app-core/src/lib/kanban-subtasks.ts @@ -0,0 +1,153 @@ +/** + * Subtasks on the Kanban board (#1028). + * + * A task's indented subtasks fold into its card instead of standing as cards + * of their own: the card lists them (checkable) and shows how far along they + * are. The parent links come from the task scan (`VaultTask.parentId`, read + * from the note's list structure in shared-domain `task-nesting.ts`), so this + * module only groups records and counts. + * + * The count is the note's own `2/5` chip (#512): direct subtasks only, with + * cancelled and forwarded ones out of the total and in-progress ones not yet + * done, through the same helpers in `task-rollup.ts`. The card lists the + * whole subtree, deeper subtasks indented under their own parent, so every + * checkbox folded into it stays reachable; only the fraction stops at the + * first level, which keeps a parent's numbers identical in the note and on + * the board. + */ +import type { VaultTask } from '@shared/tasks' +import { filterTasks, type NoteTagsByPath } from './tasks-filter' +import { + rollupChildDone, + rollupCountsChild, + type ChildTaskState, + type TaskRollup +} from './task-rollup' + +export interface CardSubtask { + task: VaultTask + /** 1 for a subtask of the card's task, 2 for a subtask of that, and on. */ + depth: number +} + +export interface FoldedTasks { + /** Tasks that stand as cards: no parent, or one the list does not hold. */ + cards: VaultTask[] + /** Everything folded under a card, by the card's id, in note order. */ + subtasksByCard: ReadonlyMap<string, CardSubtask[]> +} + +/** + * Split tasks into board cards and the subtasks folded into them. A task + * whose parent is not in `tasks` (an older server that sends no links, a + * parent the scan left out) stands as a card, so nothing ever disappears + * from the board because of a link it cannot follow. + */ +export function foldSubtasks(tasks: readonly VaultTask[]): FoldedTasks { + const byId = new Map(tasks.map((task) => [task.id, task] as const)) + const cards: VaultTask[] = [] + const subtasksByCard = new Map<string, CardSubtask[]>() + + for (const task of tasks) { + let root = task + let depth = 0 + const seen = new Set<string>([task.id]) + let looped = false + while (root.parentId) { + const parent = byId.get(root.parentId) + if (!parent) break + if (seen.has(parent.id)) { + looped = true + break + } + seen.add(parent.id) + root = parent + depth += 1 + } + if (depth === 0 || looped) { + cards.push(task) + continue + } + const list = subtasksByCard.get(root.id) + if (list) list.push({ task, depth }) + else subtasksByCard.set(root.id, [{ task, depth }]) + } + + // A subtree lives in one note, so its task index is its note order. + for (const list of subtasksByCard.values()) { + list.sort((a, b) => a.task.taskIndex - b.task.taskIndex) + } + return { cards, subtasksByCard } +} + +/** A task's checkbox state in the rollup's vocabulary. */ +export function taskRollupState( + task: Pick<VaultTask, 'checked' | 'inProgress' | 'cancelled' | 'forwarded'> +): ChildTaskState { + if (task.checked) return 'done' + if (task.cancelled) return 'cancelled' + if (task.forwarded) return 'forwarded' + if (task.inProgress) return 'in-progress' + return 'open' +} + +/** Done over total for the card's direct subtasks, or null when none counts. */ +export function subtaskProgress( + card: Pick<VaultTask, 'id'>, + subtasks: readonly CardSubtask[] +): TaskRollup | null { + let done = 0 + let total = 0 + for (const { task } of subtasks) { + if (task.parentId !== card.id) continue + const state = taskRollupState(task) + if (!rollupCountsChild(state)) continue + total += 1 + if (rollupChildDone(state)) done += 1 + } + return total > 0 ? { done, total } : null +} + +/** Rounded down, so the bar never reads 100% while a subtask is open. */ +export function progressPercent(progress: TaskRollup): number { + if (progress.total <= 0) return 0 + return Math.floor((progress.done / progress.total) * 100) +} + +/** + * Where `Tab` (dir 1) or `Shift+Tab` (dir -1) takes the cursor inside a card: + * null is the card itself, a number is that subtask. The walk wraps from the + * last subtask back to the card, like the calendar's Tab through a day. + */ +export function nextSubtaskFocus(current: number | null, count: number, dir: 1 | -1): number | null { + if (count <= 0) return null + const from = current == null || current < 0 || current >= count ? -1 : current + if (current != null && from === -1) return null + const next = (((from + 1 + dir) % (count + 1)) + count + 1) % (count + 1) + return next === 0 ? null : next - 1 +} + +/** + * The cards the Tasks filter leaves on the board: a card stays when it or any + * subtask folded into it matches, so a query that used to find a subtask's + * own card still finds the card that now holds it. + */ +export function cardsMatchingFilter( + cards: VaultTask[], + subtasksByCard: ReadonlyMap<string, readonly CardSubtask[]>, + query: string, + noteTags: NoteTagsByPath | null | undefined +): VaultTask[] { + if (!query.trim()) return cards + const candidates: VaultTask[] = [] + for (const card of cards) { + candidates.push(card) + for (const { task } of subtasksByCard.get(card.id) ?? []) candidates.push(task) + } + const matched = new Set(filterTasks(candidates, query, noteTags).map((task) => task.id)) + return cards.filter( + (card) => + matched.has(card.id) || + (subtasksByCard.get(card.id) ?? []).some(({ task }) => matched.has(task.id)) + ) +} From c71a7dcc8dcdb1367febe87ead5b9ffbe0db919b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 12:00:30 -0500 Subject: [PATCH 40/53] Test(tasks): the subtask contract pins which lines count as blank A blank line keeps a list item open and any other line at the margin closes it, so the TypeScript walk and its Go mirror must agree on what blank means. The TypeScript reads it with /^\s*$/, where U+FEFF is whitespace and U+0085 is not; Go's TrimSpace says the opposite for both, and the mirror first used it. The new case puts a BOM-only line inside the item (it stays open) and, after a real blank line, a NEL-only line at the margin (it closes the list). The NEL line follows a blank line on purpose: straight after the item's text it would be a lazy continuation, which the walk does not model, and the editor's chip would disagree. --- packages/bridge-contract/fixtures/task-subtasks.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/bridge-contract/fixtures/task-subtasks.json b/packages/bridge-contract/fixtures/task-subtasks.json index ae362a30e..5203e5fd5 100644 --- a/packages/bridge-contract/fixtures/task-subtasks.json +++ b/packages/bridge-contract/fixtures/task-subtasks.json @@ -121,6 +121,16 @@ { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" } ] }, + { + "id": "a line blank to JavaScript keeps the item open, and only that", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n\ufeff\n - [ ] after a BOM-only line\n\n\u0085\n - [ ] after a NEL-only line\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2" } + ] + }, { "id": "windows line endings nest the same", "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, From 3b48e94f8db39edf41c3c75990eccd18e90fa760 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 12:12:06 -0500 Subject: [PATCH 41/53] Feat(tasks): empty tasks stay out of the Tasks views, and the keys move behind a Keys button (#1026) A daily-note template leaves bare `- [ ]` lines for the day's tasks, and every Tasks view drew each one as "(empty task)": a card on the board, a row in the list, an entry on the calendar day, and by the next morning an overdue one, counted in Today's overdue badge (williamcheuk03, #1026). A task whose text is empty or only whitespace now stays out of the Tasks list, calendar and board. It is one setting for all three, because all three drew the placeholders: Hide empty tasks, on by default, portable as `hide_empty_tasks` under [view] in config.toml, and deliberately not a per-vault view override (the reason #889 gave). A task with any text always shows, even one that is only `due:` or `!high`: the parser keeps the raw tail when stripping tokens leaves nothing, so the test is the content itself. The board hides empty cards from its visible columns only, the way the filter narrows them (#583), so a hand-made card order that places one keeps it for when they show again, and an overdue placeholder leaves the overdue badge with its card. The calendar panel, the Timeline and Home are not the Tasks view and still show empty tasks. The switch lives in a new View menu, after Group by and Order, with a quiet "3 hidden" beside it that shows them on a click, so a hidden placeholder never reads as a lost task. Adib asked for less on that row: the paragraph of key hints in the board's toolbar, the hint line under every Tasks view and the calendar header's hint are gone. Each view has a Keys button at the right of its toolbar instead (`?` in Vim mode, Esc closes it) that opens a short cheat sheet: groups of a key chip and a few words, from one table in lib/tasks-keys.ts, named for the mode that is on. With Vim off it lists only the arrows, Enter, Space and Tab. A Subtasks group is reserved there for the subtasks branch, off until its keys exist. No label in the header wraps inside itself at any width: on a narrow pane the toolbar's groups wrap whole and the filter box gives up its width first. Keys: `v` opens the View menu and `E` flips the setting, both Vim mode; the palette has Tasks: Hide Empty Tasks / Show Empty Tasks; Settings > Tasks has the switch. A toolbar button reached with Tab takes Enter, Space and Shift+F10 (the View menu), and Esc hands the keyboard back to the view. A click on a toolbar button does not move focus, so the board and list keys keep working after one. Tests: the empty-task rule on parsed lines (bare, whitespace, token-only, checked), the board's hiding with its count and overdue badge, the cheat sheet naming no letter with Vim off on any view, the setting's default and round trip, config.toml, the Settings search and the palette catalog. Over CDP on the built app, both stores isolated: a vault whose daily notes hold `- [ ]` placeholders shows "3 hidden" and no empty card in the list, calendar and board; the View menu and `E` show and hide them; `?` and the Keys button open the sheet and Esc closes it; with Vim off, E, v, ?, m, x, j, l, g and s do nothing and the sheet names only universal keys; at 800, 1100 and 1400 px panes, light and dark, no header label wraps and no row overflows. --- apps/desktop/src/main/app-config.test.ts | 4 + apps/desktop/src/main/app-config.ts | 5 + .../src/components/SettingsModal.test.ts | 3 + .../app-core/src/components/SettingsModal.tsx | 26 ++ .../app-core/src/components/TasksCalendar.tsx | 32 ++- .../src/components/TasksKanban.test.ts | 47 +++- .../app-core/src/components/TasksKanban.tsx | 89 ++++-- .../app-core/src/components/TasksToolbar.tsx | 262 ++++++++++++++++++ .../app-core/src/components/TasksView.tsx | 148 ++++++---- packages/app-core/src/components/icons.tsx | 27 ++ packages/app-core/src/lib/commands.ts | 10 + packages/app-core/src/lib/empty-tasks.test.ts | 47 ++++ packages/app-core/src/lib/empty-tasks.ts | 28 ++ packages/app-core/src/lib/help.ts | 14 + packages/app-core/src/lib/tasks-keys.test.ts | 59 ++++ packages/app-core/src/lib/tasks-keys.ts | 238 ++++++++++++++++ packages/app-core/src/store.test.ts | 17 ++ packages/app-core/src/store.ts | 17 ++ packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + .../shared-domain/src/commands-catalog.ts | 1 + 21 files changed, 992 insertions(+), 84 deletions(-) create mode 100644 packages/app-core/src/components/TasksToolbar.tsx create mode 100644 packages/app-core/src/lib/empty-tasks.test.ts create mode 100644 packages/app-core/src/lib/empty-tasks.ts create mode 100644 packages/app-core/src/lib/tasks-keys.test.ts create mode 100644 packages/app-core/src/lib/tasks-keys.ts diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index fe06c5ba0..ab44cecf2 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -133,6 +133,7 @@ describe('TOML serialization', () => { savedTaskFilters: { 'Project alpha': '@project:alpha', Blocked: '@status:blocked' }, kanbanGroupBy: 'folder', kanbanCardSort: 'due', + hideEmptyTasks: false, tasksListOrder: 'priority', timelineRange: 'month', gallerySort: 'title', @@ -173,6 +174,9 @@ describe('TOML serialization', () => { expect(round.kanbanGroupBy).toBe('folder') expect(text).toContain('kanban_card_sort = "due"') expect(round.kanbanCardSort).toBe('due') + // #1026: the empty-task switch travels too. + expect(text).toContain('hide_empty_tasks = false') + expect(round.hideEmptyTasks).toBe(false) expect(text).toContain('tasks_list_order = "priority"') expect(round.tasksListOrder).toBe('priority') // The Timeline panel's reach travels as [view].timeline_range (#1007). diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index a736fcbb8..219f623fe 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -441,6 +441,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = { tomlKey: 'kanban_card_sort', comment: 'manual | due: cards in a Kanban column keep the order you drag them into, or follow their due dates' }, + hideEmptyTasks: { + section: 'view', + tomlKey: 'hide_empty_tasks', + comment: 'leave blank checkboxes out of the Tasks list, calendar and board' + }, tasksListOrder: { section: 'view', tomlKey: 'tasks_list_order', diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 11fd51354..f1cc7d5eb 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -52,6 +52,7 @@ const mocks = vi.hoisted(() => { interfaceFont: null, // The Tasks pane: "due date" finds its Kanban card order row first. kanbanCardSort: "manual", + hideEmptyTasks: true, kanbanFolderRoot: "", kanbanStatuses: [] as string[], keymapOverrides: {} as Record<string, string>, @@ -571,6 +572,8 @@ describe("SettingsModal date note directories", () => { ["due date", "Tasks are due on the note's date", "daily-notes-tasks-due-on-date"], ["due date", "Kanban card order", "kanban-card-sort"], ["card order", "Kanban card order", "kanban-card-sort"], + ["empty task", "Hide empty tasks", "hide-empty-tasks"], + ["placeholder", "Hide empty tasks", "hide-empty-tasks"], ["rollover", "Roll over unfinished tasks to today", "daily-notes-rollover"], ["tutorial", "Guided tutorial", "workflow-tutorial"], ["recipe gallery", "Built-in recipes", "workflow-hidden-recipes"], diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 00f8f793a..8dacad5ae 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -742,6 +742,8 @@ export function SettingsModal(): JSX.Element { const showArchivedTasks = useStore((s) => s.showArchivedTasks); const kanbanCardSort = useStore((s) => s.kanbanCardSort); const setKanbanCardSort = useStore((s) => s.setKanbanCardSort); + const hideEmptyTasks = useStore((s) => s.hideEmptyTasks); + const setHideEmptyTasks = useStore((s) => s.setHideEmptyTasks); const tasksListOrder = useStore((s) => s.tasksListOrder); const setTasksListOrder = useStore((s) => s.setTasksListOrder); const setShowArchivedTasks = useStore((s) => s.setShowArchivedTasks); @@ -3681,6 +3683,21 @@ export function SettingsModal(): JSX.Element { "manual", ], }, + { + id: "hide-empty-tasks", + title: "Hide empty tasks", + description: "Leave blank checkboxes out of the Tasks views.", + keywords: [ + "empty", + "empty task", + "blank", + "placeholder", + "template", + "daily note", + "kanban", + "hide", + ], + }, { id: "show-archived-tasks", title: "Show tasks from archived notes", @@ -3776,6 +3793,15 @@ export function SettingsModal(): JSX.Element { onChange={(next) => setTasksListOrder(next)} /> </Section> + <Section title="Empty tasks"> + <ToggleRow + label="Hide empty tasks" + description="Leave blank checkboxes out of the Tasks views." + value={hideEmptyTasks} + settingId="hide-empty-tasks" + onChange={setHideEmptyTasks} + /> + </Section> <Section title="Archived notes" description="What happens to a note's tasks when the note moves to the Archive." diff --git a/packages/app-core/src/components/TasksCalendar.tsx b/packages/app-core/src/components/TasksCalendar.tsx index 34c27ce6c..06543b51f 100644 --- a/packages/app-core/src/components/TasksCalendar.tsx +++ b/packages/app-core/src/components/TasksCalendar.tsx @@ -28,6 +28,7 @@ import { resolveWeekStartDay } from '../lib/week-start' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' import { isOverlayOrDialogOpen } from '../lib/overlay-open' +import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' interface Props { tasks: VaultTask[] @@ -43,6 +44,8 @@ interface Props { /** Whether daily notes are enabled — gates the quick-add box, which writes * into the selected day's daily note. */ dailyNotesEnabled: boolean + /** Empty tasks left off the calendar, for the View menu's "3 hidden". (#1026) */ + hiddenEmpty?: number } /** Canonical Sun..Sat labels; rotated to the user's `calendarWeekStart` at @@ -101,7 +104,8 @@ export function TasksCalendar({ onRescheduleTask, onMoveTask, onAddTask, - dailyNotesEnabled + dailyNotesEnabled, + hiddenEmpty = 0 }: Props): JSX.Element { const monthAnchorIso = useStore((s) => s.tasksCalendarMonthAnchor) const setMonthAnchor = useStore((s) => s.setTasksCalendarMonthAnchor) @@ -368,6 +372,8 @@ export function TasksCalendar({ const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } + // A header button reached with Tab takes its own keys. + if (tasksToolbarOwnsKey(e)) return const consume = (): void => { e.preventDefault() @@ -621,7 +627,10 @@ export function TasksCalendar({ return ( <div ref={rootRef} className="flex min-h-0 flex-1 flex-col"> - <div className="flex shrink-0 items-center justify-between gap-2 px-3 pt-3"> + <div + data-tasks-toolbar + className="flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-1.5 whitespace-nowrap px-3 pt-3" + > <div className="flex items-center gap-1"> <button type="button" @@ -651,12 +660,19 @@ export function TasksCalendar({ <div className="text-sm font-semibold text-current/85"> {formatMonthLabel(monthAnchor)} </div> - <div className="text-xs text-current/40"> - {grabbedTask - ? `Moving “${grabbedTask.content || 'task'}”: h/j/k/l pick a day · Enter place · Esc cancel` - : vimMode - ? 'h/j/k/l day · Tab pick · x toggle · e edit · dd del · m move · < > / T due · a add' - : '←/→/↑/↓ day · Tab pick · Space toggle · drag to move · right-click actions'} + {/* The keys sit behind Keys; only the grab mode, a state rather + than a hint, still says how to finish what it started. */} + <div className="flex items-center gap-3 text-xs text-current/60"> + {grabbedTask ? ( + <span className="text-current/40"> + {`Moving “${grabbedTask.content || 'task'}”: h/j/k/l pick a day · Enter place · Esc cancel`} + </span> + ) : ( + <> + <TasksViewOptions hidden={hiddenEmpty} /> + <TasksKeysButton view="calendar" /> + </> + )} </div> </div> diff --git a/packages/app-core/src/components/TasksKanban.test.ts b/packages/app-core/src/components/TasksKanban.test.ts index 0755e87ac..ae596f807 100644 --- a/packages/app-core/src/components/TasksKanban.test.ts +++ b/packages/app-core/src/components/TasksKanban.test.ts @@ -19,7 +19,8 @@ import { folderColumns, noteLocationOf, orderCards, - FOLDER_OTHER_LABEL + FOLDER_OTHER_LABEL, + hideEmptyCards } from './TasksKanban' function col(id: string): Column { @@ -628,3 +629,47 @@ describe('card order by due date (#889)', () => { expect(texts(built, 0)).toEqual(['Someday', 'Late', 'Soon']) }) }) + +describe('empty tasks stay off the board (#1026)', () => { + // Yesterday's daily note left its template placeholders behind: overdue, + // empty, and in the Today column beside the real work. + const placeholder = (taskIndex: number): VaultTask => + card({ content: '', taskIndex, sourcePath: 'Daily/2026-08-24.md', due: '2026-08-24' }) + const real = card({ + content: 'Call the bank', + taskIndex: 3, + sourcePath: 'Daily/2026-08-24.md', + due: '2026-08-24' + }) + const built = (): Column[] => + statusColumns( + [placeholder(0), placeholder(1), real, card({ content: ' ', taskIndex: 4 })], + TODAY + ) + + it('leaves empty cards off every column and counts them', () => { + const { columns: shown, hidden } = hideEmptyCards(built(), TODAY) + expect(hidden).toBe(3) + expect(columnIds(shown, 'today')).toEqual(['Call the bank']) + expect(columnIds(shown, 'undated')).toEqual([]) + }) + + it('keeps every column, even one the hiding emptied', () => { + const board = built() + expect(hideEmptyCards(board, TODAY).columns.map((c) => c.id)).toEqual(board.map((c) => c.id)) + }) + + it('stops counting a hidden placeholder as overdue', () => { + const board = built() + expect(board.find((c) => c.id === 'today')!.badge).toEqual({ kind: 'overdue', value: 3 }) + const { columns: shown } = hideEmptyCards(board, TODAY) + expect(shown.find((c) => c.id === 'today')!.badge).toEqual({ kind: 'overdue', value: 1 }) + }) + + it('hands back the same columns when nothing is empty', () => { + const board = statusColumns([real], TODAY) + const result = hideEmptyCards(board, TODAY) + expect(result.hidden).toBe(0) + expect(result.columns[0]).toBe(board[0]) + }) +}) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 32ef16ddd..645e01ae4 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -46,10 +46,12 @@ import { type TaskMutation } from '../store' import { filterTasks, type NoteTagsByPath } from '../lib/tasks-filter' +import { isEmptyTask } from '../lib/empty-tasks' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' import { ArrowUpRightIcon, PencilIcon } from './icons' +import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' import { InlineMarkdown } from '../lib/inline-markdown' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { TaskStateBox } from './TaskStateBox' @@ -577,6 +579,40 @@ export function arrangeColumns(columns: Column[], order: string[]): Column[] { return [...sorted, ...noValue] } +/** A column showing only `visible` of its cards, its overdue badge counting + * only those. The filter (#583) and the empty-task hiding (#1026) both + * narrow cards this way and never drop a column. */ +function withVisibleCards(column: Column, visible: VaultTask[], today: Date): Column { + if (visible.length === column.tasks.length) return column + let badge = column.badge + if (badge?.kind === 'overdue') { + const value = visible.filter((t) => isTaskOverdue(t, today)).length + badge = value > 0 ? { kind: 'overdue', value } : undefined + } + return { ...column, tasks: visible, badge } +} + +/** Leave the empty cards (a template's bare `- [ ]`) off the board, and count + * them so the toolbar can say they are hidden rather than lost (#1026). An + * overdue placeholder in yesterday's daily note stops counting as overdue + * with it. The same columns come back when nothing was empty. */ +export function hideEmptyCards( + columns: Column[], + today: Date +): { columns: Column[]; hidden: number } { + let hidden = 0 + const next = columns.map((column) => { + const shown = withVisibleCards( + column, + column.tasks.filter((task) => !isEmptyTask(task)), + today + ) + hidden += column.tasks.length - shown.tasks.length + return shown + }) + return { columns: hidden === 0 ? columns : next, hidden } +} + /** How long an armed `g` waits for a tab-sequence completion before the * group-by cycle fires. Matches VimNav's own gt/gT pending window. */ export const GROUP_BY_PENDING_MS = 500 @@ -684,6 +720,7 @@ export function TasksKanban({ }: Props): JSX.Element { const groupBy = useStore((s) => s.kanbanGroupBy) const cardSort = useStore((s) => s.kanbanCardSort) + const hideEmptyTasks = useStore((s) => s.hideEmptyTasks) const setCardSort = useStore((s) => s.setKanbanCardSort) const setGroupBy = useStore((s) => s.setKanbanGroupBy) const kanbanColumnTitles = useStore((s) => s.kanbanColumnTitles) @@ -855,20 +892,20 @@ export function TasksKanban({ // cards are filtered out), and the unfiltered columns stay the source of // truth for card-order persistence so a focused board can't prune hidden // cards out of a hand-made arrangement. (#583) + // Empty cards leave the board the same way (#1026): off the visible + // columns only, so a hand-made arrangement that places one keeps it for + // when they are shown again, and counted for the toolbar. const filterQuery = (filter ?? '').trim() - const columns = useMemo(() => { - if (!filterQuery) return fullColumns - return fullColumns.map((column) => { - const visible = filterTasks(column.tasks, filterQuery, noteTags) - if (visible.length === column.tasks.length) return column - let badge = column.badge - if (badge?.kind === 'overdue') { - const value = visible.filter((t) => isTaskOverdue(t, today)).length - badge = value > 0 ? { kind: 'overdue', value } : undefined - } - return { ...column, tasks: visible, badge } - }) - }, [fullColumns, filterQuery, noteTags, today]) + const { columns, hiddenEmpty } = useMemo(() => { + const narrowed = filterQuery + ? fullColumns.map((column) => + withVisibleCards(column, filterTasks(column.tasks, filterQuery, noteTags), today) + ) + : fullColumns + if (!hideEmptyTasks) return { columns: narrowed, hiddenEmpty: 0 } + const hidden = hideEmptyCards(narrowed, today) + return { columns: hidden.columns, hiddenEmpty: hidden.hidden } + }, [fullColumns, filterQuery, hideEmptyTasks, noteTags, today]) columnsRef.current = columns fullColumnsRef.current = fullColumns @@ -1704,6 +1741,8 @@ export function TasksKanban({ const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } + // A toolbar button reached with Tab takes its own keys. + if (tasksToolbarOwnsKey(e)) return // A deferred group-by cycle is waiting on the gt/gT window (#573). // Resolved before the modifier guard so a chord also drops the prefix. @@ -1854,8 +1893,13 @@ export function TasksKanban({ return ( <div className="flex min-h-0 flex-1 flex-col"> - <div className="flex shrink-0 items-center justify-between gap-2 border-b border-paper-300/45 px-3 py-2"> - <div className="flex items-center gap-1 text-xs text-current/60"> + {/* Group by, Order and the View menu; the keys sit behind Keys. On a + narrow pane each group wraps whole, never a label inside it. */} + <div + data-tasks-toolbar + className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-paper-300/45 px-3 py-2 text-xs text-current/60" + > + <div className="flex shrink-0 items-center gap-1 whitespace-nowrap"> <span>Group by</span> <select value={groupBy} @@ -1883,7 +1927,9 @@ export function TasksKanban({ {kanbanFolderRoot ? `inside ${kanbanFolderRoot}` : 'each note’s folder'} </button> )} - <span className="ml-2">Order</span> + </div> + <div className="flex shrink-0 items-center gap-1 whitespace-nowrap"> + <span>Order</span> <select data-kanban-card-sort value={cardSort} @@ -1899,14 +1945,9 @@ export function TasksKanban({ <option value="due">Due date</option> </select> </div> - <div className="text-xs text-current/40"> - {vimMode - ? dndEnabled - ? 'Drag or Shift+H·L move card · drag header or </> reorder columns · h/l · j/k · g group-by · s order · x · Enter · right-click actions' - : 'Drag header or </> reorder columns · h/l column · j/k card · g group-by · s order · x · Enter · right-click actions' - : dndEnabled - ? 'Drag to move a card · drag a header to reorder columns · ←/→ · ↑/↓ · Space · Enter · right-click actions' - : 'Drag a header to reorder columns · ←/→ column · ↑/↓ card · Space · Enter · right-click actions'} + <TasksViewOptions hidden={hiddenEmpty} /> + <div className="ml-auto shrink-0"> + <TasksKeysButton view="kanban" dndEnabled={dndEnabled} /> </div> </div> diff --git a/packages/app-core/src/components/TasksToolbar.tsx b/packages/app-core/src/components/TasksToolbar.tsx new file mode 100644 index 000000000..695ac52d4 --- /dev/null +++ b/packages/app-core/src/components/TasksToolbar.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useStore } from '../store' +import { emptyTasksShowLabel } from '../lib/empty-tasks' +import { isKeyboardContextMenuKey } from '../lib/keyboard-context-menu' +import { tasksKeySheet, type TasksKeysView } from '../lib/tasks-keys' +import { ContextMenu } from './ContextMenu' +import { KeyboardIcon, SlidersIcon } from './icons' + +/** + * The Tasks views' toolbar controls, shared by the list, the calendar and the + * board: one View menu for the display options, and a Keys button for the + * keyboard cheat sheet. Both replaced controls that had crowded the toolbar + * (a separate switch per option, and a paragraph of key hints). + */ + +/** A key as the cheat sheet draws it. */ +export function KeyChip({ children }: { children: ReactNode }): JSX.Element { + return ( + <kbd className="inline-flex min-w-5 justify-center whitespace-nowrap rounded border border-paper-300 bg-paper-200/85 px-1.5 py-0.5 font-mono text-2xs leading-none text-ink-700"> + {children} + </kbd> + ) +} + +/** For a toolbar button's onMouseDown: a click does not move focus onto it, + * so the menu or sheet it opens hands focus back to the view, and the board + * or list keys work again after Esc. Tab still reaches the button. */ +function keepFocusOnClick(e: React.MouseEvent): void { + e.preventDefault() +} + +/** + * Whether a toolbar button that holds keyboard focus (reached with Tab) owns + * this key, so a view's key handler stands aside: Enter and Space press the + * button, Shift+F10 opens the View menu from its button, Escape hands the + * keyboard back to the view. A button that took focus from a click does not + * count (`:focus-visible`), so the board keys keep working after a mouse + * click on the toolbar; selects are left to the view as before, whose `j` + * would otherwise change a select's value by typeahead. + */ +export function tasksToolbarOwnsKey(e: KeyboardEvent): boolean { + const active = document.activeElement as HTMLElement | null + if (!active || active.tagName !== 'BUTTON') return false + if (!active.closest('[data-tasks-toolbar]') || !active.matches(':focus-visible')) return false + if (isKeyboardContextMenuKey(e) && active.hasAttribute('data-tasks-view-options')) { + e.preventDefault() + e.stopImmediatePropagation() + active.click() + return true + } + if (e.key === 'Escape') { + e.preventDefault() + e.stopImmediatePropagation() + active.blur() + return true + } + return true +} + +/** + * The View menu (#1026): display options for the Tasks views, today Hide + * empty tasks; later board options belong in it too, not as more toolbar + * controls. Beside it, only when something is hidden, a quiet "3 hidden" + * that shows them on a click. + */ +export function TasksViewOptions({ hidden }: { hidden: number }): JSX.Element { + const hide = useStore((s) => s.hideEmptyTasks) + const setHide = useStore((s) => s.setHideEmptyTasks) + const vimMode = useStore((s) => s.vimMode) + const buttonRef = useRef<HTMLButtonElement | null>(null) + const [menu, setMenu] = useState<{ x: number; y: number; top: number } | null>(null) + + const openMenu = (): void => { + const rect = buttonRef.current?.getBoundingClientRect() + if (!rect) return + setMenu({ x: rect.left, y: rect.bottom + 4, top: rect.top }) + } + + return ( + <div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap"> + <button + ref={buttonRef} + type="button" + data-tasks-view-options + aria-haspopup="menu" + aria-expanded={menu !== null} + onMouseDown={keepFocusOnClick} + onClick={() => (menu ? setMenu(null) : openMenu())} + title={`View options${vimMode ? ' (v)' : ''}`} + className="flex items-center gap-1 whitespace-nowrap rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-0.5 text-xs text-current/80 transition-colors hover:bg-paper-200 hover:text-current/90" + > + <SlidersIcon width={12} height={12} aria-hidden="true" /> + View + </button> + {hide && hidden > 0 && ( + <button + type="button" + data-empty-tasks-hidden + onMouseDown={keepFocusOnClick} + onClick={() => setHide(false)} + title={emptyTasksShowLabel(hidden)} + aria-label={emptyTasksShowLabel(hidden)} + className="whitespace-nowrap rounded-full px-1.5 py-0.5 text-2xs text-current/45 transition-colors hover:bg-paper-200/80 hover:text-current/75" + > + {hidden} hidden + </button> + )} + {menu && ( + <ContextMenu + x={menu.x} + y={menu.y} + anchorTop={menu.top} + title="View" + items={[ + { + label: 'Hide empty tasks', + checked: hide, + hint: vimMode ? 'E' : undefined, + keywords: ['empty', 'blank', 'placeholder', 'template'], + onSelect: () => setHide(!hide) + } + ]} + onClose={() => setMenu(null)} + /> + )} + </div> + ) +} + +const SHEET_TITLES: Record<TasksKeysView, string> = { + list: 'List keys', + calendar: 'Calendar keys', + kanban: 'Board keys' +} + +/** + * The Keys button and its cheat sheet (`?` in Vim mode). The popover is a + * small modal dialog: the views stand aside while it is open, and Escape or + * `?` closes it and gives focus back to whatever held it. + */ +export function TasksKeysButton({ + view, + dndEnabled = true, + subtasks = false +}: { + view: TasksKeysView + dndEnabled?: boolean + /** The board's Subtasks group (Tab / Shift+Tab); see tasksKeySheet. */ + subtasks?: boolean +}): JSX.Element { + const vimMode = useStore((s) => s.vimMode) + const listOrder = useStore((s) => s.tasksListOrder) + const buttonRef = useRef<HTMLButtonElement | null>(null) + const panelRef = useRef<HTMLDivElement | null>(null) + const previousFocusRef = useRef<HTMLElement | null>(null) + const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null) + + const close = useCallback((restoreFocus: boolean): void => { + setAnchor(null) + if (restoreFocus) previousFocusRef.current?.focus?.({ preventScroll: true }) + previousFocusRef.current = null + }, []) + + const open = (): void => { + const rect = buttonRef.current?.getBoundingClientRect() + if (!rect) return + previousFocusRef.current = document.activeElement as HTMLElement | null + setAnchor({ top: rect.bottom + 6, right: Math.max(8, window.innerWidth - rect.right) }) + } + + useEffect(() => { + if (!anchor) return + panelRef.current?.focus({ preventScroll: true }) + const onKey = (e: KeyboardEvent): void => { + const toggle = e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey + if (e.key === 'Escape' || toggle) { + e.preventDefault() + e.stopImmediatePropagation() + close(true) + } else if (e.key === 'Tab') { + // Nothing inside to tab to; keep focus on the sheet. + e.preventDefault() + } + } + const onPointerDown = (e: PointerEvent): void => { + const target = e.target as Node + if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return + close(false) + } + window.addEventListener('keydown', onKey, true) + window.addEventListener('pointerdown', onPointerDown, true) + return () => { + window.removeEventListener('keydown', onKey, true) + window.removeEventListener('pointerdown', onPointerDown, true) + } + }, [anchor, close]) + + const sheet = tasksKeySheet(view, { vimMode, dndEnabled, listOrder, subtasks }) + + return ( + <> + <button + ref={buttonRef} + type="button" + data-tasks-keys + aria-label="Keyboard shortcuts" + aria-haspopup="dialog" + aria-expanded={anchor !== null} + onMouseDown={keepFocusOnClick} + onClick={() => (anchor ? close(true) : open())} + title={`Keyboard shortcuts${vimMode ? ' (?)' : ''}`} + className="flex items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs text-current/55 transition-colors hover:bg-paper-200/80 hover:text-current/85" + > + <KeyboardIcon width={14} height={14} aria-hidden="true" /> + Keys + </button> + {anchor && + createPortal( + <div + ref={panelRef} + role="dialog" + aria-modal="true" + aria-label="Keyboard shortcuts" + tabIndex={-1} + data-tasks-keys-popover + style={{ top: anchor.top, right: anchor.right }} + className="fixed z-popover max-h-[80vh] w-[92vw] max-w-dialog-xs overflow-y-auto rounded-xl border border-paper-300/70 bg-paper-100 p-3 text-xs text-ink-900 shadow-float outline-none" + > + <div className="mb-2.5 flex items-center justify-between gap-3"> + <span className="font-semibold">{SHEET_TITLES[view]}</span> + <span className="text-2xs text-ink-500">{vimMode ? 'Vim mode' : 'Vim mode off'}</span> + </div> + <div className="grid grid-cols-2 gap-x-5 gap-y-3"> + {sheet.groups.map((group) => ( + <div key={group.title} data-tasks-keys-group={group.title}> + <div className="mb-1 text-2xs font-medium uppercase tracking-wide text-ink-500"> + {group.title} + </div> + <ul className="space-y-1"> + {group.rows.map((row) => ( + <li + key={`${row.keys} ${row.label}`} + className="flex items-center gap-2 whitespace-nowrap" + > + <KeyChip>{row.keys}</KeyChip> + <span className="text-ink-700">{row.label}</span> + </li> + ))} + </ul> + </div> + ))} + </div> + <div className="mt-3 border-t border-paper-300/60 pt-2 text-2xs text-ink-500"> + {sheet.mouse} + </div> + </div>, + document.body + )} + </> + ) +} diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 285d3cc7f..376d7157c 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -7,6 +7,8 @@ import { forwardTaskWithPicker } from '../lib/forward-task' import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' import { TasksKanban } from './TasksKanban' +import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' +import { isEmptyTask, withoutEmptyTasks } from '../lib/empty-tasks' import { CalendarIcon, CheckSquareIcon, KanbanIcon, ListIcon } from './icons' import { advanceSequence, getKeymapBinding, matchesSequenceToken, type KeymapId } from '../lib/keymaps' import { @@ -111,9 +113,24 @@ export function TasksView(): JSX.Element { // options and card-order persistence must keep seeing every task). // A file task also matches the tags written in its note's body (#1008). const noteTags = useMemo(() => noteTagsByPath(notes), [notes]) + // Empty tasks (a template's bare `- [ ]`) leave the list and the calendar + // here (#1026). The board gets every task and hides them itself, off its + // visible columns only, so its saved card arrangement keeps them. + const hideEmptyTasks = useStore((s) => s.hideEmptyTasks) + const setHideEmptyTasks = useStore((s) => s.setHideEmptyTasks) + const shownTasks = useMemo( + () => (hideEmptyTasks ? withoutEmptyTasks(tasks).shown : tasks), + [tasks, hideEmptyTasks] + ) + // What the list and the calendar left out, under the same filter as what + // they show (both list every task, the calendar its undated ones apart). + const hiddenEmpty = useMemo( + () => (hideEmptyTasks ? filterTasks(tasks.filter(isEmptyTask), filter, noteTags).length : 0), + [tasks, hideEmptyTasks, filter, noteTags] + ) const filteredTasks = useMemo( - () => filterTasks(tasks, filter, noteTags), - [tasks, filter, noteTags] + () => filterTasks(shownTasks, filter, noteTags), + [shownTasks, filter, noteTags] ) const keymapOverrides = useStore((s) => s.keymapOverrides) const vimMode = useStore((s) => s.vimMode) @@ -168,24 +185,24 @@ export function TasksView(): JSX.Element { const render = useMemo(() => { const linger = lingerRef.current if (linger.size === 0) { - return computeTasksRender(tasks, filter, today, collapsed, listOrder, noteTags) + return computeTasksRender(shownTasks, filter, today, collapsed, listOrder, noteTags) } // Group lingering tasks by their PRE-toggle checked state so a just-checked // task stays in its current group instead of jumping straight to Done. - const source = tasks.map((t) => { + const source = shownTasks.map((t) => { const l = linger.get(t.id) return l ? { ...t, checked: l.groupChecked } : t }) const r = computeTasksRender(source, filter, today, collapsed, listOrder, noteTags) // Render the real task so the checkbox reflects the actual (new) state while // the row lingers in place. - const byId = new Map(tasks.map((t) => [t.id, t])) + const byId = new Map(shownTasks.map((t) => [t.id, t])) const rows = r.rows.map((row) => row.kind === 'task' && row.task ? { ...row, task: byId.get(row.task.id) ?? row.task } : row ) return { ...r, rows } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tasks, filter, today, collapsed, lingerVersion, listOrder, noteTags]) + }, [shownTasks, filter, today, collapsed, lingerVersion, listOrder, noteTags]) // Index-into-rows map for just the 'task' rows (what the cursor navigates). const taskRowIndices = useMemo(() => { @@ -662,6 +679,8 @@ export function TasksView(): JSX.Element { const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } + // A toolbar button reached with Tab takes its own keys. + if (tasksToolbarOwnsKey(e)) return const overrides = keymapOverrides // A chord gets through only as the key of one of this view's actions, // and never past a global shortcut (#972, lib/list-view-keys.ts). @@ -712,6 +731,29 @@ export function TasksView(): JSX.Element { return } + // Show or hide the empty tasks (#1026). One setting for all three + // sub-views, so the key works in each; Vim-gated like `a`. + if (vimMode && key === 'E') { + consume() + const hide = !useStore.getState().hideEmptyTasks + setHideEmptyTasks(hide) + toast(hide ? 'Empty tasks hidden' : 'Empty tasks shown') + return + } + + // The View menu (`v`) and the keys cheat sheet (`?`) of whichever + // sub-view is showing; each lives on its toolbar as a button. + if (vimMode && (key === 'v' || key === '?')) { + const button = rootRef.current?.querySelector<HTMLButtonElement>( + key === 'v' ? '[data-tasks-view-options]' : '[data-tasks-keys]' + ) + if (button) { + consume() + button.click() + } + return + } + if (seq('nav.filter')) { consume() filterRef.current?.focus() @@ -850,12 +892,14 @@ export function TasksView(): JSX.Element { pickSavedFilter, listOrder, setTasksListOrder, + setHideEmptyTasks, toast ]) return ( <div ref={rootRef} + data-tasks-view className="flex min-h-0 flex-1 flex-col bg-paper-100 text-ink-900" // The pane's capture handlers claim focusedPanel 'editor' for any click // or focus inside the pane (#477), which disconnects this view's window @@ -867,15 +911,19 @@ export function TasksView(): JSX.Element { onMouseDownCapture={() => setFocusedPanel('tasks')} onFocusCapture={() => setFocusedPanel('tasks')} > - <div className="flex items-center gap-2 border-b border-paper-300/45 px-4 py-3"> - <CheckSquareIcon width={18} height={18} /> - <h1 className="text-sm font-semibold">Tasks</h1> - <span className="ml-2 rounded bg-paper-300/60 px-1.5 py-0.5 text-xs text-current/60"> - {filter.trim() ? `${filteredTasks.length} of ${tasks.length}` : `${tasks.length} total`} + {/* Nothing here wraps inside itself: on a narrow pane the filter box + gives up its width first. */} + <div className="flex items-center gap-2 whitespace-nowrap border-b border-paper-300/45 px-4 py-3"> + <CheckSquareIcon width={18} height={18} className="shrink-0" /> + <h1 className="shrink-0 text-sm font-semibold">Tasks</h1> + <span className="ml-2 shrink-0 whitespace-nowrap rounded bg-paper-300/60 px-1.5 py-0.5 text-xs text-current/60"> + {filter.trim() + ? `${filteredTasks.length} of ${shownTasks.length}` + : `${shownTasks.length} total`} </span> {loading && <span className="text-xs text-current/50">scanning…</span>} - <div className="ml-2 flex items-center gap-0.5 rounded-md bg-paper-200/60 p-0.5"> + <div className="ml-2 flex shrink-0 items-center gap-0.5 rounded-md bg-paper-200/60 p-0.5"> {VIEW_BUTTONS.map(({ id, label, shortcut, Icon }) => { const isActive = viewMode === id return ( @@ -898,7 +946,7 @@ export function TasksView(): JSX.Element { })} </div> - <div className="ml-auto flex items-center gap-2"> + <div className="ml-auto flex min-w-0 items-center gap-2"> {/* Mounted in every sub-view: the same query narrows the list, the calendar, and the Kanban board, so switching views keeps the focus you typed. (#583) */} @@ -920,12 +968,12 @@ export function TasksView(): JSX.Element { e.currentTarget.blur() } }} - className="w-56 rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-1 text-xs outline-none focus:border-paper-400/70" + className="w-56 min-w-24 shrink rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-1 text-xs outline-none focus:border-paper-400/70" /> <button type="button" onClick={() => void newTaskFile()} - className="rounded-md border border-accent/45 bg-accent/10 px-2 py-1 text-xs font-medium text-accent hover:bg-accent/20" + className="shrink-0 rounded-md border border-accent/45 bg-accent/10 px-2 py-1 text-xs font-medium text-accent hover:bg-accent/20" title={vimMode ? 'New task (a)' : 'New task'} > + New task @@ -933,7 +981,7 @@ export function TasksView(): JSX.Element { <button type="button" onClick={() => void refreshTasks()} - className="rounded-md px-2 py-1 text-xs text-current/70 hover:bg-paper-200/80" + className="shrink-0 rounded-md px-2 py-1 text-xs text-current/70 hover:bg-paper-200/80" title="Rescan vault" > Refresh @@ -941,7 +989,7 @@ export function TasksView(): JSX.Element { <button type="button" onClick={closeTasksView} - className="rounded-md px-2 py-1 text-xs text-current/70 hover:bg-paper-200/80" + className="shrink-0 rounded-md px-2 py-1 text-xs text-current/70 hover:bg-paper-200/80" title="Close (:q)" > Close @@ -1001,23 +1049,35 @@ export function TasksView(): JSX.Element { </div> )} + {/* The list's toolbar, laid out like the board's: Order and the View + menu, the keys behind Keys. The calendar carries the same two + buttons in its own header row; the board in its toolbar. */} {viewMode === 'list' && ( - <div className="flex items-center justify-end gap-2 border-b border-paper-300/45 px-4 py-1 text-xs text-current/60"> - <span>Order</span> - <select - data-tasks-list-order - value={listOrder} - onChange={(e) => setTasksListOrder(e.target.value as TasksListOrder)} - title={ - listOrder === 'priority' - ? `Each group puts high priority first, then medium, low, and none; ties follow the due date. Note order brings back the notes' line order${vimMode ? ' (s)' : ''}.` - : `Each group follows its notes' line order, which J/K rearrange. Priority puts high priority first${vimMode ? ' (s)' : ''}.` - } - className="rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-0.5 text-xs text-current/85 outline-none focus:border-paper-400/70" - > - <option value="note">Note order</option> - <option value="priority">Priority</option> - </select> + <div + data-tasks-toolbar + className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-paper-300/45 px-4 py-1.5 text-xs text-current/60" + > + <div className="flex shrink-0 items-center gap-1 whitespace-nowrap"> + <span>Order</span> + <select + data-tasks-list-order + value={listOrder} + onChange={(e) => setTasksListOrder(e.target.value as TasksListOrder)} + title={ + listOrder === 'priority' + ? `Each group puts high priority first, then medium, low, and none; ties follow the due date. Note order brings back the notes' line order${vimMode ? ' (s)' : ''}.` + : `Each group follows its notes' line order, which J/K rearrange. Priority puts high priority first${vimMode ? ' (s)' : ''}.` + } + className="rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-0.5 text-xs text-current/85 outline-none focus:border-paper-400/70" + > + <option value="note">Note order</option> + <option value="priority">Priority</option> + </select> + </div> + <TasksViewOptions hidden={hiddenEmpty} /> + <div className="ml-auto shrink-0"> + <TasksKeysButton view="list" /> + </div> </div> )} @@ -1087,6 +1147,7 @@ export function TasksView(): JSX.Element { onMoveTask={(task, dateIso) => void moveTaskToDate(task, dateIso)} onAddTask={(dateIso, text) => addTaskForDate(dateIso, text)} dailyNotesEnabled={vaultSettings.dailyNotes.enabled} + hiddenEmpty={hiddenEmpty} /> )} @@ -1134,24 +1195,9 @@ export function TasksView(): JSX.Element { autoComplete="off" /> </form> - ) : ( - /* Each line names only keys that fire in the current mode. Single - keys are Vim's on every surface; arrows, Enter, Space, Tab and the - Shift+J/K chord are the universal ones. */ - <div className="border-t border-paper-300/45 px-4 py-1.5 text-xs text-current/40"> - {viewMode === 'list' - ? vimMode - ? `j/k move · ${listOrder === 'note' ? 'J/K reorder · ' : ''}Enter/o open · x toggle · i start · c cancel · @ due date · s order · F saved filters · right-click actions · :q close` - : `↑/↓ move · ${listOrder === 'note' ? 'Shift+J/K reorder · ' : ''}Enter open · right-click actions` - : viewMode === 'calendar' - ? vimMode - ? 'h/j/k/l day · [ ] month · Tab pick · x toggle · i start · c cancel · @ due date · F saved filters · drag to move · right-click actions · :q' - : '←/→/↑/↓ day · Tab pick · Space toggle · drag to move · right-click actions' - : vimMode - ? 'h/l column · j/k card · x toggle · i start · c cancel · @ due date · Enter open · s order · F saved filters · right-click actions · :q close' - : '←/→ column · ↑/↓ card · Space toggle · Enter open · right-click actions'} - </div> - )} + ) : null} + {/* No key-hint line under the views: each view's keys are behind its + Keys button (`?` in Vim mode), named for the mode that is on. */} {menu && ( <ContextMenu diff --git a/packages/app-core/src/components/icons.tsx b/packages/app-core/src/components/icons.tsx index 099620365..8f8e8b578 100644 --- a/packages/app-core/src/components/icons.tsx +++ b/packages/app-core/src/components/icons.tsx @@ -362,6 +362,33 @@ export const PinIcon = (p: IconProps): JSX.Element => ( </I> ) +/** View options: the Tasks views' View menu. */ +export const SlidersIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M4 6h10" /> + <path d="M18 6h2" /> + <circle cx="16" cy="6" r="2" /> + <path d="M4 12h4" /> + <path d="M12 12h8" /> + <circle cx="10" cy="12" r="2" /> + <path d="M4 18h12" /> + <path d="M20 18h0" /> + <circle cx="18" cy="18" r="2" /> + </I> +) + +/** Keyboard shortcuts: the Tasks views' Keys button. */ +export const KeyboardIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <rect x="2.5" y="6" width="19" height="12" rx="2" /> + <path d="M6.5 10h.01" /> + <path d="M10 10h.01" /> + <path d="M13.5 10h.01" /> + <path d="M17 10h.01" /> + <path d="M8 14h8" /> + </I> +) + export const ZapIcon = (p: IconProps): JSX.Element => ( <I {...p}> <path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" /> diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 363ee98e2..b791250ec 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1893,6 +1893,16 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma run: () => getState().setKanbanCardSort(getState().kanbanCardSort === 'due' ? 'manual' : 'due') }) + // Empty tasks (#1026), from any note: the list, calendar and board follow it. + cmds.push({ + id: 'view.hide-empty-tasks', + bindTitle: 'Tasks: Toggle Empty Tasks', + title: getState().hideEmptyTasks ? 'Tasks: Show Empty Tasks' : 'Tasks: Hide Empty Tasks', + category: 'View', + keywords: 'tasks empty blank placeholder template daily note kanban board list calendar hide show', + run: () => getState().setHideEmptyTasks(!getState().hideEmptyTasks) + }) + // The Tasks list's order (#989), from any note: the next list opened uses it. cmds.push({ id: 'view.tasks-list-order', diff --git a/packages/app-core/src/lib/empty-tasks.test.ts b/packages/app-core/src/lib/empty-tasks.test.ts new file mode 100644 index 000000000..39776e20d --- /dev/null +++ b/packages/app-core/src/lib/empty-tasks.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { parseTasksFromBody } from '@shared/tasks' +import { emptyTasksShowLabel, isEmptyTask, withoutEmptyTasks } from './empty-tasks' + +// A daily-note template's placeholders sit beside real work (#1026). +const body = [ + '# 2026-10-30', + '- [ ]', + '- [ ] ', + '- [ ] Call the bank', + '- [ ] due:2026-10-31', + '- [x] ', + '- [ ] !high' +].join('\n') +const tasks = parseTasksFromBody(body, { + path: 'Daily/2026-10-30.md', + title: '2026-10-30', + folder: 'inbox' +}) + +describe('empty tasks (#1026)', () => { + it('counts a bare or whitespace-only checkbox as empty', () => { + expect(tasks.map(isEmptyTask)).toEqual([true, true, false, false, true, false]) + }) + + it('never hides a task with text, even text that is only a token', () => { + const { shown, hidden } = withoutEmptyTasks(tasks) + expect(shown.map((task) => task.content)).toEqual([ + 'Call the bank', + 'due:2026-10-31', + '!high' + ]) + expect(hidden).toBe(3) + }) + + it('keeps the list itself when nothing is empty', () => { + const real = tasks.filter((task) => !isEmptyTask(task)) + const result = withoutEmptyTasks(real) + expect(result.shown).toBe(real) + expect(result.hidden).toBe(0) + }) + + it('says what showing the hidden ones brings back', () => { + expect(emptyTasksShowLabel(1)).toBe('Show 1 empty task') + expect(emptyTasksShowLabel(3)).toBe('Show 3 empty tasks') + }) +}) diff --git a/packages/app-core/src/lib/empty-tasks.ts b/packages/app-core/src/lib/empty-tasks.ts new file mode 100644 index 000000000..fbb513982 --- /dev/null +++ b/packages/app-core/src/lib/empty-tasks.ts @@ -0,0 +1,28 @@ +import type { VaultTask } from '@shared/tasks' + +/** + * A checkbox with nothing written after it: the `- [ ]` a daily-note template + * leaves for the day's tasks (#1026). The parser falls back to the raw tail + * when stripping tokens leaves nothing, so a task that is only `due:…` or + * `!high` still has text here and is never counted as empty. + */ +export function isEmptyTask(task: Pick<VaultTask, 'content'>): boolean { + return task.content.trim() === '' +} + +/** The tasks with text, and how many empty ones were left out. The same + * array comes back when nothing was empty, so memoized consumers keep + * their identity. */ +export function withoutEmptyTasks<T extends Pick<VaultTask, 'content'>>( + tasks: T[] +): { shown: T[]; hidden: number } { + const shown = tasks.filter((task) => !isEmptyTask(task)) + const hidden = tasks.length - shown.length + return { shown: hidden === 0 ? tasks : shown, hidden } +} + +/** The quiet "3 hidden" pill's tooltip, which also says what a click does, + * so the hidden placeholders read as hidden rather than lost. */ +export function emptyTasksShowLabel(count: number): string { + return `Show ${count} empty task${count === 1 ? '' : 's'}` +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 6f4a32d97..c799cc192 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -379,6 +379,16 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / No due date / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between No due date and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date (or on No due date to reopen it without one: that drop clears its `due:`, and a daily-note task, whose date is its note’s, cannot take it), and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Finished and cancelled tasks leave the boards, with one exception: a task note whose `status:` names one of your Kanban statuses stays in that column on the Custom status board, so a `done` column holds the task notes you finished, still checked. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' }, + { + title: 'Empty tasks stay out of the Tasks views', + body: + 'A bare `- [ ]`, like the ones a daily-note template leaves, is an empty task, and the Tasks list, calendar, and board leave it out. A quiet “3 hidden” beside the **View** button says how many; click it to show them. A task with any text always shows, even just `due:2026-11-02`. Turn it off in the View menu (`v`, then Hide empty tasks), with `E` in Vim mode, or in Settings → Tasks. Config key: `hide_empty_tasks` under `[view]`.' + }, + { + title: 'Keys for the Tasks views', + body: + 'Each Tasks view has a **Keys** button at the right of its toolbar (`?` in Vim mode) that lists its keys. With Vim mode off it shows only the keys that work then: the arrows, Enter, Space, and Tab.' + }, { title: 'Filter the Tasks views to one project', body: @@ -791,6 +801,9 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'x / d', action: 'Delete forever', detail: 'Trash view only: permanently delete the selected trashed note after confirmation.' }, { keys: '/', action: 'Filter the view', detail: 'Focus the local filter box for tasks, tag matches, or trashed notes. In Tags, a word from a tagged block narrows the list to that block.' }, { keys: 'F', action: 'Pick a saved Tasks filter', detail: 'Tasks view: open the list of saved filters and apply one. Save the current query with the Save filter… chip or `:savefilter <name>`.' }, + { keys: 'E', action: 'Show or hide empty tasks', detail: 'Tasks view: show or hide bare `- [ ]` tasks in the list, calendar, and board.' }, + { keys: 'v', action: 'View menu', detail: 'Tasks view: open the View menu (Hide empty tasks).' }, + { keys: '?', action: 'Keys', detail: 'Tasks view: list the current view’s keys. Esc closes it.' }, { keys: ':', action: 'Open local ex prompt', detail: 'Run the view-specific command line inside Tasks or Tags.' }, { keys: 'Esc', action: 'Clear the filter', detail: 'Clears an active filter. These views are tabs, so Esc no longer closes them. Close with :q or the ✕ in the tab header.' } ] @@ -1366,6 +1379,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ title: 'Tasks', items: [ { label: 'Show tasks from archived notes', detail: 'Off by default: archiving a note retires its tasks from the Tasks list, calendar, and Kanban. Turn this on to keep them visible everywhere, Archive column included. The raw key is `show_archived_tasks` in `config.toml`.' }, + { label: 'Hide empty tasks', detail: 'On by default: bare `- [ ]` tasks stay out of the Tasks views. Same as the View menu; config key `hide_empty_tasks`.' }, { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' }, { label: 'Tasks list order', detail: 'Note order keeps each group of the Tasks list in its notes’ line order, which Shift+J/K and dragging rearrange; Priority puts high priority first, then medium, low, and none, ties by due date. The list’s Order menu sets the same thing, and the raw key is `tasks_list_order` (`note` or `priority`) under `[view]` in `config.toml`.' }, { label: 'Kanban card order', detail: 'Manual keeps the cards in the order you drag them into; Due date sorts every Kanban column by due date, cards without one last, and keeps your manual order for when you switch back. The board\'s Order menu sets the same thing, and the raw key is `kanban_card_sort` (`manual` or `due`) under `[view]` in `config.toml`.' } diff --git a/packages/app-core/src/lib/tasks-keys.test.ts b/packages/app-core/src/lib/tasks-keys.test.ts new file mode 100644 index 000000000..0ff12b6d6 --- /dev/null +++ b/packages/app-core/src/lib/tasks-keys.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { tasksKeySheet, type TasksKeySheet } from './tasks-keys' + +const keysOf = (sheet: TasksKeySheet): string[] => + sheet.groups.flatMap((group) => group.rows.map((row) => row.keys)) + +// The keys Vim mode off leaves live on every Tasks surface: the arrows, +// Enter, Space, Tab and the menu key, plus the Shift+J/K reorder chord. +const VIM_OFF_KEYS = /^(← \/ →|↑ \/ ↓|Enter|Space|Tab|Shift\+F10|Shift\+J \/ K)$/ + +describe('the Tasks keys cheat sheet', () => { + for (const view of ['kanban', 'list', 'calendar'] as const) { + it(`names no Vim key on the ${view} with Vim mode off`, () => { + const sheet = tasksKeySheet(view, { vimMode: false, dndEnabled: true, listOrder: 'note' }) + expect(keysOf(sheet).length).toBeGreaterThan(0) + for (const key of keysOf(sheet)) expect(key).toMatch(VIM_OFF_KEYS) + expect(sheet.mouse).not.toBe('') + }) + + it(`keeps each ${view} row to a chip and a few words`, () => { + const sheet = tasksKeySheet(view, { vimMode: true, dndEnabled: true, listOrder: 'note' }) + for (const group of sheet.groups) { + for (const row of group.rows) { + expect(row.label.split(' ').length, `${row.keys} ${row.label}`).toBeLessThanOrEqual(3) + } + } + }) + } + + it('lists the board keys with Vim mode on', () => { + const keys = keysOf(tasksKeySheet('kanban', { vimMode: true, dndEnabled: true, listOrder: 'note' })) + for (const key of ['h / l', 'j / k', 'x', 'Enter', 'Shift+H / L', '< / >', 'g', 's', 'v', 'E', '?']) { + expect(keys).toContain(key) + } + }) + + it('keeps a Subtasks group for the subtasks keys, off until they exist', () => { + const titles = (subtasks: boolean, vimMode: boolean): string[] => + tasksKeySheet('kanban', { vimMode, dndEnabled: true, listOrder: 'note', subtasks }).groups.map((g) => g.title) + expect(titles(false, true)).not.toContain('Subtasks') + expect(titles(true, true)).toEqual(['Move', 'Cards', 'Subtasks', 'Columns', 'Board']) + // Tab and Esc are not letters: the group shows with Vim mode off too. + const off = tasksKeySheet('kanban', { vimMode: false, dndEnabled: true, listOrder: 'note', subtasks: true }) + expect(off.groups.map((g) => g.title)).toContain('Subtasks') + for (const key of keysOf(off)) expect(key).toMatch(/^(← \/ →|↑ \/ ↓|Enter|Space|Shift\+F10|Tab \/ Shift\+Tab|Esc)$/) + }) + + it('leaves card moves off a board that cannot move cards', () => { + const sheet = tasksKeySheet('kanban', { vimMode: true, dndEnabled: false, listOrder: 'note' }) + expect(keysOf(sheet)).not.toContain('Shift+H / L') + expect(sheet.mouse).not.toMatch(/cards/) + }) + + it('offers the list reorder only in note order', () => { + expect(keysOf(tasksKeySheet('list', { vimMode: true, dndEnabled: true, listOrder: 'note' }))).toContain('J / K') + expect(keysOf(tasksKeySheet('list', { vimMode: true, dndEnabled: true, listOrder: 'priority' }))).not.toContain('J / K') + expect(keysOf(tasksKeySheet('list', { vimMode: false, dndEnabled: true, listOrder: 'priority' }))).not.toContain('Shift+J / K') + }) +}) diff --git a/packages/app-core/src/lib/tasks-keys.ts b/packages/app-core/src/lib/tasks-keys.ts new file mode 100644 index 000000000..3ea0da0d7 --- /dev/null +++ b/packages/app-core/src/lib/tasks-keys.ts @@ -0,0 +1,238 @@ +/** + * The Tasks views' keyboard cheat sheet: what the Keys button (`?` in Vim + * mode) shows for the list, the calendar and the board. It replaced the + * always-visible hint lines, which had grown into a paragraph nobody read. + * + * Every key a Tasks surface answers stays named here, so no binding lost its + * discoverability when the lines went. Vim mode decides the set, the rule + * every list follows: with it off only the arrows, Enter, Space, Tab and the + * menu key are live, so only they appear (plus the mouse line), and a letter + * is never named where it would do nothing. + */ +export type TasksKeysView = 'list' | 'calendar' | 'kanban' + +export interface TasksKeyRow { + /** The chip: one key, or a pair like `h / l`. */ + keys: string + /** One to three words. */ + label: string +} + +export interface TasksKeyGroup { + title: string + rows: TasksKeyRow[] +} + +export interface TasksKeySheet { + groups: TasksKeyGroup[] + /** The mouse equivalents, one muted line. */ + mouse: string +} + +export interface TasksKeySheetOptions { + vimMode: boolean + /** The board can move cards (every board but Folder). */ + dndEnabled: boolean + /** The list reorders tasks only in its notes' order. */ + listOrder: 'note' | 'priority' + /** The board's cards step into their subtasks (Tab / Shift+Tab, Esc back). + * Reserved for the subtasks branch, which binds those keys; off until + * then, so the sheet never names a key that does nothing. */ + subtasks?: boolean +} + +/** The board's Subtasks group, shown once `subtasks` is on. Tab and Esc are + * not letters, so it belongs to both modes. */ +const SUBTASK_GROUP: TasksKeyGroup = { + title: 'Subtasks', + rows: [ + { keys: 'Tab / Shift+Tab', label: 'step through' }, + { keys: 'Esc', label: 'back to card' } + ] +} + +/** The view-wide keys every sub-view answers in Vim mode, after the view's + * own `s` order where it has one. The view switch (1 2 3) and New task (a) + * name their keys in their buttons' tooltips, so the sheet leaves them out. */ +function viewRows(order: string | null): TasksKeyRow[] { + return [ + ...(order ? [{ keys: 's', label: order }] : []), + { keys: 'v', label: 'view menu' }, + { keys: 'E', label: 'empty tasks' }, + { keys: '/', label: 'filter' }, + { keys: 'F', label: 'saved filters' }, + { keys: '?', label: 'these keys' } + ] +} + +function kanbanSheet(options: TasksKeySheetOptions): TasksKeySheet { + const sheet = kanbanGroups(options) + if (options.subtasks) sheet.groups.splice(2, 0, SUBTASK_GROUP) + return sheet +} + +function kanbanGroups({ vimMode, dndEnabled }: TasksKeySheetOptions): TasksKeySheet { + const mouse = dndEnabled + ? 'Drag cards and headers · right-click for menus' + : 'Drag headers · right-click for menus' + if (!vimMode) { + return { + groups: [ + { + title: 'Move', + rows: [ + { keys: '← / →', label: 'columns' }, + { keys: '↑ / ↓', label: 'cards' } + ] + }, + { + title: 'Cards', + rows: [ + { keys: 'Space', label: 'toggle' }, + { keys: 'Enter', label: 'open' } + ] + } + ], + mouse + } + } + return { + groups: [ + { + title: 'Move', + rows: [ + { keys: 'h / l', label: 'columns' }, + { keys: 'j / k', label: 'cards' } + ] + }, + { + title: 'Cards', + // The card's states (i start, c cancel, @ due date) are in its + // right-click menu, each with its key. Subtasks get a group of their + // own (SUBTASK_GROUP), right after this one. + rows: [ + { keys: 'x', label: 'toggle' }, + { keys: 'Enter', label: 'open' }, + ...(dndEnabled ? [{ keys: 'Shift+H / L', label: 'move' }] : []) + ] + }, + { title: 'Columns', rows: [{ keys: '< / >', label: 'reorder' }] }, + { title: 'Board', rows: [{ keys: 'g', label: 'group by' }, ...viewRows('order')] } + ], + mouse + } +} + +function listSheet({ vimMode, listOrder }: TasksKeySheetOptions): TasksKeySheet { + const reorder = listOrder === 'note' + const mouse = reorder ? 'Drag to reorder · right-click for actions' : 'Right-click for actions' + if (!vimMode) { + return { + groups: [ + { + title: 'Move', + rows: [ + { keys: '↑ / ↓', label: 'tasks' }, + ...(reorder ? [{ keys: 'Shift+J / K', label: 'reorder' }] : []) + ] + }, + { title: 'Tasks', rows: [{ keys: 'Enter', label: 'open' }] } + ], + mouse + } + } + return { + groups: [ + { + title: 'Move', + rows: [ + { keys: 'j / k', label: 'tasks' }, + { keys: 'g g / G', label: 'top, bottom' }, + ...(reorder ? [{ keys: 'J / K', label: 'reorder' }] : []) + ] + }, + { + title: 'Task', + rows: [ + { keys: 'x', label: 'toggle' }, + { keys: 'Enter', label: 'open' }, + { keys: 'i', label: 'start' }, + { keys: 'c', label: 'cancel' }, + { keys: '@', label: 'due date' }, + { keys: '>', label: 'forward' } + ] + }, + { title: 'List', rows: viewRows('order') } + ], + mouse + } +} + +function calendarSheet({ vimMode }: TasksKeySheetOptions): TasksKeySheet { + const mouse = 'Drag to another day · right-click for actions' + if (!vimMode) { + return { + groups: [ + { + title: 'Move', + rows: [ + { keys: '← / →', label: 'days' }, + { keys: '↑ / ↓', label: 'weeks' } + ] + }, + { + title: 'Tasks', + rows: [ + { keys: 'Tab', label: 'next task' }, + { keys: 'Space', label: 'toggle' }, + { keys: 'Enter', label: 'open' } + ] + } + ], + mouse + } + } + return { + groups: [ + { + title: 'Move', + rows: [ + { keys: 'h / l', label: 'days' }, + { keys: 'j / k', label: 'weeks' }, + { keys: '[ / ]', label: 'months' }, + { keys: 'g t', label: 'today' } + ] + }, + { + title: 'Task', + rows: [ + { keys: 'Tab', label: 'next task' }, + { keys: 'x', label: 'toggle' }, + { keys: 'Enter', label: 'open' }, + { keys: 'e', label: 'edit' }, + { keys: 'm', label: 'move' }, + { keys: 'd d', label: 'delete' }, + { keys: 'i', label: 'start' }, + { keys: 'c', label: 'cancel' } + ] + }, + { + title: 'Dates', + rows: [ + { keys: '< / >', label: 'due ±1 day' }, + { keys: 'T', label: 'due today' }, + { keys: '@', label: 'due date' }, + { keys: 'a', label: 'add to day' } + ] + }, + { title: 'Calendar', rows: viewRows(null) } + ], + mouse + } +} + +export function tasksKeySheet(view: TasksKeysView, options: TasksKeySheetOptions): TasksKeySheet { + if (view === 'kanban') return kanbanSheet(options) + if (view === 'calendar') return calendarSheet(options) + return listSheet(options) +} diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 6c9b6e588..70202e421 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -2145,6 +2145,23 @@ describe('folder Gallery', () => { }) }) +describe('hideEmptyTasks (#1026)', () => { + it('hides empty tasks by default and remembers showing them', async () => { + installZen() + const { useStore } = await loadStore() + expect(useStore.getState().hideEmptyTasks).toBe(true) + + useStore.getState().setHideEmptyTasks(false) + expect(useStore.getState().hideEmptyTasks).toBe(false) + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.hideEmptyTasks).toBe(false) + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().hideEmptyTasks).toBe(false) + }) +}) + describe('kanbanCardSort (#889)', () => { it('defaults to the manual order and round-trips due-date ordering', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 1d9b444bc..9d281fae2 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -817,6 +817,10 @@ interface Prefs { * they were dragged into (due date until then), `due` always sorts them by * due date and leaves that arrangement untouched for later. (#889) */ kanbanCardSort: KanbanCardSort + /** Leave tasks with no text (a template's bare `- [ ]`) off the Tasks + * list, calendar and Kanban board. On by default; portable as + * `hide_empty_tasks` under [view]. (#1026) */ + hideEmptyTasks: boolean /** Tasks list groups in their notes' line order or by priority. (#989) */ tasksListOrder: TasksListOrder /** How far ahead the Timeline panel reaches. (#1007) */ @@ -1266,6 +1270,7 @@ export const DEFAULT_PREFS: Prefs = { showArchivedTasks: false, kanbanGroupBy: 'status', kanbanCardSort: 'manual', + hideEmptyTasks: true, tasksListOrder: 'note', timelineRange: DEFAULT_TIMELINE_RANGE, gallerySort: 'updated', @@ -1651,6 +1656,8 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { : DEFAULT_PREFS.showArchivedTasks, kanbanGroupBy: normalizeKanbanGroupBy(p.kanbanGroupBy), kanbanCardSort: p.kanbanCardSort === 'due' ? 'due' : 'manual', + hideEmptyTasks: + typeof p.hideEmptyTasks === 'boolean' ? p.hideEmptyTasks : DEFAULT_PREFS.hideEmptyTasks, tasksListOrder: p.tasksListOrder === 'priority' ? 'priority' : 'note', timelineRange: normalizeTimelineRange(p.timelineRange), gallerySort: normalizeGallerySort(p.gallerySort), @@ -2625,6 +2632,7 @@ function collectPrefs(s: { showArchivedTasks: boolean kanbanGroupBy: KanbanGroupBy kanbanCardSort: KanbanCardSort + hideEmptyTasks: boolean tasksListOrder: TasksListOrder timelineRange: TimelineRange gallerySort: GallerySort @@ -2746,6 +2754,7 @@ function collectPrefs(s: { showArchivedTasks: s.showArchivedTasks, kanbanGroupBy: s.kanbanGroupBy, kanbanCardSort: s.kanbanCardSort, + hideEmptyTasks: s.hideEmptyTasks, tasksListOrder: s.tasksListOrder, timelineRange: s.timelineRange, gallerySort: s.gallerySort, @@ -3432,6 +3441,8 @@ interface Store { kanbanGroupBy: KanbanGroupBy /** Cards in each Kanban column by hand-placed order or by due date. (#889) */ kanbanCardSort: KanbanCardSort + /** Tasks with no text stay off the Tasks list, calendar and board. (#1026) */ + hideEmptyTasks: boolean /** Tasks list groups in note order or by priority. (#989) */ tasksListOrder: TasksListOrder /** How far ahead the Timeline panel reaches: two weeks, a month, or every @@ -3719,6 +3730,7 @@ interface Store { confirmArchiveNotes: (paths: string[]) => Promise<boolean> setKanbanGroupBy: (group: KanbanGroupBy) => void setKanbanCardSort: (sort: KanbanCardSort) => void + setHideEmptyTasks: (hide: boolean) => void setTasksListOrder: (order: TasksListOrder) => void setTimelineRange: (range: TimelineRange) => void setTimelineFilter: (query: string) => void @@ -6306,6 +6318,7 @@ export const useStore = create<Store>((set, get) => { showArchivedTasks: loadPrefs().showArchivedTasks, kanbanGroupBy: loadPrefs().kanbanGroupBy, kanbanCardSort: loadPrefs().kanbanCardSort, + hideEmptyTasks: loadPrefs().hideEmptyTasks, tasksListOrder: loadPrefs().tasksListOrder, timelineRange: loadPrefs().timelineRange, timelineFilter: '', @@ -7843,6 +7856,10 @@ export const useStore = create<Store>((set, get) => { set({ kanbanCardSort: sort }) savePrefs(collectPrefs(get())) }, + setHideEmptyTasks: (hide) => { + set({ hideEmptyTasks: hide }) + savePrefs(collectPrefs(get())) + }, setTasksListOrder: (order) => { set({ tasksListOrder: order }) savePrefs(collectPrefs(get())) diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index b139afc1f..33b7d09e9 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -105,6 +105,7 @@ export const PORTABLE_PREF_KEYS = [ 'showArchivedTasks', 'kanbanGroupBy', 'kanbanCardSort', + 'hideEmptyTasks', 'tasksListOrder', 'timelineRange', 'gallerySort', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index f5ef5ff98..52f58b876 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -171,6 +171,7 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { showArchivedTasks: false, kanbanGroupBy: 'status', kanbanCardSort: 'manual', + hideEmptyTasks: true, tasksListOrder: 'note', timelineRange: 'two-weeks', gallerySort: 'updated', diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index cdf02bc8e..18b533796 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -171,6 +171,7 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { id: "view.line-numbers.absolute", title: "Line Numbers: Absolute", category: "View" }, { id: "view.line-numbers.relative", title: "Line Numbers: Relative", category: "View" }, { id: "view.kanban-card-sort", title: "Kanban: Toggle Card Order", category: "View", keywords: "tasks board sort due date deadline order cards columns manual" }, + { id: "view.hide-empty-tasks", title: "Tasks: Toggle Empty Tasks", category: "View", keywords: "tasks empty blank placeholder template daily note kanban board list calendar hide show" }, { id: "view.tasks-list-order", title: "Tasks: Toggle List Order", category: "View", keywords: "tasks list sort priority high order note line" }, { id: "editor.vim.toggle", title: "Toggle Vim Mode", category: "Editor" }, { id: "editor.completed-task.none", title: "Completed Tasks: No Style", category: "Editor" }, From fa5ba5a6cced0eda111f3f0e43e29354767d9178 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 12:12:56 -0500 Subject: [PATCH 42/53] Feat(kanban): a column can carry a WIP limit, and turns red past it (#1025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board shows how much work sits in each column but had no way to say how much should (williamcheuk03, #1025: show a WIP limit, highlight a column over it in red). Any column on any board can now carry a limit: Set WIP limit… in the column header's right-click menu, `W` on the board in Vim mode, or `:wip 3` on the Tasks ex line, all for the column under the cursor. Bare `:wip` and the menu's Clear WIP limit take it off. The header then reads `2 / 3`; a column holding more than its limit gets the danger tokens (border, tint and a warning sign beside `4 / 3`), while one exactly at its limit stays as it was. The count is everything the column holds before the filter narrows it, since a limit is about the work and not about what a query shows, and it leaves out the empty tasks the board hides (#1026). Limits are kept per board and column under the column-title key (`status:in-progress`, `field:status:review`), global and portable as the new `[kanban_wip_limits]` table in config.toml, the first map table that holds numbers; like the card order (#889) it is not a per-vault view override, which would need the Go server's vault.json mirror to learn it. Hand-edited values that are not whole numbers from 1 to 999 are dropped, never guessed. The column menu needed a way in from the keyboard, so the cursor can now stand on a column's header: `↑` (or `k`) from the first card reaches it, the way the database grid's header row works, `←`/`→` keep it on the headers, and `↓` comes back to the cards. There, Enter renames the column (until now only a click on its title did) and Shift+F10 or the Context Menu key opens the header's menu, in either mode; on a card those keys open the card's own menu, as do `m` in Vim mode. A header menu opens below its header so the count stays in view. The Keys sheet names the new keys, `W` only with Vim mode on. Tests: the limit input (1 to 999, empty clears, 0 and fractions refused), over only past the limit, loads counted before the filter and without hidden empty tasks, the cursor's climb onto the header and back, the store's per-column limits through a restart and its cleanup of bad values, and the `[kanban_wip_limits]` round trip. Over CDP on the built app, both stores isolated: `l l l k` then Shift+F10 on In progress opens Rename column… / Set WIP limit…; a limit of 3 over four cards turns the column red with `4 / 3`; `:wip 5` reads `4 / 5` and is not red; bare `:wip` clears it; `W` sets it again; the limit survives a restart, read back from the saved config.toml; with Vim off, ↑ and Shift+F10 reach the menu, which names no `W`, and `W` does nothing. --- apps/desktop/src/main/app-config.test.ts | 6 +- apps/desktop/src/main/app-config.ts | 17 +- .../src/components/TasksKanban.test.ts | 59 +++- .../app-core/src/components/TasksKanban.tsx | 286 ++++++++++++++++-- .../app-core/src/components/TasksView.tsx | 23 +- packages/app-core/src/components/icons.tsx | 9 + packages/app-core/src/lib/help.ts | 13 +- packages/app-core/src/lib/tasks-keys.test.ts | 4 +- packages/app-core/src/lib/tasks-keys.ts | 32 +- packages/app-core/src/store.test.ts | 56 ++++ packages/app-core/src/store.ts | 77 ++++- packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + 13 files changed, 536 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index ab44cecf2..41d74f609 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -134,6 +134,7 @@ describe('TOML serialization', () => { kanbanGroupBy: 'folder', kanbanCardSort: 'due', hideEmptyTasks: false, + kanbanWipLimits: { 'status:in-progress': 3, 'field:status:review': 2 }, tasksListOrder: 'priority', timelineRange: 'month', gallerySort: 'title', @@ -174,9 +175,12 @@ describe('TOML serialization', () => { expect(round.kanbanGroupBy).toBe('folder') expect(text).toContain('kanban_card_sort = "due"') expect(round.kanbanCardSort).toBe('due') - // #1026: the empty-task switch travels too. + // #1026 and #1025: the empty-task switch and the WIP limits travel too, + // the limits as plain numbers a person can edit. expect(text).toContain('hide_empty_tasks = false') expect(round.hideEmptyTasks).toBe(false) + expect(text).toMatch(/\[kanban_wip_limits\]\n"status:in-progress" = 3\n"field:status:review" = 2/) + expect(round.kanbanWipLimits).toEqual({ 'status:in-progress': 3, 'field:status:review': 2 }) expect(text).toContain('tasks_list_order = "priority"') expect(round.tasksListOrder).toBe('priority') // The Timeline panel's reach travels as [view].timeline_range (#1007). diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 219f623fe..875d92ca1 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -508,9 +508,9 @@ const LIST_FIELDS: Partial<Record<PortablePrefKey, ListFieldMap>> = { } } -/** A map-valued portable pref rendered as its own TOML table of string→string, - * always emitted (even when empty) with a header comment + example so users - * can discover the format. */ +/** A map-valued portable pref rendered as its own TOML table of string→string + * (string→number for the WIP limits), always emitted (even when empty) with a + * header comment + example so users can discover the format. */ interface MapTableField { table: string /** Lines of `#` comment shown above the table header. */ @@ -539,6 +539,11 @@ const MAP_TABLE_FIELDS: Partial<Record<PortablePrefKey, MapTableField>> = { comment: ['Kanban column title overrides, keyed by "<groupBy>:<columnId>".'], example: '"status:todo" = "To Do"' }, + kanbanWipLimits: { + table: 'kanban_wip_limits', + comment: ['Kanban WIP limits (1 to 999), keyed by "<groupBy>:<columnId>".'], + example: '"status:in-progress" = 3' + }, enabledOverrides: { table: 'overrides', comment: ['Enabled CSS overrides. List the filenames you want active.'], @@ -699,7 +704,11 @@ export function serializeConfig(portable: AppConfigPortable): string { const value = portable[key] if (value && typeof value === 'object' && !Array.isArray(value)) { for (const [entryKey, entryValue] of Object.entries(value as Record<string, unknown>)) { - if (typeof entryValue === 'string') { + // Strings for most tables; [kanban_wip_limits] holds plain numbers. + if ( + typeof entryValue === 'string' || + (typeof entryValue === 'number' && Number.isFinite(entryValue)) + ) { lines.push(`${tomlKey(entryKey)} = ${tomlValue(entryValue)}`) } } diff --git a/packages/app-core/src/components/TasksKanban.test.ts b/packages/app-core/src/components/TasksKanban.test.ts index ae596f807..6680c2714 100644 --- a/packages/app-core/src/components/TasksKanban.test.ts +++ b/packages/app-core/src/components/TasksKanban.test.ts @@ -20,7 +20,11 @@ import { noteLocationOf, orderCards, FOLDER_OTHER_LABEL, - hideEmptyCards + columnWipLoads, + hideEmptyCards, + kanbanCardStep, + parseWipLimitInput, + wipState } from './TasksKanban' function col(id: string): Column { @@ -673,3 +677,56 @@ describe('empty tasks stay off the board (#1026)', () => { expect(result.columns[0]).toBe(board[0]) }) }) + +describe('WIP limits (#1025)', () => { + it('reads a limit as a whole number from 1 to 999, and empty as no limit', () => { + expect(parseWipLimitInput('3')).toEqual({ ok: true, limit: 3 }) + expect(parseWipLimitInput(' 12 ')).toEqual({ ok: true, limit: 12 }) + expect(parseWipLimitInput('999')).toEqual({ ok: true, limit: 999 }) + expect(parseWipLimitInput('')).toEqual({ ok: true, limit: null }) + expect(parseWipLimitInput(' ')).toEqual({ ok: true, limit: null }) + for (const bad of ['0', '-1', '2.5', 'three', '1000', '3 cards']) { + expect(parseWipLimitInput(bad).ok, bad).toBe(false) + } + }) + + it('is over only past the limit, not at it', () => { + expect(wipState(4, undefined)).toBeNull() + expect(wipState(2, 3)).toEqual({ load: 2, limit: 3, over: false }) + expect(wipState(3, 3)).toEqual({ load: 3, limit: 3, over: false }) + expect(wipState(4, 3)).toEqual({ load: 4, limit: 3, over: true }) + }) + + it('counts every card a column holds, whatever the filter narrows it to', () => { + // The loads come from the board before the filter, so a column holding + // four cards reads 4 / 3 even while the filter shows one of them. + const full = [ + colWith('in-progress', [ + card({ content: 'a' }), + card({ content: 'b', taskIndex: 1 }), + card({ content: '', taskIndex: 2 }) + ]) + ] + expect(columnWipLoads(full, false).get('in-progress')).toBe(3) + // A placeholder the board hides is not work in progress either. + expect(columnWipLoads(full, true).get('in-progress')).toBe(2) + }) +}) + +describe('the cursor climbs onto the column header (#1025)', () => { + it('goes from the first card up to the header, and stays there', () => { + expect(kanbanCardStep(0, -1, 3)).toBe(-1) + expect(kanbanCardStep(-1, -1, 3)).toBe(-1) + expect(kanbanCardStep(2, -1, 3)).toBe(1) + }) + + it('comes back down to the first card, and stops at the last', () => { + expect(kanbanCardStep(-1, 1, 3)).toBe(0) + expect(kanbanCardStep(2, 1, 3)).toBe(2) + }) + + it('keeps an empty column on its header', () => { + expect(kanbanCardStep(-1, 1, 0)).toBe(-1) + expect(kanbanCardStep(0, -1, 0)).toBe(-1) + }) +}) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 645e01ae4..b703dcb64 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -21,10 +21,13 @@ import { dropMutationsFor } from '../lib/task-column-mutations' * toggle in place. * * Vim navigation: - * h/l — move between columns - * j/k — move between cards within the active column - * Enter — open the card's source note - * Space / x — toggle the checkbox on the focused card + * h/l: move between columns + * j/k: move between cards within the active column; k above the first + * card reaches the column's header (the arrows too, in either mode) + * Enter: open the card's source note; on a header, rename the column + * Space / x: toggle the checkbox on the focused card + * W: set the focused column's WIP limit (#1025) + * m, or Shift+F10 in either mode: the card's or the header's menu */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' @@ -39,6 +42,7 @@ import { promptApp } from '../lib/prompt-requests' import type { VaultTask } from '@shared/tasks' import { groupTasks, isOverdue as isTaskOverdue, toIsoDateLocal } from '@shared/tasks' import { + MAX_KANBAN_WIP_LIMIT, normalizeKanbanFolderRoot, useStore, type KanbanCardSort, @@ -48,9 +52,11 @@ import { import { filterTasks, type NoteTagsByPath } from '../lib/tasks-filter' import { isEmptyTask } from '../lib/empty-tasks' import { isOverlayOrDialogOpen } from '../lib/overlay-open' +import { isKeyboardContextMenuKey, keyboardMenuPosition } from '../lib/keyboard-context-menu' +import { listViewBindingMatches } from '../lib/list-view-keys' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' -import { ArrowUpRightIcon, PencilIcon } from './icons' +import { AlertTriangleIcon, ArrowUpRightIcon, PencilIcon } from './icons' import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' import { InlineMarkdown } from '../lib/inline-markdown' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' @@ -75,6 +81,16 @@ interface Props { today: Date onOpenTask: (task: VaultTask) => void onToggleTask: (task: VaultTask) => void + /** Filled while the board is mounted, for the Tasks ex line's `:wip`. */ + controlRef?: React.MutableRefObject<KanbanControl | null> +} + +/** What the Tasks view's ex line asks of the board, which alone knows the + * column under its cursor. */ +export interface KanbanControl { + /** Set or clear the focused column's WIP limit (#1025). Returns that + * column's label, or null on a board with no columns. */ + setFocusedColumnWipLimit: (limit: number | null) => string | null } // Sentinel column id for the "No status" bucket on the custom-status board. @@ -613,6 +629,55 @@ export function hideEmptyCards( return { columns: hidden === 0 ? columns : next, hidden } } +/** How many cards each column holds against its WIP limit (#1025). Counted + * on the board before the filter narrows it: a limit is about the work in + * the column, not about what a query shows of it. A placeholder the board + * hides is not work either, so it stops counting while hidden. */ +export function columnWipLoads(fullColumns: Column[], hideEmpty: boolean): Map<string, number> { + return new Map( + fullColumns.map((column) => [ + column.id, + hideEmpty ? column.tasks.filter((task) => !isEmptyTask(task)).length : column.tasks.length + ]) + ) +} + +export interface WipState { + load: number + limit: number + /** More cards than the limit. At the limit is full, not over. */ + over: boolean +} + +export function wipState(load: number, limit: number | undefined): WipState | null { + if (limit === undefined) return null + return { load, limit, over: load > limit } +} + +/** A typed WIP limit: a whole number from 1 to the cap, or nothing for no + * limit. `0` is refused rather than read as "clear": a column that should + * hold nothing is not something to guess at. */ +export function parseWipLimitInput( + raw: string +): { ok: true; limit: number | null } | { ok: false; error: string } { + const text = raw.trim() + if (!text) return { ok: true, limit: null } + const value = /^\d+$/.test(text) ? Number(text) : Number.NaN + if (!Number.isInteger(value) || value < 1 || value > MAX_KANBAN_WIP_LIMIT) { + return { ok: false, error: `A whole number, 1 to ${MAX_KANBAN_WIP_LIMIT}.` } + } + return { ok: true, limit: value } +} + +/** The card j/k or the arrows move the cursor to. Above the first card sits + * the column's header (-1), where Enter renames the column and Shift+F10 + * opens its menu, the way the database grid's header row works (#1025). An + * empty column keeps the cursor on its header. */ +export function kanbanCardStep(cardIdx: number, delta: -1 | 1, cardCount: number): number { + if (cardCount === 0) return -1 + return Math.max(-1, Math.min(cardCount - 1, cardIdx + delta)) +} + /** How long an armed `g` waits for a tab-sequence completion before the * group-by cycle fires. Matches VimNav's own gt/gT pending window. */ export const GROUP_BY_PENDING_MS = 500 @@ -716,11 +781,14 @@ export function TasksKanban({ noteTags, today, onOpenTask, - onToggleTask + onToggleTask, + controlRef }: Props): JSX.Element { const groupBy = useStore((s) => s.kanbanGroupBy) const cardSort = useStore((s) => s.kanbanCardSort) const hideEmptyTasks = useStore((s) => s.hideEmptyTasks) + const kanbanWipLimits = useStore((s) => s.kanbanWipLimits) + const setKanbanWipLimit = useStore((s) => s.setKanbanWipLimit) const setCardSort = useStore((s) => s.setKanbanCardSort) const setGroupBy = useStore((s) => s.setKanbanGroupBy) const kanbanColumnTitles = useStore((s) => s.kanbanColumnTitles) @@ -785,6 +853,7 @@ export function TasksKanban({ const dropIndicatorFrameRef = useRef<number | null>(null) const dropIndicatorRectRef = useRef<{ x: number; y: number; width: number } | null>(null) const cardRef = useRef<HTMLDivElement | null>(null) + const headerRef = useRef<HTMLDivElement | null>(null) const dragOverColumnRef = useRef<string | null>(null) const dragOverElementRef = useRef<HTMLElement | null>(null) const suppressCardClickUntilRef = useRef(0) @@ -906,6 +975,10 @@ export function TasksKanban({ const hidden = hideEmptyCards(narrowed, today) return { columns: hidden.columns, hiddenEmpty: hidden.hidden } }, [fullColumns, filterQuery, hideEmptyTasks, noteTags, today]) + const wipLoads = useMemo( + () => columnWipLoads(fullColumns, hideEmptyTasks), + [fullColumns, hideEmptyTasks] + ) columnsRef.current = columns fullColumnsRef.current = fullColumns @@ -983,6 +1056,75 @@ export function TasksKanban({ setEditingTitle('') }, []) + // ---- WIP limits (#1025): the column menu, `W`, and `:wip` -------------- + const promptWipLimit = useCallback( + async (column: Column): Promise<void> => { + const current = useStore.getState().kanbanWipLimits[columnOrderKey(groupBy, column.id)] + const answer = await promptApp({ + title: `WIP limit: ${column.label}`, + description: 'Most cards before it turns red. Empty for none.', + initialValue: current ? String(current) : '', + placeholder: '3', + okLabel: 'Set', + allowEmptySubmit: true, + plainInput: true, + validate: (value) => { + const parsed = parseWipLimitInput(value) + return parsed.ok ? null : parsed.error + } + }) + if (answer === null) return + const parsed = parseWipLimitInput(answer) + if (parsed.ok) setKanbanWipLimit(groupBy, column.id, parsed.limit) + }, + [groupBy, setKanbanWipLimit] + ) + + const openColumnMenu = useCallback( + (column: Column, x: number, y: number): void => { + const limit = useStore.getState().kanbanWipLimits[columnOrderKey(groupBy, column.id)] + const items: ContextMenuItem[] = [ + { label: 'Rename column…', hint: 'Enter', onSelect: () => beginColumnRename(column) }, + { + label: limit ? 'Change WIP limit…' : 'Set WIP limit…', + hint: vimMode ? 'W' : undefined, + keywords: ['wip', 'limit', 'cap', 'maximum', 'work in progress'], + onSelect: () => void promptWipLimit(column) + } + ] + if (limit) { + items.push({ + label: 'Clear WIP limit', + keywords: ['wip', 'limit', 'remove'], + onSelect: () => setKanbanWipLimit(groupBy, column.id, null) + }) + } + setMenu({ x, y, items }) + }, + [beginColumnRename, groupBy, promptWipLimit, setKanbanWipLimit, vimMode] + ) + + // The ex line's `:wip` acts on the column under the cursor, which only the + // board knows; the latest cursor is read when the command runs. + const colIdxRef = useRef(colIdx) + colIdxRef.current = colIdx + useEffect(() => { + if (!controlRef) return + const control: KanbanControl = { + setFocusedColumnWipLimit: (limit) => { + const cols = columnsRef.current + const column = cols[Math.min(colIdxRef.current, cols.length - 1)] + if (!column) return null + setKanbanWipLimit(useStore.getState().kanbanGroupBy, column.id, limit) + return column.label + } + } + controlRef.current = control + return () => { + if (controlRef.current === control) controlRef.current = null + } + }, [controlRef, setKanbanWipLimit]) + // ---- Column reordering: drag a header, or Shift+`<` / Shift+`>` ---------- // A click that started a header drag must not also fire the rename button. const shouldSuppressColumnRename = useCallback( @@ -1687,6 +1829,44 @@ export function TasksKanban({ ? Math.min(cardIdx, Math.max(0, focusedColumn.tasks.length - 1)) : 0 const focusedTask = focusedColumn?.tasks[safeCardIdx] + // No card under the cursor: k above the first card, or an empty column. + // The header is what Enter and the menu keys act on then (#1025). + const headerFocused = !!focusedColumn && !focusedTask + + // Shift+F10, the Context Menu key, or `m` in Vim mode: the right-click menu + // of what the cursor is on, a card's task menu or its column's header menu. + // A header that holds DOM focus (Tab reached its title) speaks for itself. + const openKeyboardMenu = useCallback((): void => { + const active = document.activeElement as HTMLElement | null + const focusedHeader = active?.closest<HTMLElement>('[data-kanban-column-header]') + const domColumnId = focusedHeader + ?.closest<HTMLElement>('[data-kanban-column-id]') + ?.dataset.kanbanColumnId + const domColIdx = domColumnId + ? columnsRef.current.findIndex((column) => column.id === domColumnId) + : -1 + // A header's menu opens below it, so its count stays in view. + const belowHeader = (el: HTMLElement): { x: number; y: number } => { + const rect = el.getBoundingClientRect() + return { x: rect.left + 12, y: rect.bottom + 2 } + } + if (focusedHeader && domColIdx >= 0) { + setColIdx(domColIdx) + setCardIdx(-1) + const { x, y } = belowHeader(focusedHeader) + openColumnMenu(columnsRef.current[domColIdx], x, y) + return + } + if (focusedTask && cardRef.current) { + const { x, y } = keyboardMenuPosition(cardRef.current.getBoundingClientRect()) + setMenu({ x, y, items: buildTaskMenuItems(focusedTask, { today, showKeyHints: vimMode }) }) + return + } + if (focusedColumn && headerRef.current) { + const { x, y } = belowHeader(headerRef.current) + openColumnMenu(focusedColumn, x, y) + } + }, [focusedColumn, focusedTask, openColumnMenu, today, vimMode]) // Switching the order keeps the cursor on its card, wherever the new order // puts it: the rebuild is flushed first, the way a move is. (#889) @@ -1707,8 +1887,9 @@ export function TasksKanban({ ) useEffect(() => { - cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) - }, [safeColIdx, safeCardIdx, focusedTask?.id]) + if (headerFocused) headerRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + else cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, [headerFocused, safeColIdx, safeCardIdx, focusedTask?.id]) useEffect(() => clearDropTarget, [clearDropTarget]) @@ -1757,6 +1938,17 @@ export function TasksKanban({ } if (e.metaKey || e.ctrlKey || e.altKey) return + // The menu keys work in either mode (Shift+F10 and the Context Menu + // key are not letters); `m` is Vim's, as in the sidebar and the lists. + if ( + isKeyboardContextMenuKey(e) || + listViewBindingMatches(e, useStore.getState().keymapOverrides, 'nav.contextMenu', vimMode) + ) { + e.preventDefault() + e.stopImmediatePropagation() + openKeyboardMenu() + return + } // With Vim off the single-character keys stay with the page, the rule // every list in the app follows: arrows, Enter, Space and Escape are // universal, the letters (and < > H L) are Vim's. The board kept them @@ -1786,17 +1978,19 @@ export function TasksKanban({ cycleGroupBy() }, GROUP_BY_PENDING_MS) return + // Across the headers the cursor stays on the headers, so a row of + // columns can be walked for their menus (#1025). case 'h': case 'ArrowLeft': consume() setColIdx((i) => Math.max(0, i - 1)) - setCardIdx(0) + setCardIdx(safeCardIdx < 0 ? -1 : 0) return case 'l': case 'ArrowRight': consume() setColIdx((i) => Math.min(columns.length - 1, i + 1)) - setCardIdx(0) + setCardIdx(safeCardIdx < 0 ? -1 : 0) return case 'H': if (dndEnabled && focusedTask) { @@ -1825,19 +2019,28 @@ export function TasksKanban({ case 'j': case 'ArrowDown': consume() - setCardIdx((i) => - focusedColumn ? Math.min(focusedColumn.tasks.length - 1, i + 1) : 0 - ) + setCardIdx(kanbanCardStep(safeCardIdx, 1, focusedColumn?.tasks.length ?? 0)) return case 'k': case 'ArrowUp': consume() - setCardIdx((i) => Math.max(0, i - 1)) + setCardIdx(kanbanCardStep(safeCardIdx, -1, focusedColumn?.tasks.length ?? 0)) return case 'Enter': if (focusedTask) { consume() onOpenTask(focusedTask) + } else if (focusedColumn) { + // On a header, Enter renames the column, as a click on its title does. + consume() + beginColumnRename(focusedColumn) + } + return + // The focused column's WIP limit (#1025), card or header alike. + case 'W': + if (vimMode && focusedColumn) { + consume() + void promptWipLimit(focusedColumn) } return case ' ': @@ -1877,6 +2080,7 @@ export function TasksKanban({ window.addEventListener('keydown', handler, true) return () => window.removeEventListener('keydown', handler, true) }, [ + beginColumnRename, chooseCardSort, clearGroupByPending, columns.length, @@ -1888,6 +2092,9 @@ export function TasksKanban({ moveFocusedColumn, onOpenTask, onToggleTask, + openKeyboardMenu, + promptWipLimit, + safeCardIdx, vimMode ]) @@ -1979,21 +2186,45 @@ export function TasksKanban({ fieldKey && isMovableColumn && kanbanColumnTitles[columnOrderKey(groupBy, column.id)] ? column.id : null + // A limit counts the column's whole load, not the filtered view of + // it; with no limit the count stays what the column shows. + const wip = wipState( + wipLoads.get(column.id) ?? column.tasks.length, + kanbanWipLimits[columnOrderKey(groupBy, column.id)] + ) + const isHeaderFocused = isColumnFocused && headerFocused return ( <div key={column.id} data-kanban-column-id={column.id} + data-wip-over={wip?.over ? '' : undefined} className={[ - 'task-kanban-column flex w-72 shrink-0 flex-col rounded-lg border bg-paper-100/60 transition-opacity', - isColumnFocused ? 'border-paper-400/70' : 'border-paper-300/60', + 'task-kanban-column flex w-72 shrink-0 flex-col rounded-lg border transition-opacity', + wip?.over + ? `bg-danger/5 ${isColumnFocused ? 'border-danger' : 'border-danger/60'}` + : `bg-paper-100/60 ${isColumnFocused ? 'border-paper-400/70' : 'border-paper-300/60'}`, isColumnDragging ? 'opacity-40' : '', isColumnDropTarget ? 'ring-2 ring-accent/70' : '' ].join(' ')} > <div + ref={isHeaderFocused ? headerRef : undefined} + data-kanban-column-header + data-focused={isHeaderFocused ? '' : undefined} onPointerDown={(e) => beginColumnDrag(column.id, e)} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + // The cursor moves onto the header first, so the menu and + // the keyboard agree on which column is being acted on. + setColIdx(ci) + setCardIdx(-1) + openColumnMenu(column, e.clientX, e.clientY) + }} className={[ - 'flex shrink-0 select-none items-center justify-between gap-2 border-b border-paper-300/45 px-3 py-2', + 'flex shrink-0 select-none items-center justify-between gap-2 rounded-t-lg border-b px-3 py-2', + wip?.over ? 'border-danger/35' : 'border-paper-300/45', + isHeaderFocused ? 'ring-1 ring-inset ring-accent/60' : '', isMovableColumn && editingColumnId !== column.id ? 'cursor-grab active:cursor-grabbing' : '' @@ -2061,7 +2292,26 @@ export function TasksKanban({ )} </div> <div className="flex items-center gap-1.5 text-xs text-current/50"> - <span>{column.tasks.length}</span> + {wip ? ( + <span + data-kanban-wip + title={ + `WIP limit ${wip.limit}` + + (wip.over ? `, ${wip.load - wip.limit} over` : '') + + (wip.load !== column.tasks.length ? ` (${column.tasks.length} shown)` : '') + } + className={ + wip.over + ? 'flex items-center gap-1 rounded bg-danger/15 px-1.5 py-0.5 text-2xs font-medium text-danger' + : '' + } + > + {wip.over && <AlertTriangleIcon width={12} height={12} aria-hidden="true" />} + {wip.load} / {wip.limit} + </span> + ) : ( + <span>{column.tasks.length}</span> + )} {column.badge?.kind === 'overdue' && column.badge.value > 0 && ( <span className="rounded bg-rose-500/15 px-1.5 py-0.5 text-2xs font-medium text-rose-300"> {column.badge.value} overdue diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 376d7157c..4fdce51a1 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -6,7 +6,7 @@ import { computeTasksRender, filterTasks, isOverdue, noteTagsByPath } from '../l import { forwardTaskWithPicker } from '../lib/forward-task' import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' -import { TasksKanban } from './TasksKanban' +import { TasksKanban, parseWipLimitInput, type KanbanControl } from './TasksKanban' import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' import { isEmptyTask, withoutEmptyTasks } from '../lib/empty-tasks' import { CalendarIcon, CheckSquareIcon, KanbanIcon, ListIcon } from './icons' @@ -132,6 +132,8 @@ export function TasksView(): JSX.Element { () => filterTasks(shownTasks, filter, noteTags), [shownTasks, filter, noteTags] ) + // The board's handle for `:wip`, which needs the column under its cursor. + const kanbanControlRef = useRef<KanbanControl | null>(null) const keymapOverrides = useStore((s) => s.keymapOverrides) const vimMode = useStore((s) => s.vimMode) const listOrder = useStore((s) => s.tasksListOrder) @@ -547,6 +549,24 @@ export function TasksView(): JSX.Element { toast(next === 'due' ? 'Kanban cards ordered by due date' : 'Kanban cards in your own order') return } + // `:wip 3` caps the focused Kanban column at three cards; bare `:wip` + // clears its limit, like the column menu's Clear WIP limit. (#1025) + if (head === 'wip') { + const control = kanbanControlRef.current + if (viewMode !== 'kanban' || !control) { + toast(':wip works on the board') + return + } + const parsed = parseWipLimitInput(arg) + if (!parsed.ok) { + toast('Usage: :wip 1-999, or :wip to clear') + return + } + const label = control.setFocusedColumnWipLimit(parsed.limit) + if (label === null) return + toast(parsed.limit === null ? `${label}: no WIP limit` : `${label}: WIP limit ${parsed.limit}`) + return + } if (head === 'delfilter' || head === 'df') { if (!arg) { toast('Usage: :delfilter <name>') @@ -1159,6 +1179,7 @@ export function TasksView(): JSX.Element { today={today} onOpenTask={(task) => void openTaskAt(task)} onToggleTask={(task) => void toggleTaskFromList(task)} + controlRef={kanbanControlRef} /> )} diff --git a/packages/app-core/src/components/icons.tsx b/packages/app-core/src/components/icons.tsx index 8f8e8b578..60ac51e50 100644 --- a/packages/app-core/src/components/icons.tsx +++ b/packages/app-core/src/components/icons.tsx @@ -389,6 +389,15 @@ export const KeyboardIcon = (p: IconProps): JSX.Element => ( </I> ) +/** A warning: a Kanban column holding more cards than its WIP limit. */ +export const AlertTriangleIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" /> + <path d="M12 9v4" /> + <path d="M12 17h.01" /> + </I> +) + export const ZapIcon = (p: IconProps): JSX.Element => ( <I {...p}> <path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" /> diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index c799cc192..8dc6d340f 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -384,10 +384,15 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'A bare `- [ ]`, like the ones a daily-note template leaves, is an empty task, and the Tasks list, calendar, and board leave it out. A quiet “3 hidden” beside the **View** button says how many; click it to show them. A task with any text always shows, even just `due:2026-11-02`. Turn it off in the View menu (`v`, then Hide empty tasks), with `E` in Vim mode, or in Settings → Tasks. Config key: `hide_empty_tasks` under `[view]`.' }, + { + title: 'WIP limits on Kanban columns', + body: + 'A WIP limit caps how many cards a column should hold. Right-click a column header and choose **Set WIP limit…**. The header then reads `2 / 3`; past the limit the column turns red, as in `4 / 3`. From the keyboard, `↑` on a column’s first card reaches its header, where `Shift+F10` opens the menu and `Enter` renames the column. In Vim mode, `W` sets the focused column’s limit, `:wip 3` does too, and a bare `:wip` clears it. Limits are kept per board and column, in config.toml’s `[kanban_wip_limits]` table, such as `"status:in-progress" = 3`. The count ignores the filter and the hidden empty tasks.' + }, { title: 'Keys for the Tasks views', body: - 'Each Tasks view has a **Keys** button at the right of its toolbar (`?` in Vim mode) that lists its keys. With Vim mode off it shows only the keys that work then: the arrows, Enter, Space, and Tab.' + 'Each Tasks view has a **Keys** button at the right of its toolbar (`?` in Vim mode) that lists its keys. With Vim mode off it shows only the keys that work then: the arrows, Enter, Space, Tab, and Shift+F10.' }, { title: 'Filter the Tasks views to one project', @@ -804,6 +809,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'E', action: 'Show or hide empty tasks', detail: 'Tasks view: show or hide bare `- [ ]` tasks in the list, calendar, and board.' }, { keys: 'v', action: 'View menu', detail: 'Tasks view: open the View menu (Hide empty tasks).' }, { keys: '?', action: 'Keys', detail: 'Tasks view: list the current view’s keys. Esc closes it.' }, + { keys: 'W', action: 'Set a WIP limit', detail: 'Tasks board only: set the focused column’s WIP limit. Same as `:wip <limit>`.' }, { keys: ':', action: 'Open local ex prompt', detail: 'Run the view-specific command line inside Tasks or Tags.' }, { keys: 'Esc', action: 'Clear the filter', detail: 'Clears an active filter. These views are tabs, so Esc no longer closes them. Close with :q or the ✕ in the tab header.' } ] @@ -932,6 +938,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Order Kanban cards by due date or by hand, or the list by priority', detail: 'In the Tasks view, `:order due` sorts the cards in every Kanban column by due date, earliest first and cards without one last; `:order manual` brings back the order you dragged them into, which ordering by date never overwrites. On the board or the calendar, bare `:order` switches between the two, like `s` on the board in Vim mode. Same as `kanban_card_sort` under `[view]` in config.toml. In the list, `:order priority` puts high priority first inside each group and `:order note` brings back the notes’ line order; bare `:order` there switches between the two, like `s`. Same as `tasks_list_order` under `[view]`.' }, + { + command: ':wip <limit>', + summary: 'Set the focused Kanban column’s WIP limit', + detail: 'On the board, `:wip 3` caps the focused column at three cards; past that it turns red. A bare `:wip` clears it. Same as Set WIP limit… in the column menu, or `W`.' + }, { command: ':delfilter <name>', summary: 'Delete a saved Tasks filter', diff --git a/packages/app-core/src/lib/tasks-keys.test.ts b/packages/app-core/src/lib/tasks-keys.test.ts index 0ff12b6d6..d01084065 100644 --- a/packages/app-core/src/lib/tasks-keys.test.ts +++ b/packages/app-core/src/lib/tasks-keys.test.ts @@ -27,9 +27,9 @@ describe('the Tasks keys cheat sheet', () => { }) } - it('lists the board keys with Vim mode on', () => { + it('lists the board keys with Vim mode on, the WIP limit among them', () => { const keys = keysOf(tasksKeySheet('kanban', { vimMode: true, dndEnabled: true, listOrder: 'note' })) - for (const key of ['h / l', 'j / k', 'x', 'Enter', 'Shift+H / L', '< / >', 'g', 's', 'v', 'E', '?']) { + for (const key of ['h / l', 'j / k', 'x', 'Enter', 'Shift+H / L', 'm', '< / >', 'W', 'g', 's', 'v', 'E', '?']) { expect(keys).toContain(key) } }) diff --git a/packages/app-core/src/lib/tasks-keys.ts b/packages/app-core/src/lib/tasks-keys.ts index 3ea0da0d7..77b347948 100644 --- a/packages/app-core/src/lib/tasks-keys.ts +++ b/packages/app-core/src/lib/tasks-keys.ts @@ -82,14 +82,22 @@ function kanbanGroups({ vimMode, dndEnabled }: TasksKeySheetOptions): TasksKeySh title: 'Move', rows: [ { keys: '← / →', label: 'columns' }, - { keys: '↑ / ↓', label: 'cards' } + { keys: '↑ / ↓', label: 'cards, header' } ] }, { title: 'Cards', rows: [ { keys: 'Space', label: 'toggle' }, - { keys: 'Enter', label: 'open' } + { keys: 'Enter', label: 'open' }, + { keys: 'Shift+F10', label: 'menu' } + ] + }, + { + title: 'Columns', + rows: [ + { keys: 'Enter', label: 'rename (header)' }, + { keys: 'Shift+F10', label: 'header menu' } ] } ], @@ -102,21 +110,29 @@ function kanbanGroups({ vimMode, dndEnabled }: TasksKeySheetOptions): TasksKeySh title: 'Move', rows: [ { keys: 'h / l', label: 'columns' }, - { keys: 'j / k', label: 'cards' } + { keys: 'j / k', label: 'cards, header' } ] }, { title: 'Cards', - // The card's states (i start, c cancel, @ due date) are in its - // right-click menu, each with its key. Subtasks get a group of their - // own (SUBTASK_GROUP), right after this one. + // The card's states (i start, c cancel, @ due date) are in the menu + // `m` opens, each with its key. Subtasks get a group of their own + // (SUBTASK_GROUP), right after this one. rows: [ { keys: 'x', label: 'toggle' }, { keys: 'Enter', label: 'open' }, - ...(dndEnabled ? [{ keys: 'Shift+H / L', label: 'move' }] : []) + ...(dndEnabled ? [{ keys: 'Shift+H / L', label: 'move' }] : []), + { keys: 'm', label: 'menu' } + ] + }, + { + title: 'Columns', + rows: [ + { keys: '< / >', label: 'reorder' }, + { keys: 'W', label: 'WIP limit' }, + { keys: 'Enter', label: 'rename (header)' } ] }, - { title: 'Columns', rows: [{ keys: '< / >', label: 'reorder' }] }, { title: 'Board', rows: [{ keys: 'g', label: 'group by' }, ...viewRows('order')] } ], mouse diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 70202e421..b13ac68af 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -2162,6 +2162,62 @@ describe('hideEmptyTasks (#1026)', () => { }) }) +describe('kanbanWipLimits (#1025)', () => { + it('keeps a limit per board and column, through a restart', async () => { + installZen() + const { useStore } = await loadStore() + expect(useStore.getState().kanbanWipLimits).toEqual({}) + + useStore.getState().setKanbanWipLimit('status', 'in-progress', 3) + useStore.getState().setKanbanWipLimit('field:status', 'review', 2) + expect(useStore.getState().kanbanWipLimits).toEqual({ + 'status:in-progress': 3, + 'field:status:review': 2 + }) + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.kanbanWipLimits).toEqual({ 'status:in-progress': 3, 'field:status:review': 2 }) + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().kanbanWipLimits['status:in-progress']).toBe(3) + }) + + it('clears a limit with null', async () => { + installZen() + const { useStore } = await loadStore() + useStore.getState().setKanbanWipLimit('status', 'in-progress', 3) + useStore.getState().setKanbanWipLimit('status', 'in-progress', null) + expect(useStore.getState().kanbanWipLimits).toEqual({}) + }) + + it('drops limits a hand-edited config.toml cannot mean', async () => { + installZen() + vi.resetModules() + localStorage.clear() + localStorage.setItem( + 'zen:prefs:v2', + JSON.stringify({ + kanbanWipLimits: { + 'status:in-progress': 3, + 'status:waiting': '4', + 'priority:high': 0, + 'priority:med': -2, + 'priority:low': 2.5, + 'status:done': 1000, + 'folder:Projects/alpha': 5, + bogus: 3 + } + }) + ) + const { useStore } = await import('./store') + expect(useStore.getState().kanbanWipLimits).toEqual({ + 'status:in-progress': 3, + 'status:waiting': 4, + 'folder:Projects/alpha': 5 + }) + }) +}) + describe('kanbanCardSort (#889)', () => { it('defaults to the manual order and round-trips due-date ordering', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 9d281fae2..85333820a 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -821,6 +821,10 @@ interface Prefs { * list, calendar and Kanban board. On by default; portable as * `hide_empty_tasks` under [view]. (#1026) */ hideEmptyTasks: boolean + /** Kanban WIP limits: the most cards a column should hold, keyed by + * `${groupBy}:${columnId}` like the column titles. Portable as the + * `[kanban_wip_limits]` table. (#1025) */ + kanbanWipLimits: Record<string, number> /** Tasks list groups in their notes' line order or by priority. (#989) */ tasksListOrder: TasksListOrder /** How far ahead the Timeline panel reaches. (#1007) */ @@ -925,24 +929,58 @@ const FIELD_COLUMN_TITLE_KEY_RE = /^field:[a-z][a-z0-9_-]*:(?:__none__|[\p{L}\d] // Folder columns are keyed by the note directory (#730): any printable path. const FOLDER_COLUMN_TITLE_KEY_RE = /^folder:(?:__none__|[^\u0000-\u001f]{1,256})$/u +/** A `<groupBy>:<columnId>` key some board can build: what titles, card + * orders and WIP limits are stored under. */ +function isKanbanColumnKey(key: string): boolean { + const isStatic = + STATIC_COLUMN_TITLE_KEY_RE.test(key) && + STATIC_KANBAN_GROUP_BYS.some((group) => key.startsWith(`${group}:`)) + return isStatic || FIELD_COLUMN_TITLE_KEY_RE.test(key) || FOLDER_COLUMN_TITLE_KEY_RE.test(key) +} + function normalizeKanbanColumnTitles(raw: unknown): Record<string, string> { if (!raw || typeof raw !== 'object') return {} const out: Record<string, string> = {} for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { if (typeof value !== 'string') continue - const isStatic = - STATIC_COLUMN_TITLE_KEY_RE.test(key) && - STATIC_KANBAN_GROUP_BYS.some((group) => key.startsWith(`${group}:`)) - const isField = FIELD_COLUMN_TITLE_KEY_RE.test(key) - const isFolder = FOLDER_COLUMN_TITLE_KEY_RE.test(key) - if (!isStatic && !isField && !isFolder) continue + if (!isKanbanColumnKey(key)) continue const normalized = normalizeKanbanColumnTitle(value) if (normalized) out[key] = normalized } return out } +/** The largest WIP limit a column takes; TasksKanban's parseWipLimitInput + * holds typed limits to the same bound. */ +export const MAX_KANBAN_WIP_LIMIT = 999 +const MAX_KANBAN_WIP_LIMIT_COLUMNS = 256 + +function normalizeKanbanWipLimit(raw: unknown): number | null { + // A hand-edited config.toml can quote the number; anything that is not a + // whole number from 1 to the cap means no limit, never a guessed one. + const value = + typeof raw === 'number' ? raw : typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : NaN + return Number.isInteger(value) && value >= 1 && value <= MAX_KANBAN_WIP_LIMIT ? value : null +} + +// WIP limits per column (#1025): `{ "<groupBy>:<columnId>": <limit> }`, the +// column-title key grammar with a whole-number value. +export function normalizeKanbanWipLimits(raw: unknown): Record<string, number> { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + const out: Record<string, number> = {} + let columns = 0 + for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { + if (!isKanbanColumnKey(key)) continue + const limit = normalizeKanbanWipLimit(value) + if (limit === null) continue + out[key] = limit + columns += 1 + if (columns >= MAX_KANBAN_WIP_LIMIT_COLUMNS) break + } + return out +} + const MAX_KANBAN_ORDERED_COLUMNS = 64 const MAX_KANBAN_COLUMN_ID_LENGTH = 256 @@ -986,12 +1024,7 @@ export function normalizeKanbanCardOrder(raw: unknown): Record<string, string[]> let columns = 0 for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { if (!Array.isArray(value)) continue - const isStatic = - STATIC_COLUMN_TITLE_KEY_RE.test(key) && - STATIC_KANBAN_GROUP_BYS.some((group) => key.startsWith(`${group}:`)) - const isField = FIELD_COLUMN_TITLE_KEY_RE.test(key) - const isFolder = FOLDER_COLUMN_TITLE_KEY_RE.test(key) - if (!isStatic && !isField && !isFolder) continue + if (!isKanbanColumnKey(key)) continue const cards: string[] = [] const seen = new Set<string>() for (const entry of value) { @@ -1271,6 +1304,7 @@ export const DEFAULT_PREFS: Prefs = { kanbanGroupBy: 'status', kanbanCardSort: 'manual', hideEmptyTasks: true, + kanbanWipLimits: {}, tasksListOrder: 'note', timelineRange: DEFAULT_TIMELINE_RANGE, gallerySort: 'updated', @@ -1658,6 +1692,7 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { kanbanCardSort: p.kanbanCardSort === 'due' ? 'due' : 'manual', hideEmptyTasks: typeof p.hideEmptyTasks === 'boolean' ? p.hideEmptyTasks : DEFAULT_PREFS.hideEmptyTasks, + kanbanWipLimits: normalizeKanbanWipLimits(p.kanbanWipLimits), tasksListOrder: p.tasksListOrder === 'priority' ? 'priority' : 'note', timelineRange: normalizeTimelineRange(p.timelineRange), gallerySort: normalizeGallerySort(p.gallerySort), @@ -2633,6 +2668,7 @@ function collectPrefs(s: { kanbanGroupBy: KanbanGroupBy kanbanCardSort: KanbanCardSort hideEmptyTasks: boolean + kanbanWipLimits: Record<string, number> tasksListOrder: TasksListOrder timelineRange: TimelineRange gallerySort: GallerySort @@ -2755,6 +2791,7 @@ function collectPrefs(s: { kanbanGroupBy: s.kanbanGroupBy, kanbanCardSort: s.kanbanCardSort, hideEmptyTasks: s.hideEmptyTasks, + kanbanWipLimits: s.kanbanWipLimits, tasksListOrder: s.tasksListOrder, timelineRange: s.timelineRange, gallerySort: s.gallerySort, @@ -3443,6 +3480,8 @@ interface Store { kanbanCardSort: KanbanCardSort /** Tasks with no text stay off the Tasks list, calendar and board. (#1026) */ hideEmptyTasks: boolean + /** Per-column Kanban WIP limits, keyed `${groupBy}:${columnId}`. (#1025) */ + kanbanWipLimits: Record<string, number> /** Tasks list groups in note order or by priority. (#989) */ tasksListOrder: TasksListOrder /** How far ahead the Timeline panel reaches: two weeks, a month, or every @@ -3731,6 +3770,8 @@ interface Store { setKanbanGroupBy: (group: KanbanGroupBy) => void setKanbanCardSort: (sort: KanbanCardSort) => void setHideEmptyTasks: (hide: boolean) => void + /** Set one column's WIP limit, or clear it with null. (#1025) */ + setKanbanWipLimit: (group: KanbanGroupBy, columnId: string, limit: number | null) => void setTasksListOrder: (order: TasksListOrder) => void setTimelineRange: (range: TimelineRange) => void setTimelineFilter: (query: string) => void @@ -6319,6 +6360,7 @@ export const useStore = create<Store>((set, get) => { kanbanGroupBy: loadPrefs().kanbanGroupBy, kanbanCardSort: loadPrefs().kanbanCardSort, hideEmptyTasks: loadPrefs().hideEmptyTasks, + kanbanWipLimits: loadPrefs().kanbanWipLimits, tasksListOrder: loadPrefs().tasksListOrder, timelineRange: loadPrefs().timelineRange, timelineFilter: '', @@ -7860,6 +7902,17 @@ export const useStore = create<Store>((set, get) => { set({ hideEmptyTasks: hide }) savePrefs(collectPrefs(get())) }, + // Global and portable like the column titles, deliberately not a per-vault + // view override: that would need the Go server's vault.json mirror to learn + // it (the reasoning #889 gave for the card order). + setKanbanWipLimit: (group, columnId, limit) => { + const key = `${group}:${columnId}` + const next = { ...get().kanbanWipLimits } + if (limit === null) delete next[key] + else next[key] = limit + set({ kanbanWipLimits: normalizeKanbanWipLimits(next) }) + savePrefs(collectPrefs(get())) + }, setTasksListOrder: (order) => { set({ tasksListOrder: order }) savePrefs(collectPrefs(get())) diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 33b7d09e9..e67c4bfd1 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -106,6 +106,7 @@ export const PORTABLE_PREF_KEYS = [ 'kanbanGroupBy', 'kanbanCardSort', 'hideEmptyTasks', + 'kanbanWipLimits', 'tasksListOrder', 'timelineRange', 'gallerySort', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 52f58b876..45d4eab12 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -172,6 +172,7 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { kanbanGroupBy: 'status', kanbanCardSort: 'manual', hideEmptyTasks: true, + kanbanWipLimits: {}, tasksListOrder: 'note', timelineRange: 'two-weeks', gallerySort: 'updated', From 7a6e3d367690d691fe8a75d45fe89d5981052254 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 13:05:47 -0500 Subject: [PATCH 43/53] Fix(kanban): a card sent to In progress takes the cursor with it Shift+L (or a drop) that sent a card into the Status board's In progress column wrote `[/]` on its line, but the board's own copy of the card did not change: the overlay that draws a moved card before its note is written knew checked, waiting, priority, due and fields, never in-progress (#677 added the column, not this case). So the card sat in No due date until the write came back, and the cursor, which follows the card to its new home, could not find it there and fell onto In progress's first card instead. A second Shift+H or Shift+L then moved that other task: the #492 trap, back for one column. The overlay now applies set-in-progress like the other states, so the card lands in In progress at once and the cursor lands on it. Found while verifying swimlanes (#1027) in the built app, where the same move inside a lane showed it; it happens on every Status board. How to test locally: build (`cd apps/desktop && npx electron-vite build`), launch, open Tasks, Kanban, Group by Status, with two notes holding `- [/] Review PR` and `- [ ] Draft pricing`. Put the cursor on Draft pricing in No due date and press Shift+L. Before: the cursor lands on Review PR. After: it stays on Draft pricing, now in In progress, and the line reads `- [/] Draft pricing`. --- .../app-core/src/components/TasksKanban.test.ts | 15 ++++++++++++++- packages/app-core/src/components/TasksKanban.tsx | 10 +++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/app-core/src/components/TasksKanban.test.ts b/packages/app-core/src/components/TasksKanban.test.ts index 6680c2714..1d30b615c 100644 --- a/packages/app-core/src/components/TasksKanban.test.ts +++ b/packages/app-core/src/components/TasksKanban.test.ts @@ -24,7 +24,8 @@ import { hideEmptyCards, kanbanCardStep, parseWipLimitInput, - wipState + wipState, + applyTaskMutationsForBoard } from './TasksKanban' function col(id: string): Column { @@ -730,3 +731,15 @@ describe('the cursor climbs onto the column header (#1025)', () => { expect(kanbanCardStep(0, -1, 0)).toBe(-1) }) }) + +describe('the board moves a card before its note is written (#677)', () => { + it('sends a card to In progress at once, so the cursor can follow it there', () => { + const before = card({ content: 'draft', taskIndex: 0, id: 'a' }) + const moved = applyTaskMutationsForBoard(before, dropMutationsFor('status', IN_PROGRESS_COLUMN_ID, before, TODAY)!) + expect(moved.inProgress).toBe(true) + const columns = statusColumns([moved, card({ content: 'other', taskIndex: 1, inProgress: true, id: 'b' })], TODAY) + const at = cursorAfterCardMove(columns, IN_PROGRESS_COLUMN_ID, taskIdentityKey(moved)) + expect(columns[at.colIdx].id).toBe(IN_PROGRESS_COLUMN_ID) + expect(columns[at.colIdx].tasks[at.cardIdx].content).toBe('draft') + }) +}) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index c163b8208..a4d322bbe 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -504,7 +504,9 @@ function sameBoardPlacement(a: VaultTask, b: VaultTask): boolean { ) } -function applyTaskMutationsForBoard(task: VaultTask, mutations: TaskMutation[]): VaultTask { +/** A card as it reads once `mutations` land: what the board draws, and + * where its cursor follows the card to, before the note is written. */ +export function applyTaskMutationsForBoard(task: VaultTask, mutations: TaskMutation[]): VaultTask { let next = task for (const m of mutations) { switch (m.kind) { @@ -514,6 +516,12 @@ function applyTaskMutationsForBoard(task: VaultTask, mutations: TaskMutation[]): case 'set-waiting': if (next.waiting !== m.waiting) next = { ...next, waiting: m.waiting } break + // Without it a card sent to In progress stayed where it was until the + // write came back, and the cursor, which follows the card, landed on + // another card of In progress instead (#677, the #492 rule). + case 'set-in-progress': + if (next.inProgress !== m.inProgress) next = { ...next, inProgress: m.inProgress } + break case 'set-priority': { const priority = m.priority ?? undefined if (next.priority !== priority) next = { ...next, priority } From 70189664d8eb39602866c0cfeaacbc2fe4234686 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 13:06:00 -0500 Subject: [PATCH 44/53] Feat(tasks): a task line can take a new first tag, the field a tag lane writes (#1027) Kanban swimlanes can split a board by tag, and a card dropped on another lane has to say so on its task line, the way a column drop rewrites `!high` or `@status:review`. Tags had no writer: they are free text in the line, any number of them, read by the parser wherever a `#` follows the start or a space. A tag lane files a card under its first tag, so the writer's one job is to make a given tag the first. setFirstTag replaces the current first tag where it stands (the line still reads as written, `Fix login #home`), drops a later copy of the new tag so it cannot come first twice, and keeps every other tag; a line without tags gets the tag at its end, like the other tokens. Null strips every tag, which is what the No tag lane means: a card dropped there lands there, rather than in the lane of its second tag. It reads tags with the parser's own token rule, so `[[Note#Part]]` and a URL fragment are left alone, `#work` never matches inside `#workshop`, and case does not matter, as the parser lower-cases tags. A name the parser would not read back leaves the line untouched. withFirstTag does the same to a parsed tag list, for the copy of a task the board and the store draw before the write lands; setTaskTagAtIndex applies it after a task line's checkbox, beside the other setTask*AtIndex writers. How to test locally: `cd apps/desktop && npx vitest run src/main/tasklists.test.ts`; the setFirstTag, withFirstTag and setTaskTagAtIndex groups cover the cases above. --- apps/desktop/src/main/tasklists.test.ts | 66 ++++++++++++++++++++++++- packages/shared-domain/src/tasklists.ts | 63 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/tasklists.test.ts b/apps/desktop/src/main/tasklists.test.ts index 12f28a5f2..dc3be1d8b 100644 --- a/apps/desktop/src/main/tasklists.test.ts +++ b/apps/desktop/src/main/tasklists.test.ts @@ -6,12 +6,15 @@ import { takeTaskLineAtIndex, setTaskCheckedAtIndex, setTaskDueAtIndex, + setFirstTag, setTaskFieldAtIndex, setTaskPriorityAtIndex, + setTaskTagAtIndex, setTaskStatusAtIndex, setTaskTextAtIndex, setTaskWaitingAtIndex, - toggleTaskAtIndex + toggleTaskAtIndex, + withFirstTag } from '@shared/tasklists' describe('toggleTaskAtIndex', () => { @@ -319,6 +322,67 @@ describe('setTaskFieldAtIndex', () => { }) }) +// A Kanban tag lane (#1027) is a card's first #tag: moving the card to +// another lane makes that lane's tag the first one, in place, and the No tag +// lane strips them all, so the card always lands in the lane it was dropped on. +describe('setFirstTag (#1027)', () => { + it('replaces the first tag in place and keeps the rest of the line', () => { + expect(setFirstTag(' Fix login #work !high', 'home')).toBe(' Fix login #home !high') + }) + + it('keeps the other tags, and drops a later copy of the new first tag', () => { + expect(setFirstTag(' a #work #urgent #home', 'home')).toBe(' a #home #urgent') + }) + + it('matches the first tag whatever its case', () => { + expect(setFirstTag(' a #Work', 'home')).toBe(' a #home') + }) + + it('appends the tag to a line that has none', () => { + expect(setFirstTag(' Call Sam due:2026-11-02', 'calls')).toBe(' Call Sam due:2026-11-02 #calls') + }) + + it('strips every tag for the No tag lane, and tidies the spaces', () => { + expect(setFirstTag(' a #work b #urgent', null)).toBe(' a b') + }) + + it('reads tags the way the task parser does', () => { + // A heading link and a URL fragment are not tags; a nested tag is one tag. + expect(setFirstTag(' see [[Note#Part]] x.com/#frag #proj/alpha', 'beta')).toBe( + ' see [[Note#Part]] x.com/#frag #beta' + ) + // `#work` is not a prefix match for `#workshop`. + expect(setFirstTag(' a #workshop #work', 'work')).toBe(' a #work') + }) + + it('leaves the text alone when the tag is already first', () => { + expect(setFirstTag(' a #home #x', 'home')).toBe(' a #home #x') + }) + + it('refuses a tag the parser would not read back', () => { + expect(setFirstTag(' a #work', 'two words')).toBe(' a #work') + }) +}) + +describe('withFirstTag (#1027)', () => { + it('mirrors setFirstTag on a parsed tag list', () => { + expect(withFirstTag(['work', 'urgent', 'home'], 'home')).toEqual(['home', 'urgent']) + expect(withFirstTag([], 'calls')).toEqual(['calls']) + expect(withFirstTag(['work', 'urgent'], null)).toEqual([]) + }) +}) + +describe('setTaskTagAtIndex (#1027)', () => { + it('retags only the task at the index, after its checkbox', () => { + const md = ['- [ ] a #work', '- [/] b #work @sprint:24'].join('\n') + expect(setTaskTagAtIndex(md, 1, 'home')).toBe(['- [ ] a #work', '- [/] b #home @sprint:24'].join('\n')) + }) + + it('clears the tags of a task with null', () => { + expect(setTaskTagAtIndex('- [ ] a #work #x', 0, null)).toBe('- [ ] a') + }) +}) + describe('task index walks on Windows line endings', () => { it('toggles a CRLF task and keeps its ending', () => { const md = '# Saved on Windows\r\n\r\n- [ ] one\r\n- [ ] two\r\n' diff --git a/packages/shared-domain/src/tasklists.ts b/packages/shared-domain/src/tasklists.ts index 216daf164..201e31191 100644 --- a/packages/shared-domain/src/tasklists.ts +++ b/packages/shared-domain/src/tasklists.ts @@ -279,6 +279,69 @@ export function setTaskStatusAtIndex( return setTaskFieldAtIndex(markdown, taskIndex, 'status', status) } +// The task parser's own tag token (INLINE_TAG_RE in tasks.ts): `#` after the +// start or whitespace, so `[[Note#Part]]` and `x.com/#frag` are not tags. +const TAG_TOKEN_RE = /(^|\s)#([\p{L}\d][\p{L}\d/_-]*)/gu +const TAG_NAME_RE = /^[\p{L}\d][\p{L}\d/_-]*$/u + +/** + * Make `tag` the first `#tag` of `text`, the tag a Kanban tag lane files a + * card under (#1027). The current first tag is replaced where it stands and a + * later copy of `tag` goes, so the card lands in the lane it was dropped on + * and keeps its other tags; a line without tags gets `tag` at its end. `null` + * strips every tag, which is what the No tag lane means. Tags compare without + * case, as the parser lower-cases them. A name the parser would not read back + * leaves the text alone. + */ +export function setFirstTag(text: string, tag: string | null): string { + if (tag !== null && !TAG_NAME_RE.test(tag)) return text + const tokens: { at: number; end: number; name: string }[] = [] + for (const match of text.matchAll(TAG_TOKEN_RE)) { + const at = (match.index ?? 0) + match[1].length + tokens.push({ at, end: at + 1 + match[2].length, name: match[2].toLowerCase() }) + } + if (tag === null) { + if (tokens.length === 0) return text + return text.replace(TAG_TOKEN_RE, '$1').replace(/\s{2,}/g, ' ').replace(/\s+$/u, '') + } + const wanted = tag.toLowerCase() + if (tokens.length === 0) return `${text.replace(/\s+$/u, '')} #${tag}` + if (tokens[0].name === wanted) return text + // Right to left, so earlier offsets stay valid while later copies go. + let next = text + for (let i = tokens.length - 1; i >= 1; i -= 1) { + if (tokens[i].name === wanted) next = next.slice(0, tokens[i].at) + next.slice(tokens[i].end) + } + next = `${next.slice(0, tokens[0].at)}#${tag}${next.slice(tokens[0].end)}` + return next.replace(/\s{2,}/g, ' ').replace(/\s+$/u, '') +} + +/** {@link setFirstTag} on a parsed (lower-cased, de-duplicated) tag list, for + * the optimistic copy of a task whose line is being retagged. */ +export function withFirstTag(tags: readonly string[], tag: string | null): string[] { + if (tag === null) return [] + const wanted = tag.toLowerCase() + if (tags.length === 0) return [wanted] + if (tags[0] === wanted) return [...tags] + return [wanted, ...tags.slice(1).filter((name) => name !== wanted)] +} + +/** {@link setFirstTag} on the task line at `taskIndex`: the text after its + * checkbox, every other token kept. (#1027) */ +export function setTaskTagAtIndex( + markdown: string, + taskIndex: number, + tag: string | null +): string { + return editTaskAtIndex(markdown, taskIndex, (match) => { + const prefix = match[1] + const checkChar = match[2] + const tailWithBracket = match[3] + if (!tailWithBracket.startsWith(']')) return null + return `${prefix}${checkChar}]${setFirstTag(tailWithBracket.slice(1), tag)}` + }) +} + /** Replace everything after the checkbox on the task line at `taskIndex` with * `text` (verbatim — the caller owns any `due:`/`!priority` tokens). Used by * inline task editing. */ From f3ad9a3c8632b66ccef77e8976f52b12c2817ad9 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 13:06:26 -0500 Subject: [PATCH 45/53] Feat(kanban): swimlanes split the board into rows by a second field (#1027) A board had one axis. Status columns could not also say which work is high priority, which project it belongs to or which tag it carries, short of switching boards and losing the flow view. Now the View menu offers Lanes: None, Priority, Folder, Tag, Custom status (once there are statuses) and every @field in use, and the board splits into horizontal lanes, each a row across the same columns with its own count. The board's own column field is never offered: lanes by it would put every card on the diagonal. A stored choice that matches the group-by just shows no lanes and comes back with another group-by. Lanes order like the same field's columns: High, Medium, Low, No priority; a field's configured values, then the ones found, then No <field>; folders by the folder board's order (the folder grouping is now shared, folderColumns unchanged); tags sorted, No tag last. A card sits in exactly one lane, a tagged one in the lane of its first tag. Lanes come from every card on the board, so a filter or the hidden empty tasks narrow the cells without moving lanes. WIP limits still count the whole column across its lanes, and subtasks still fold into their card, placed by the card's own fields. A drop writes only what it crossed: another lane writes that lane's field (priority and @field lanes through the column drop helpers, tag lanes through the new first-tag writer), another column its tokens, both in one write. If either half cannot land the drop is refused, as the undated drop is (#990): folder lanes are read-only like folder columns, and a task note keeps its tags in a frontmatter list. A lane header takes a drop too (lane only, column kept), folded or not, and a column header the column only. The board's overlay learned set-tag so a tag move shows at once. Keyboard, in Vim mode: S steps through the lane fields (with a toast, since the choice lives in the menu), j/k walk the column header, each lane's header and its cell (an empty cell is a place to stand), { and } hop lane to lane as they hop note to note in the Tags view, Shift+J/K move the card a lane down or up as Shift+H/L move it across, za folds the lane under the cursor and zM / zR fold or open them all, and :lanes <field> sets them from the ex line. J/K move the card rather than the cursor because Shift already means "move the thing" on this board and in the list. While a z waits, the Tasks view's own keys stand aside, so za never opens New task. With Vim off no letter does anything new: the arrows reach every lane header, and Enter or Space on one folds it; the lane menu (right-click or Shift+F10) holds Collapse lane and Collapse or Expand all lanes. The Keys popover has a Lanes group, Vim-gated like the rest, and S joins the Board group. No toolbar control was added. Persistence: the lane field is the portable kanban_lanes under [view], global like the card order rather than a per-vault override, which the Go server's vault.json mirror would have to learn. Collapsed lanes are kept per board and lane field, machine-local like the column and card arrangements: folding a lane is where you are looking today, not a setting worth a config.toml write on every za. Settings > Tasks has a one line Lanes row, the palette has Lanes by Priority, Folder, Tag and Lanes Off, and help.ts documents it all. In lane mode the column headers stay on top as the board scrolls down, and a lane header's label stays in view as it scrolls across. A lane header scrolls into view vertically only: as one element as wide as the board, scrollIntoView also slid the board sideways off its gutter. How to test locally: build (`cd apps/desktop && npx electron-vite build`), launch, open Tasks, Kanban, Group by Status, with tasks like `- [ ] Fix login bug !high #work` and `- [ ] Buy milk`. Press v and pick Lanes: Priority (or S in Vim mode). Before: one row of columns. After: a High, Medium, Low and No priority row across them. Put the cursor on Buy milk and press Shift+K: it moves into Low and the line reads `- [ ] Buy milk !low`. Drag Fix login bug into the Low row of another column: both its column tokens and `!low` are written. `za` folds the lane, `zR` opens them all; :lanes tag splits by first tag. --- apps/desktop/src/main/app-config.test.ts | 4 + apps/desktop/src/main/app-config.ts | 5 + .../src/components/SettingsModal.test.ts | 2 + .../app-core/src/components/SettingsModal.tsx | 38 + .../src/components/TasksKanban.test.ts | 133 +- .../app-core/src/components/TasksKanban.tsx | 1490 +++++++++++++---- .../app-core/src/components/TasksToolbar.tsx | 51 +- .../app-core/src/components/TasksView.tsx | 23 + packages/app-core/src/lib/commands.ts | 23 +- packages/app-core/src/lib/help.ts | 19 +- .../app-core/src/lib/kanban-lanes.test.ts | 237 +++ packages/app-core/src/lib/kanban-lanes.ts | 152 ++ .../app-core/src/lib/task-column-mutations.ts | 67 +- packages/app-core/src/lib/tasks-keys.test.ts | 19 + packages/app-core/src/lib/tasks-keys.ts | 37 +- packages/app-core/src/store.test.ts | 68 + packages/app-core/src/store.ts | 114 ++ packages/app-core/src/styles/index.css | 4 +- packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + .../shared-domain/src/commands-catalog.ts | 4 + 21 files changed, 2161 insertions(+), 331 deletions(-) create mode 100644 packages/app-core/src/lib/kanban-lanes.test.ts create mode 100644 packages/app-core/src/lib/kanban-lanes.ts diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 41d74f609..81f956698 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -133,6 +133,7 @@ describe('TOML serialization', () => { savedTaskFilters: { 'Project alpha': '@project:alpha', Blocked: '@status:blocked' }, kanbanGroupBy: 'folder', kanbanCardSort: 'due', + kanbanLanes: 'field:sprint', hideEmptyTasks: false, kanbanWipLimits: { 'status:in-progress': 3, 'field:status:review': 2 }, tasksListOrder: 'priority', @@ -175,6 +176,9 @@ describe('TOML serialization', () => { expect(round.kanbanGroupBy).toBe('folder') expect(text).toContain('kanban_card_sort = "due"') expect(round.kanbanCardSort).toBe('due') + // #1027: the lane field travels like the card order. + expect(text).toContain('kanban_lanes = "field:sprint"') + expect(round.kanbanLanes).toBe('field:sprint') // #1026 and #1025: the empty-task switch and the WIP limits travel too, // the limits as plain numbers a person can edit. expect(text).toContain('hide_empty_tasks = false') diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 875d92ca1..49dcd422f 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -441,6 +441,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = { tomlKey: 'kanban_card_sort', comment: 'manual | due: cards in a Kanban column keep the order you drag them into, or follow their due dates' }, + kanbanLanes: { + section: 'view', + tomlKey: 'kanban_lanes', + comment: 'none | priority | folder | tag | field:<key>: a second field that splits the Kanban board into lanes' + }, hideEmptyTasks: { section: 'view', tomlKey: 'hide_empty_tasks', diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index e55a8627e..2353562a9 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -62,6 +62,8 @@ const mocks = vi.hoisted(() => { interfaceFont: null, // The Tasks pane: "due date" finds its Kanban card order row first. kanbanCardSort: "manual", + kanbanLanes: "none", + setKanbanLanes: vi.fn(), hideEmptyTasks: true, kanbanFolderRoot: "", kanbanStatuses: [] as string[], diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 80f9ec2dc..dda7f091a 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -182,6 +182,8 @@ import { CloudSettings } from "./CloudSettings"; import { ErrorDetails } from "./ErrorDetails"; import { consumeSettingsTarget } from "../lib/settings-navigation"; import { unsplashKeySupported } from "../lib/unsplash-key"; +import { laneFieldLabel } from "../lib/kanban-lanes"; +import type { KanbanLaneBy } from "../store"; import { UnsplashKeySetting } from "./UnsplashKeySetting"; import { normalizeApplicationSchemes } from "@shared/application-links"; @@ -747,6 +749,8 @@ export function SettingsModal(): JSX.Element { const showArchivedTasks = useStore((s) => s.showArchivedTasks); const kanbanCardSort = useStore((s) => s.kanbanCardSort); const setKanbanCardSort = useStore((s) => s.setKanbanCardSort); + const kanbanLanes = useStore((s) => s.kanbanLanes); + const setKanbanLanes = useStore((s) => s.setKanbanLanes); const hideEmptyTasks = useStore((s) => s.hideEmptyTasks); const setHideEmptyTasks = useStore((s) => s.setHideEmptyTasks); const tasksListOrder = useStore((s) => s.tasksListOrder); @@ -3702,6 +3706,21 @@ export function SettingsModal(): JSX.Element { "manual", ], }, + { + id: "kanban-lanes", + title: "Kanban lanes", + description: "Split the board into rows by a second field.", + keywords: [ + "kanban", + "lanes", + "swimlanes", + "rows", + "priority", + "folder", + "tag", + "split", + ], + }, { id: "hide-empty-tasks", title: "Hide empty tasks", @@ -3796,6 +3815,25 @@ export function SettingsModal(): JSX.Element { onChange={(next) => setKanbanCardSort(next)} /> </Section> + <Section title="Lanes"> + <SegmentedRow<KanbanLaneBy> + label="Kanban lanes" + description="Split the board into rows by a second field." + value={kanbanLanes} + settingId="kanban-lanes" + options={[ + { value: "none", label: "None" }, + { value: "priority", label: "Priority" }, + { value: "folder", label: "Folder" }, + { value: "tag", label: "Tag" }, + // An @field chosen on the board shows here too. + ...(kanbanLanes.startsWith("field:") + ? [{ value: kanbanLanes, label: laneFieldLabel(kanbanLanes) }] + : []), + ]} + onChange={(next) => setKanbanLanes(next)} + /> + </Section> <Section title="List order" description="How the tasks inside each group of the Tasks list are ordered." diff --git a/packages/app-core/src/components/TasksKanban.test.ts b/packages/app-core/src/components/TasksKanban.test.ts index 1d30b615c..19377f69d 100644 --- a/packages/app-core/src/components/TasksKanban.test.ts +++ b/packages/app-core/src/components/TasksKanban.test.ts @@ -25,8 +25,13 @@ import { kanbanCardStep, parseWipLimitInput, wipState, - applyTaskMutationsForBoard + laneDefsFor, + splitIntoLanes, + cursorAfterLaneMove, + applyTaskMutationsForBoard, + type Lane } from './TasksKanban' +import { foldSubtasks } from '../lib/kanban-subtasks' function col(id: string): Column { return { id, label: id, tasks: [] } @@ -732,7 +737,124 @@ describe('the cursor climbs onto the column header (#1025)', () => { }) }) -describe('the board moves a card before its note is written (#677)', () => { +describe('swimlanes (#1027)', () => { + const layout = { folderRoot: '', systemFolderPaths: null, systemFolderLabels: null } + const opts = { statuses: [] as string[], folderLayout: layout } + const t = (content: string, taskIndex: number, over: Partial<VaultTask> = {}): VaultTask => + card({ content, taskIndex, id: `inbox/board.md#${taskIndex}`, noteFolder: 'inbox', tags: [], ...over }) + const laneIds = (lanes: Lane[]): string[] => lanes.map((lane) => lane.id) + const cell = (lanes: Lane[], laneId: string, columnId: string): string[] => + lanes.find((lane) => lane.id === laneId)!.columns.find((c) => c.id === columnId)!.tasks.map((x) => x.content) + + const tasks = [ + t('ship', 0, { priority: 'high', tags: ['work'] }), + t('call', 1, { priority: 'low', tags: ['home', 'work'] }), + t('read', 2, { tags: [] }), + t('fix', 3, { priority: 'high', due: '2026-09-01', tags: ['work'] }), + t('done', 4, { priority: 'med', checked: true, tags: ['home'] }) + ] + + it('splits a Status board into priority lanes, High to No priority, each a row of the same columns', () => { + const columns = statusColumns(tasks, TODAY) + const { defs, laneOf } = laneDefsFor('priority', tasks, opts) + const lanes = splitIntoLanes(columns, defs, laneOf) + expect(laneIds(lanes)).toEqual(['high', 'med', 'low', 'none']) + expect(lanes.map((lane) => lane.label)).toEqual(['High', 'Medium', 'Low', 'No priority']) + for (const lane of lanes) expect(lane.columns.map((c) => c.id)).toEqual(columns.map((c) => c.id)) + expect(cell(lanes, 'high', 'undated')).toEqual(['ship']) + expect(cell(lanes, 'high', 'upcoming')).toEqual(['fix']) + expect(cell(lanes, 'med', 'done')).toEqual(['done']) + expect(cell(lanes, 'none', 'undated')).toEqual(['read']) + expect(lanes.map((lane) => lane.count)).toEqual([2, 1, 1, 1]) + }) + + it('puts every card in exactly one lane, so a column keeps its whole count across lanes', () => { + const columns = statusColumns(tasks, TODAY) + for (const laneBy of ['priority', 'tag', 'folder', 'field:sprint'] as const) { + const { defs, laneOf } = laneDefsFor(laneBy, tasks, opts) + const lanes = splitIntoLanes(columns, defs, laneOf) + columns.forEach((column, ci) => { + const across = lanes.flatMap((lane) => lane.columns[ci].tasks.map((x) => x.id)) + expect(across.sort(), `${laneBy} ${column.id}`).toEqual(column.tasks.map((x) => x.id).sort()) + }) + } + }) + + it('keeps each column\'s card order inside every lane', () => { + const column: Column = { id: 'c', label: 'C', tasks: [tasks[3], tasks[2], tasks[0]] } + const { defs, laneOf } = laneDefsFor('priority', tasks, opts) + expect(cell(splitIntoLanes([column], defs, laneOf), 'high', 'c')).toEqual(['fix', 'ship']) + }) + + it('lanes a card by its first tag, sorted, with No tag last', () => { + const { defs, laneOf } = laneDefsFor('tag', tasks, opts) + expect(defs).toEqual([ + { id: 'home', label: '#home' }, + { id: 'work', label: '#work' }, + { id: NO_VALUE_COLUMN_ID, label: 'No tag' } + ]) + expect(laneOf(tasks[1])).toBe('home') + expect(laneOf(tasks[2])).toBe(NO_VALUE_COLUMN_ID) + }) + + it('orders field lanes like field columns: configured values, found ones, then No <field>', () => { + const fielded = [ + t('a', 0, { fields: { status: 'review' } }), + t('b', 1, { fields: { status: 'blocked' } }), + t('c', 2) + ] + const { defs } = laneDefsFor('field:status', fielded, { ...opts, statuses: ['backlog', 'review'] }) + expect(defs.map((d) => d.id)).toEqual(['backlog', 'review', 'blocked', NO_VALUE_COLUMN_ID]) + expect(defs.map((d) => d.label)).toEqual(['Backlog', 'Review', 'Blocked', 'No status']) + }) + + it('orders folder lanes like folder columns, Other folders only when something is outside', () => { + const foldered = [ + t('x', 0, { sourcePath: 'inbox/Projects/beta/x.md', id: 'x' }), + t('y', 1, { sourcePath: 'inbox/Projects/alpha/y.md', id: 'y' }), + t('z', 2, { sourcePath: 'inbox/z.md', id: 'z' }) + ] + const { defs } = laneDefsFor('folder', foldered, opts) + expect(defs.map((d) => d.label)).toEqual(['Inbox', 'Projects/alpha', 'Projects/beta']) + const rooted = laneDefsFor('folder', foldered, { ...opts, folderLayout: { ...layout, folderRoot: 'Projects' } }) + expect(rooted.defs.map((d) => d.label)).toEqual(['alpha', 'beta', 'Other folders']) + }) + + it('places a card by its own fields, not by a subtask\'s', () => { + const parent = t('parent', 0) + const child = t('child', 1, { priority: 'high', parentId: parent.id }) + const folded = foldSubtasks([parent, child]) + const { defs, laneOf } = laneDefsFor('priority', folded.cards, opts) + const lanes = splitIntoLanes(statusColumns(folded.cards, TODAY), defs, laneOf) + expect(cell(lanes, 'none', 'undated')).toEqual(['parent']) + expect(lanes.find((lane) => lane.id === 'high')!.count).toBe(0) + }) + + it('splits the columns it is given, so hidden empty cards stay out of every lane', () => { + const withBlank = [...tasks, t('', 5, { priority: 'high' })] + const { columns: shown } = hideEmptyCards(statusColumns(withBlank, TODAY), TODAY) + const { defs, laneOf } = laneDefsFor('priority', withBlank, opts) + expect(cell(splitIntoLanes(shown, defs, laneOf), 'high', 'undated')).toEqual(['ship']) + }) + + it('puts the cursor on a card that moved lanes, wherever its lane put it', () => { + const columns = statusColumns(tasks, TODAY) + const { defs, laneOf } = laneDefsFor('priority', tasks, opts) + const lanes = splitIntoLanes(columns, defs, laneOf) + expect(cursorAfterLaneMove(lanes, 'upcoming', taskIdentityKey(tasks[3]))).toEqual({ + laneIdx: 0, + colIdx: 1, + cardIdx: 0 + }) + expect(cursorAfterLaneMove(lanes, 'undated', taskIdentityKey(tasks[2]))).toEqual({ + laneIdx: 3, + colIdx: 2, + cardIdx: 0 + }) + }) +}) + +describe('the board moves a card before its note is written (#677, #1027)', () => { it('sends a card to In progress at once, so the cursor can follow it there', () => { const before = card({ content: 'draft', taskIndex: 0, id: 'a' }) const moved = applyTaskMutationsForBoard(before, dropMutationsFor('status', IN_PROGRESS_COLUMN_ID, before, TODAY)!) @@ -742,4 +864,11 @@ describe('the board moves a card before its note is written (#677)', () => { expect(columns[at.colIdx].id).toBe(IN_PROGRESS_COLUMN_ID) expect(columns[at.colIdx].tasks[at.cardIdx].content).toBe('draft') }) + + it('retags a card for a tag lane, text and tags alike', () => { + const before = card({ content: 'Call Sam #work #urgent', taskIndex: 0, tags: ['work', 'urgent'] }) + const moved = applyTaskMutationsForBoard(before, [{ kind: 'set-tag', tag: 'home' }]) + expect(moved.tags).toEqual(['home', 'urgent']) + expect(moved.content).toBe('Call Sam #home #urgent') + }) }) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index a4d322bbe..811931b1d 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -1,4 +1,9 @@ -import { dropMutationsFor } from '../lib/task-column-mutations' +import { + boardDropMutations, + dropMutationsFor, + laneDropMutationsFor, + type BoardCell +} from '../lib/task-column-mutations' /** * Kanban view for the Tasks tab. * @@ -36,6 +41,15 @@ import { dropMutationsFor } from '../lib/task-column-mutations' * Subtasks: a task's indented subtasks fold into its card (listed under the * title, checkable, with a progress bar) instead of standing as cards of * their own; see `lib/kanban-subtasks.ts`. + * + * Swimlanes (#1027): the View menu (or `S`, or `:lanes`) splits the board + * into horizontal lanes by a second field, each lane a row across the same + * columns. A drop on another lane writes that lane's field and nothing else + * (`boardDropMutations`); folder lanes are read-only. In lane mode j/k walk + * the column header, each lane's header and its cell; `{` / `}` hop lane to + * lane; Shift+J/K move the card a lane down or up; `za` folds the lane, `zM` / + * `zR` fold or open them all; Enter (or Space with Vim off) on a lane header + * folds it. WIP limits still count the whole column. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' @@ -48,13 +62,16 @@ import { import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/system-folder-labels' import { promptApp } from '../lib/prompt-requests' import type { VaultTask } from '@shared/tasks' +import { setFirstTag, withFirstTag } from '@shared/tasklists' import { groupTasks, isOverdue as isTaskOverdue, toIsoDateLocal } from '@shared/tasks' import { MAX_KANBAN_WIP_LIMIT, + kanbanCollapsedLanesKey, normalizeKanbanFolderRoot, useStore, type KanbanCardSort, type KanbanGroupBy, + type KanbanLaneBy, type TaskMutation } from '../store' import type { NoteTagsByPath } from '../lib/tasks-filter' @@ -68,6 +85,18 @@ import { type CardSubtask } from '../lib/kanban-subtasks' import { rollupLabel, type TaskRollup } from '../lib/task-rollup' +import { + activeLaneBy, + clampLaneCursor, + laneCursorStep, + laneFieldLabel, + laneJump, + laneOptions, + nextLaneBy, + type LaneCursor, + type LaneShape +} from '../lib/kanban-lanes' +import { useToastStore } from '../lib/toast' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { isKeyboardContextMenuKey, keyboardMenuPosition } from '../lib/keyboard-context-menu' import { listViewBindingMatches } from '../lib/list-view-keys' @@ -129,6 +158,26 @@ function columnAccent(id: string): string | null { export { dropMutationsFor } from '../lib/task-column-mutations' const NO_SUBTASKS: readonly CardSubtask[] = [] +const NO_LANE_IDS: readonly string[] = [] + +/** The dot beside a lane's label: the card chips' priority colours, a field + * value's column accent, none for tags and folders. */ +function laneDotOf( + laneBy: KanbanLaneBy, + laneId: string +): { className?: string; color?: string } | null { + if (laneBy === 'priority') { + if (laneId === 'high') return { className: 'bg-rose-400' } + if (laneId === 'med') return { className: 'bg-amber-400' } + if (laneId === 'low') return { className: 'bg-sky-400' } + return null + } + if (laneBy.startsWith('field:')) { + const color = columnAccent(laneId) + return color ? { color } : null + } + return null +} export interface Column { id: string @@ -298,50 +347,80 @@ export function folderColumns( showArchived: boolean, layout: FolderBoardLayout ): Column[] { - const labels = resolveSystemFolderLabels(layout.systemFolderLabels) + return folderGroups( + tasks.filter( + (task) => isBoardCard(task) && (task.noteFolder !== 'archive' || showArchived) + ), + layout + ) +} + +interface FolderPlace { + id: string + label: string + folder: NoteFolder + dir: string +} + +/** The folder group a task's note belongs to, or null for the trailing Other + * folders group (a folder root is set and the note is outside it). */ +function folderPlacement( + task: VaultTask, + layout: FolderBoardLayout, + labels: Record<string, string> +): FolderPlace | null { const root = normalizeKanbanFolderRoot(layout.folderRoot) + const location = noteLocationOf(task, layout.systemFolderPaths) + const vaultDir = [location.prefix, location.dir].filter(Boolean).join('/') + if (!root) { + // Each note's own folder: the system folder's root as its label, a + // subfolder as its path. + const id = vaultDir || resolveFolderPath(location.folder, layout.systemFolderPaths) + return { + id, + label: folderColumnLabel(location.folder, location.dir, labels), + folder: location.folder, + dir: location.dir + } + } + // A root narrows the board to the primary area: the root's direct + // children are the columns, deeper notes roll up to their child, notes + // in the root itself get the root's column, and everything else (other + // system folders, folders beside the root) shares one trailing column. + if (location.folder !== 'inbox') return null const rootLower = root.toLowerCase() + const dirLower = location.dir.toLowerCase() + if (dirLower === rootLower) { + const id = [location.prefix, location.dir].filter(Boolean).join('/') + return { id, label: location.dir.split('/').pop() ?? location.dir, folder: 'inbox', dir: location.dir } + } + if (dirLower.startsWith(rootLower + '/')) { + const child = location.dir.slice(root.length + 1).split('/')[0] + const childDir = `${location.dir.slice(0, root.length)}/${child}` + const id = [location.prefix, childDir].filter(Boolean).join('/') + return { id, label: child, folder: 'inbox', dir: childDir } + } + return null +} + +/** Group tasks by their notes' folders, in the folder board's order: system + * folder order first, a folder's root before its subfolders, subfolders + * alphabetically, Other folders last. Every task is placed; which tasks + * belong on a board is the caller's choice (columns leave closed work out, + * folder lanes split whatever the columns hold, #1027). */ +export function folderGroups(tasks: VaultTask[], layout: FolderBoardLayout): Column[] { + const labels = resolveSystemFolderLabels(layout.systemFolderLabels) const byId = new Map<string, { label: string; folder: NoteFolder; dir: string; tasks: VaultTask[] }>() const other: VaultTask[] = [] - const place = (id: string, label: string, folder: NoteFolder, dir: string, task: VaultTask): void => { - const entry = byId.get(id) - if (entry) entry.tasks.push(task) - else byId.set(id, { label, folder, dir, tasks: [task] }) - } for (const task of tasks) { - if (!isBoardCard(task)) continue - if (task.noteFolder === 'archive' && !showArchived) continue - const location = noteLocationOf(task, layout.systemFolderPaths) - const vaultDir = [location.prefix, location.dir].filter(Boolean).join('/') - if (!root) { - // Each note's own folder: the system folder's root as its label, a - // subfolder as its path. - const id = vaultDir || resolveFolderPath(location.folder, layout.systemFolderPaths) - place(id, folderColumnLabel(location.folder, location.dir, labels), location.folder, location.dir, task) - continue - } - // A root narrows the board to the primary area: the root's direct - // children are the columns, deeper notes roll up to their child, notes - // in the root itself get the root's column, and everything else (other - // system folders, folders beside the root) shares one trailing column. - if (location.folder !== 'inbox') { + const place = folderPlacement(task, layout, labels) + if (!place) { other.push(task) continue } - const dirLower = location.dir.toLowerCase() - if (dirLower === rootLower) { - const id = [location.prefix, location.dir].filter(Boolean).join('/') - place(id, location.dir.split('/').pop() ?? location.dir, 'inbox', location.dir, task) - continue - } - if (dirLower.startsWith(rootLower + '/')) { - const child = location.dir.slice(root.length + 1).split('/')[0] - const childDir = `${location.dir.slice(0, root.length)}/${child}` - const id = [location.prefix, childDir].filter(Boolean).join('/') - place(id, child, 'inbox', childDir, task) - continue - } - other.push(task) + const entry = byId.get(place.id) + if (entry) entry.tasks.push(task) + else byId.set(place.id, { label: place.label, folder: place.folder, dir: place.dir, tasks: [task] }) } const folderRank = (folder: NoteFolder): number => FOLDER_ORDER.indexOf(folder) @@ -454,6 +533,138 @@ function buildColumns( return statusColumns(tasks, today) } +/** A swimlane (#1027) before it holds cards: its id is what a drop on it + * writes (a priority, a tag, a field value, a folder), its label what its + * header reads. */ +export interface LaneDef { + id: string + label: string +} + +/** A swimlane: a row across the board's columns, each cell the column's cards + * that carry the lane's value, in the column's own order. */ +export interface Lane extends LaneDef { + columns: Column[] + /** Cards in the lane, across every column. */ + count: number +} + +const PRIORITY_LANES: readonly LaneDef[] = [ + { id: 'high', label: 'High' }, + { id: 'med', label: 'Medium' }, + { id: 'low', label: 'Low' }, + { id: 'none', label: 'No priority' } +] + +/** + * The lanes a board splits into and the lane each card belongs to, ordered + * the way the same field orders columns: priority High to No priority, a + * field's configured values then the ones found on tasks, folders by the + * folder board's order, tags sorted. The No <value> lane comes last. A card + * sits in exactly one lane: a tagged card in the lane of its first tag. + * + * Built from every card on the board, before the filter and the empty-task + * hiding narrow it, so a lane stays put while a query hides its cards, as a + * column does. Priority lanes and the No tag / No <field> lane always show, + * empty or not, since they are where a drop clears or sets the field. + */ +export function laneDefsFor( + laneBy: KanbanLaneBy, + cards: readonly VaultTask[], + { statuses, folderLayout }: { statuses: string[]; folderLayout: FolderBoardLayout } +): { defs: LaneDef[]; laneOf: (task: VaultTask) => string } { + if (laneBy === 'priority') { + return { defs: [...PRIORITY_LANES], laneOf: (task) => task.priority ?? 'none' } + } + if (laneBy === 'tag') { + const tags = new Set<string>() + for (const card of cards) if (card.tags?.[0]) tags.add(card.tags[0]) + const defs: LaneDef[] = [...tags].sort().map((tag) => ({ id: tag, label: `#${tag}` })) + defs.push({ id: NO_VALUE_COLUMN_ID, label: 'No tag' }) + return { defs, laneOf: (task) => task.tags?.[0] ?? NO_VALUE_COLUMN_ID } + } + if (laneBy === 'folder') { + const defs = folderGroups([...cards], folderLayout).map(({ id, label }) => ({ id, label })) + const labels = resolveSystemFolderLabels(folderLayout.systemFolderLabels) + return { + defs, + laneOf: (task) => folderPlacement(task, folderLayout, labels)?.id ?? NO_VALUE_COLUMN_ID + } + } + const fieldKey = laneBy.startsWith('field:') ? laneBy.slice('field:'.length) : null + if (!fieldKey) return { defs: [], laneOf: () => NO_VALUE_COLUMN_ID } + // The field board's column order (fieldColumns): configured values, then + // the ones found on tasks, sorted. + const ids: string[] = [] + for (const id of fieldKey === 'status' ? statuses : []) if (!ids.includes(id)) ids.push(id) + const found = new Set<string>() + for (const card of cards) { + const value = card.fields?.[fieldKey] + if (value) found.add(value) + } + for (const id of [...found].sort()) if (!ids.includes(id)) ids.push(id) + const defs: LaneDef[] = ids.map((id) => ({ id, label: prettifyFieldValue(id) })) + defs.push({ id: NO_VALUE_COLUMN_ID, label: `No ${fieldKey}` }) + return { defs, laneOf: (task) => task.fields?.[fieldKey] ?? NO_VALUE_COLUMN_ID } +} + +/** + * Split built columns into lanes: every lane a row of the same columns, each + * cell the column's cards in that lane, in the column's order. Every card + * lands in exactly one lane, so a column's cards across its cells are the + * column, and its WIP count (taken on the columns) is the whole column's. A + * card whose lane is not among `defs` gets a lane of its own at the end rather + * than leave the board. + */ +export function splitIntoLanes( + columns: readonly Column[], + defs: readonly LaneDef[], + laneOf: (task: VaultTask) => string +): Lane[] { + const lanes: Lane[] = defs.map((def) => ({ + ...def, + columns: columns.map((column) => ({ ...column, tasks: [] as VaultTask[], badge: undefined })), + count: 0 + })) + const byId = new Map(lanes.map((lane, index) => [lane.id, index] as const)) + columns.forEach((column, ci) => { + for (const task of column.tasks) { + const id = laneOf(task) + let at = byId.get(id) + if (at === undefined) { + at = lanes.length + byId.set(id, at) + lanes.push({ + id, + label: id, + columns: columns.map((c) => ({ ...c, tasks: [] as VaultTask[], badge: undefined })), + count: 0 + }) + } + lanes[at].columns[ci].tasks.push(task) + lanes[at].count += 1 + } + }) + return lanes +} + +/** The cursor on a card that moved, in lane mode: its lane, its column, its + * place in the cell, wherever the rebuilt board put it (the #492 rule). */ +export function cursorAfterLaneMove( + lanes: readonly Lane[], + columnId: string, + movedKey: string +): { laneIdx: number; colIdx: number; cardIdx: number } { + const colIdx = Math.max(0, lanes[0]?.columns.findIndex((column) => column.id === columnId) ?? 0) + for (let laneIdx = 0; laneIdx < lanes.length; laneIdx += 1) { + const cardIdx = lanes[laneIdx].columns[colIdx]?.tasks.findIndex( + (task) => taskIdentityKey(task) === movedKey + ) + if (cardIdx !== undefined && cardIdx >= 0) return { laneIdx, colIdx, cardIdx } + } + return { laneIdx: 0, colIdx, cardIdx: 0 } +} + function sameTaskIdentity(a: VaultTask, b: VaultTask): boolean { return a.sourcePath === b.sourcePath && a.taskIndex === b.taskIndex } @@ -541,6 +752,14 @@ export function applyTaskMutationsForBoard(task: VaultTask, mutations: TaskMutat if (m.key === 'status') next = { ...next, status: value } break } + // A tag lane (#1027): the first tag, in the text the card shows too. + case 'set-tag': + next = { + ...next, + tags: withFirstTag(next.tags ?? [], m.tag), + content: setFirstTag(next.content, m.tag).trim() + } + break } } return next @@ -705,6 +924,9 @@ export function kanbanCardStep(cardIdx: number, delta: -1 | 1, cardCount: number return Math.max(-1, Math.min(cardCount - 1, cardIdx + delta)) } +/** How long an armed `z` waits for the a / M / R of a lane fold (#1027). */ +export const LANE_FOLD_PENDING_MS = 1000 + /** How long an armed `g` waits for a tab-sequence completion before the * group-by cycle fires. Matches VimNav's own gt/gT pending window. */ export const GROUP_BY_PENDING_MS = 500 @@ -777,6 +999,8 @@ interface ActivePointerDrag { task: VaultTask pointerId: number sourceColumnId: string | null + /** The lane the card left, in lane mode (#1027); null without lanes. */ + sourceLaneId: string | null startX: number startY: number /** Latest pointer position, to re-aim when the board scrolls under a still pointer. */ @@ -787,10 +1011,21 @@ interface ActivePointerDrag { width: number height: number dragging: boolean - lastColumnId: string | null + /** The column, lane, or cell last hovered: the drop's fallback target. */ + lastTarget: BoardCell | null lastInsertionIndex: number | null } +/** What a dragged card is over: a column (or a column header in lane mode), + * a lane header, or a lane's cell, which is both (#1027). */ +interface DropTarget extends BoardCell { + element: HTMLElement +} + +function sameBoardCell(a: BoardCell | null, b: BoardCell | null): boolean { + return !!a && !!b && a.columnId === b.columnId && a.laneId === b.laneId +} + interface DragPreview { task: VaultTask isOverdue: boolean @@ -837,8 +1072,19 @@ export function TasksKanban({ const startTaskFromList = useStore((s) => s.startTaskFromList) const cancelTaskFromList = useStore((s) => s.cancelTaskFromList) const vimMode = useStore((s) => s.vimMode) + const kanbanLanes = useStore((s) => s.kanbanLanes) + const setKanbanLanes = useStore((s) => s.setKanbanLanes) + const kanbanCollapsedLanes = useStore((s) => s.kanbanCollapsedLanes) + const setKanbanCollapsedLanes = useStore((s) => s.setKanbanCollapsedLanes) + // Lanes by the columns' own field would put every card on the diagonal, so + // that choice shows no lanes, and comes back with another group-by (#1027). + const laneBy = activeLaneBy(groupBy, kanbanLanes) + const lanesOn = laneBy !== 'none' const [colIdx, setColIdx] = useState(0) const [cardIdx, setCardIdx] = useState(0) + // The lane under the cursor in lane mode; -1 is the column header row, and + // there `cardIdx` -1 is a lane's header (lib/kanban-lanes.ts). + const [laneIdx, setLaneIdx] = useState(0) const [draggingId, setDraggingId] = useState<string | null>(null) const [dragPreview, setDragPreview] = useState<DragPreview | null>(null) const [displayTasks, setDisplayTasks] = useState(tasks) @@ -900,9 +1146,24 @@ export function TasksKanban({ const dragOverColumnRef = useRef<string | null>(null) const dragOverElementRef = useRef<HTMLElement | null>(null) const suppressCardClickUntilRef = useRef(0) + // The card and the lane cursor as last drawn, for the keyboard moves. + const focusedTaskRef = useRef<VaultTask | undefined>(undefined) + const laneCursorNowRef = useRef<LaneCursor>({ laneIdx: -1, cardIdx: -1 }) // #573: a pressed `g` waiting out the gt/gT window before cycling group-by. const groupByPendingRef = useRef(false) const groupByTimerRef = useRef<number | null>(null) + // A pressed `z` waiting for the a / M / R of a lane fold (#1027). + const foldPendingRef = useRef(false) + const foldTimerRef = useRef<number | null>(null) + const rootRef = useRef<HTMLDivElement | null>(null) + // The lane header or empty cell under the cursor, in lane mode. + const laneCursorElRef = useRef<HTMLElement | null>(null) + const setLaneCursorEl = useCallback((el: HTMLElement | null): void => { + laneCursorElRef.current = el + }, []) + const lanesRef = useRef<Lane[]>([]) + const lanesOnRef = useRef(lanesOn) + lanesOnRef.current = lanesOn // Edge auto-scroll for both drags (#838): near the board's left or right edge // the board scrolls sideways, and a card held near the top or bottom of a // long column scrolls that column. @@ -910,6 +1171,8 @@ export function TasksKanban({ createDragAutoScroller({ horizontal: () => boardRef.current, vertical: (clientX, clientY) => { + // Lanes stack down the board, which scrolls as one; cells do not. + if (lanesOnRef.current) return boardRef.current const body = ( document.elementFromPoint(clientX, clientY) as HTMLElement | null )?.closest<HTMLElement>('[data-kanban-column-body]') @@ -1035,6 +1298,34 @@ export function TasksKanban({ columnsRef.current = columns fullColumnsRef.current = fullColumns + // Swimlanes (#1027): the lanes come from every card on the board, the cells + // from the columns as shown, so a filtered or hidden card leaves its cell + // and the lanes and their order stay. + const laneModel = useMemo( + () => + lanesOn + ? laneDefsFor( + laneBy, + fullColumns.flatMap((column) => column.tasks), + { statuses: kanbanStatuses, folderLayout } + ) + : null, + [folderLayout, fullColumns, kanbanStatuses, laneBy, lanesOn] + ) + const lanes = useMemo( + () => (laneModel ? splitIntoLanes(columns, laneModel.defs, laneModel.laneOf) : []), + [columns, laneModel] + ) + lanesRef.current = lanes + const collapsedLaneIds = + kanbanCollapsedLanes[kanbanCollapsedLanesKey(groupBy, laneBy)] ?? NO_LANE_IDS + const collapsedLanes = useMemo(() => new Set(collapsedLaneIds), [collapsedLaneIds]) + const collapsedLanesRef = useRef(collapsedLanes) + collapsedLanesRef.current = collapsedLanes + const columnsMovable = groupBy !== 'folder' + // Folder lanes are read-only like folder columns: moving a note is a move. + const lanesMovable = lanesOn && laneBy !== 'folder' + // Group-by options: the three static boards, the default custom-status field, // then one option per `@key:` field discovered across the current tasks. This // is what makes any inline field a board with zero configuration. (#354) @@ -1062,7 +1353,23 @@ export function TasksKanban({ return opts }, [tasks, groupBy]) - const dndEnabled = groupBy !== 'folder' + // The View menu's lane fields: Priority, Folder, Tag, Custom status once + // there are statuses, every `@field` in use, never the column field. + const laneFieldOptions = useMemo(() => { + const keys = new Set<string>() + let status = kanbanStatuses.length > 0 + for (const t of tasks) { + if (!t.fields) continue + for (const k of Object.keys(t.fields)) { + if (k === 'status') status = true + else keys.add(k) + } + } + return laneOptions(groupBy, keys, { customStatus: status, current: laneBy }) + }, [groupBy, kanbanStatuses.length, laneBy, tasks]) + + // A card drags when it can change a column or a lane. + const dndEnabled = columnsMovable || lanesMovable // The folder board's root (#730): a prompt listing the folders that hold // tasks, so the choice is one Enter away; an empty answer goes back to @@ -1312,6 +1619,7 @@ export function TasksKanban({ setColumnDropTarget(null) document.body.style.userSelect = '' } + // Read here, not in the cleanup: lanes on or off swap the board element. const board = boardRef.current window.addEventListener('pointermove', handleMove, { passive: false }) window.addEventListener('pointerup', handleUp, { passive: false }) @@ -1324,7 +1632,7 @@ export function TasksKanban({ board?.removeEventListener('scroll', handleScroll, { capture: true }) columnAutoScroll.stop() } - }, [columnAutoScroll, finishColumnDrag]) + }, [columnAutoScroll, finishColumnDrag, lanesOn]) useEffect(() => { if (!editingColumnId) return @@ -1418,7 +1726,14 @@ export function TasksKanban({ ) const placeTaskInColumnOrder = useCallback( - (task: VaultTask, targetColumnId: string, targetIndex: number | null) => { + ( + task: VaultTask, + targetColumnId: string, + targetIndex: number | null, + /** The cards the drop index counts: the target column as shown, or in + * lane mode the target cell (#1027). */ + visibleTargetTasks?: readonly VaultTask[] + ) => { // Ordered by due date, a card lands in its date slot; writing the board's // current (date) order here would overwrite the arrangement Manual // brings back. (#889) @@ -1431,8 +1746,11 @@ export function TasksKanban({ // column, so filtered-out cards keep their hand-arranged positions // instead of being pruned by the persist below. Unfiltered, the two // boards are identical and this is the plain bounded insert. - const visibleTarget = columnsRef.current.find((column) => column.id === targetColumnId) - const visibleKeys = (visibleTarget?.tasks ?? []) + const visibleTarget = + visibleTargetTasks ?? + columnsRef.current.find((column) => column.id === targetColumnId)?.tasks ?? + [] + const visibleKeys = visibleTarget .map((columnTask) => taskIdentityKey(columnTask)) .filter((key) => key !== movingKey) const anchorKey = visibleKeys[targetIndex] ?? null @@ -1495,10 +1813,11 @@ export function TasksKanban({ task: VaultTask, mutations: TaskMutation[], targetColumnId: string | null, - targetIndex: number | null + targetIndex: number | null, + visibleTargetTasks?: readonly VaultTask[] ) => { if (targetColumnId) { - placeTaskInColumnOrder(task, targetColumnId, targetIndex) + placeTaskInColumnOrder(task, targetColumnId, targetIndex, visibleTargetTasks) } if (mutations.length === 0) return const overlayTask = applyTaskToBoard(task, mutations) @@ -1513,7 +1832,7 @@ export function TasksKanban({ // (absolute). (#354) const moveFocusedCardTo = useCallback( (toIdx: number): void => { - if (!dndEnabled) return + if (!columnsMovable) return const cols = columnsRef.current if (cols.length === 0) return const fromIdx = Math.min(colIdx, cols.length - 1) @@ -1522,7 +1841,10 @@ export function TasksKanban({ return } const fromColumn = cols[fromIdx] - const task = fromColumn?.tasks[Math.min(cardIdx, fromColumn.tasks.length - 1)] + // In lane mode the card is the one in the focused cell (#1027). + const task = lanesOnRef.current + ? focusedTaskRef.current + : fromColumn?.tasks[Math.min(cardIdx, fromColumn.tasks.length - 1)] if (!task) { return } @@ -1536,11 +1858,47 @@ export function TasksKanban({ // The board rebuild is flushed synchronously, so columnsRef already holds // the post-move columns: read the card's new home out of them instead of // assuming it sits at the top of the column we aimed at. (#492) + if (lanesOnRef.current) { + const next = cursorAfterLaneMove(lanesRef.current, targetColumn.id, movedKey) + setLaneIdx(next.laneIdx) + setColIdx(next.colIdx) + setCardIdx(next.cardIdx) + return + } const next = cursorAfterCardMove(columnsRef.current, targetColumn.id, movedKey) setColIdx(next.colIdx) setCardIdx(next.cardIdx) }, - [cardIdx, colIdx, dndEnabled, groupBy, moveTaskOnBoard, today] + [cardIdx, colIdx, columnsMovable, groupBy, moveTaskOnBoard, today] + ) + // Shift+J/K: the focused card one lane down or up, writing that lane's + // field the way a drop on it would (#1027). A collapsed lane opens to take + // it, so the cursor can follow the card in. + const moveFocusedCardToLane = useCallback( + (dir: -1 | 1): void => { + if (!lanesMovable) return + const task = focusedTaskRef.current + const from = laneCursorNowRef.current.laneIdx + const target = lanesRef.current[from + dir] + const column = columnsRef.current[Math.min(colIdx, columnsRef.current.length - 1)] + if (!task || from < 0 || !target || !column) return + const mutations = laneDropMutationsFor(laneBy, target.id, task) + if (!mutations || mutations.length === 0) return + if (collapsedLanesRef.current.has(target.id)) { + setKanbanCollapsedLanes( + groupBy, + laneBy, + [...collapsedLanesRef.current].filter((id) => id !== target.id) + ) + } + const movedKey = taskIdentityKey(task) + moveTaskOnBoard(task, mutations, null, null) + const next = cursorAfterLaneMove(lanesRef.current, column.id, movedKey) + setLaneIdx(next.laneIdx) + setColIdx(next.colIdx) + setCardIdx(next.cardIdx) + }, + [colIdx, groupBy, laneBy, lanesMovable, moveTaskOnBoard, setKanbanCollapsedLanes] ) const moveFocusedCard = useCallback( (dir: -1 | 1): void => { @@ -1562,8 +1920,96 @@ export function TasksKanban({ setGroupBy(next.value) setColIdx(0) setCardIdx(0) + setLaneIdx(0) }, [groupByOptions, setGroupBy]) + // ---- Swimlanes (#1027): pick the field, fold lanes -------------------- + const chooseLanes = useCallback( + (next: KanbanLaneBy): void => { + setKanbanLanes(next) + setLaneIdx(0) + setCardIdx(0) + setSubFocusId(null) + }, + [setKanbanLanes] + ) + // `S` steps through the lane fields and back to none, saying where it is, + // since the choice itself sits in the View menu. + const cycleLanes = useCallback((): void => { + const next = nextLaneBy(laneFieldOptions, laneBy) + chooseLanes(next) + useToastStore + .getState() + .addToast(next === 'none' ? 'Lanes off' : `Lanes: ${laneFieldLabel(next)}`, 'info') + }, [chooseLanes, laneBy, laneFieldOptions]) + + const setLanesCollapsed = useCallback( + (ids: Iterable<string>): void => setKanbanCollapsedLanes(groupBy, laneBy, [...ids]), + [groupBy, laneBy, setKanbanCollapsedLanes] + ) + // Folding the lane the cursor is in leaves the cursor on its header, so it + // never points into cards that just went away. + const toggleLane = useCallback( + (laneId: string): void => { + const next = new Set(collapsedLanesRef.current) + if (next.has(laneId)) { + next.delete(laneId) + } else { + next.add(laneId) + const at = lanesRef.current.findIndex((lane) => lane.id === laneId) + if (at >= 0 && at === laneCursorNowRef.current.laneIdx) { + setLaneIdx(at) + setCardIdx(-1) + setSubFocusId(null) + } + } + setLanesCollapsed(next) + }, + [setLanesCollapsed] + ) + const setAllLanesCollapsed = useCallback( + (collapsed: boolean): void => { + if (collapsed) { + setLanesCollapsed(lanesRef.current.map((lane) => lane.id)) + if (laneCursorNowRef.current.laneIdx >= 0) setCardIdx(-1) + setSubFocusId(null) + } else { + setLanesCollapsed([]) + } + }, + [setLanesCollapsed] + ) + const openLaneMenu = useCallback( + (lane: Lane, x: number, y: number): void => { + const collapsed = collapsedLanesRef.current.has(lane.id) + setMenu({ + x, + y, + items: [ + { + label: collapsed ? 'Expand lane' : 'Collapse lane', + hint: vimMode ? 'za' : 'Enter', + keywords: ['fold', 'unfold', 'hide', 'show'], + onSelect: () => toggleLane(lane.id) + }, + { + label: 'Collapse all lanes', + hint: vimMode ? 'zM' : undefined, + keywords: ['fold', 'hide'], + onSelect: () => setAllLanesCollapsed(true) + }, + { + label: 'Expand all lanes', + hint: vimMode ? 'zR' : undefined, + keywords: ['unfold', 'show'], + onSelect: () => setAllLanesCollapsed(false) + } + ] + }) + }, + [setAllLanesCollapsed, toggleLane, vimMode] + ) + const scheduleDropIndicatorPosition = useCallback((x: number, y: number, width: number) => { dropIndicatorRectRef.current = { x, y, width } if (dropIndicatorFrameRef.current != null) return @@ -1602,28 +2048,65 @@ export function TasksKanban({ }) }, []) - const columnAtPoint = useCallback((clientX: number, clientY: number) => { + // A lane header is a lane alone; anything else with a column id is a + // column, or in lane mode a cell, which carries its lane too (#1027). + const dropTargetAtPoint = useCallback((clientX: number, clientY: number): DropTarget | null => { const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null - const columnEl = el?.closest<HTMLElement>('[data-kanban-column-id]') - if (!columnEl || !boardRef.current?.contains(columnEl)) return null - return { id: columnEl.dataset.kanbanColumnId ?? null, element: columnEl } + const board = boardRef.current + if (!el || !board) return null + const laneHeader = el.closest<HTMLElement>('[data-kanban-lane-header]') + if (laneHeader && board.contains(laneHeader)) { + return { columnId: null, laneId: laneHeader.dataset.kanbanLaneId ?? null, element: laneHeader } + } + const columnEl = el.closest<HTMLElement>('[data-kanban-column-id]') + if (!columnEl || !board.contains(columnEl)) return null + return { + columnId: columnEl.dataset.kanbanColumnId ?? null, + laneId: columnEl.dataset.kanbanLaneId ?? null, + element: columnEl + } }, []) + /** The cards shown in the cell a drop aims at: the column, or in lane mode + * the column's cell in the lane (a header drop keeps the card's own lane + * or column). */ + const visibleCellTasks = useCallback( + (drag: ActivePointerDrag, target: BoardCell): readonly VaultTask[] => { + const columnId = target.columnId ?? drag.sourceColumnId + if (!lanesOnRef.current) { + return columnsRef.current.find((column) => column.id === columnId)?.tasks ?? [] + } + const laneId = target.laneId ?? drag.sourceLaneId + return ( + lanesRef.current + .find((lane) => lane.id === laneId) + ?.columns.find((column) => column.id === columnId)?.tasks ?? [] + ) + }, + [] + ) + + const dropMutations = useCallback( + (drag: ActivePointerDrag, target: BoardCell): TaskMutation[] | null => + boardDropMutations( + groupBy, + laneBy, + { columnId: drag.sourceColumnId, laneId: drag.sourceLaneId }, + target, + drag.task, + today + ), + [groupBy, laneBy, today] + ) + const updateDropIndicator = useCallback( - ( - drag: ActivePointerDrag, - target: { id: string | null; element: HTMLElement } | null, - clientY: number - ): number | null => { - if (!target?.id) { + (drag: ActivePointerDrag, target: DropTarget | null, clientY: number): number | null => { + if (!target || (target.columnId === null && target.laneId === null)) { hideDropIndicator() return null } - const mutations = - target.id === drag.sourceColumnId - ? [] - : dropMutationsFor(groupBy, target.id, drag.task, today) + const mutations = dropMutations(drag, target) if (!mutations) { hideDropIndicator() return null @@ -1642,10 +2125,10 @@ export function TasksKanban({ let boundedIndex: number if (useStore.getState().kanbanCardSort === 'due') { // Ordered by due date there is no place to choose: within its own - // column the card stays put, and in another it lands in its date + // cell the card stays put, and in another it lands in its date // slot, which the line shows (a Today or Upcoming drop can change the // date first). (#889) - if (target.id === drag.sourceColumnId) { + if (mutations.length === 0) { hideDropIndicator() return null } @@ -1657,9 +2140,7 @@ export function TasksKanban({ ...drag.task, due: dueMutation ? (dueMutation.due ?? undefined) : drag.task.due } - const others = ( - columnsRef.current.find((column) => column.id === target.id)?.tasks ?? [] - ).filter((task) => task.id !== drag.task.id) + const others = visibleCellTasks(drag, target).filter((task) => task.id !== drag.task.id) const slot = others.findIndex((task) => compareCardsByDue(landing, task) < 0) boundedIndex = slot === -1 ? others.length : slot } else { @@ -1682,68 +2163,84 @@ export function TasksKanban({ scheduleDropIndicatorPosition(bodyRect.left + 12, y, bodyRect.width - 24) return boundedIndex }, - [groupBy, hideDropIndicator, scheduleDropIndicatorPosition, today] + [dropMutations, hideDropIndicator, scheduleDropIndicatorPosition, visibleCellTasks] ) const finishPointerDrag = useCallback( (drag: ActivePointerDrag, releaseX: number, releaseY: number): void => { if (!dndEnabled) return - // The drop belongs to the column under the RELEASE point. `lastColumnId` + // The drop belongs to the column under the RELEASE point. `lastTarget` // trails the last delivered pointermove, and a fast flick can release a // column past it: the hand says Today, the trailing state says Upcoming, // and the "moved to Today" card comes back due tomorrow. Falling back to // the last hovered column covers a release the board can't resolve (the // gap between columns, the padding above them), where the indicator the // user last saw is still the honest target. - const releaseTarget = columnAtPoint(releaseX, releaseY) - const columnId = releaseTarget?.id ?? drag.lastColumnId - if (!columnId) return - // The remembered insertion index was computed for the hovered column; a + const release = dropTargetAtPoint(releaseX, releaseY) + const target: BoardCell | null = release + ? { columnId: release.columnId, laneId: release.laneId } + : drag.lastTarget + if (!target || (target.columnId === null && target.laneId === null)) return + // The remembered insertion index was computed for the hovered cell; a // release that resolves elsewhere appends instead of importing it. - const insertionIndex = columnId === drag.lastColumnId ? drag.lastInsertionIndex : null - const mutations = - columnId === drag.sourceColumnId - ? [] - : dropMutationsFor(groupBy, columnId, drag.task, today) + const insertionIndex = sameBoardCell(target, drag.lastTarget) ? drag.lastInsertionIndex : null + const mutations = dropMutations(drag, target) if (!mutations) return + // A lane header drop keeps the card's column, a column header drop in + // lane mode its lane. + const columnId = target.columnId ?? drag.sourceColumnId + const laneId = target.laneId ?? drag.sourceLaneId + if (!columnId) return + const cellTasks = visibleCellTasks(drag, target) // The cursor lands on the dropped card, the way a click, a right-click // and Shift+H/L already put it on theirs. It moves BEFORE the card does: // the move flushes the board rebuild synchronously, and a rebuild under // the old cursor scrolls the board back to it, a whole board away once // an edge auto-scroll carried the card there (#838). The insertion index - // is exact for the visible column; a drop without one resolves below. + // is exact for the visible cell; a drop without one resolves below. const movedKey = taskIdentityKey(drag.task) const targetColIdx = columnsRef.current.findIndex((column) => column.id === columnId) if (targetColIdx >= 0) { - const currentIdx = columnsRef.current[targetColIdx].tasks.findIndex( - (task) => taskIdentityKey(task) === movedKey - ) + const currentIdx = cellTasks.findIndex((task) => taskIdentityKey(task) === movedKey) setColIdx(targetColIdx) setCardIdx(insertionIndex ?? Math.max(0, currentIdx)) + if (lanesOnRef.current) { + setLaneIdx(Math.max(0, lanesRef.current.findIndex((lane) => lane.id === laneId))) + } } - moveTaskOnBoard(drag.task, mutations, columnId, insertionIndex) + moveTaskOnBoard(drag.task, mutations, columnId, insertionIndex, cellTasks) if (mutations.length > 0) { // That flushed rebuild already ran, so the board knows where the card landed. + if (lanesOnRef.current) { + const next = cursorAfterLaneMove(lanesRef.current, columnId, movedKey) + setLaneIdx(next.laneIdx) + setColIdx(next.colIdx) + setCardIdx(next.cardIdx) + return + } const next = cursorAfterCardMove(columnsRef.current, columnId, movedKey) setColIdx(next.colIdx) setCardIdx(next.cardIdx) } }, - [columnAtPoint, dndEnabled, groupBy, moveTaskOnBoard, today] + [dndEnabled, dropMutations, dropTargetAtPoint, moveTaskOnBoard, visibleCellTasks] ) const beginPointerDrag = useCallback( (task: VaultTask, e: React.PointerEvent<HTMLDivElement>): void => { if (!dndEnabled || e.button !== 0) return const rect = e.currentTarget.getBoundingClientRect() - const sourceColumnId = - columnsRef.current.find((column) => - column.tasks.some((candidate) => sameTaskIdentity(candidate, task)) - )?.id ?? null + const holds = (column: Column): boolean => + column.tasks.some((candidate) => sameTaskIdentity(candidate, task)) + const sourceColumnId = columnsRef.current.find(holds)?.id ?? null + const sourceLaneId = lanesOnRef.current + ? (lanesRef.current.find((lane) => lane.columns.some(holds))?.id ?? null) + : null pointerDragRef.current = { task, pointerId: e.pointerId, sourceColumnId, + sourceLaneId, startX: e.clientX, startY: e.clientY, pointerX: e.clientX, @@ -1753,7 +2250,7 @@ export function TasksKanban({ width: rect.width, height: rect.height, dragging: false, - lastColumnId: null, + lastTarget: null, lastInsertionIndex: null } }, @@ -1762,13 +2259,19 @@ export function TasksKanban({ useEffect(() => { const aimPointerDrag = (drag: ActivePointerDrag): void => { - const target = columnAtPoint(drag.pointerX, drag.pointerY) - if (target?.id && dndEnabled) { - drag.lastColumnId = target.id - markDropTarget(target.id, target.element) + const target = dropTargetAtPoint(drag.pointerX, drag.pointerY) + if (target && (target.columnId !== null || target.laneId !== null) && dndEnabled) { + drag.lastTarget = { columnId: target.columnId, laneId: target.laneId } + // A place the card cannot go (a read-only folder lane, a column the + // board cannot write) is not lit up as one. + if (dropMutations(drag, target)) { + markDropTarget(`${target.columnId ?? ''}|${target.laneId ?? ''}`, target.element) + } else { + clearDropTarget() + } drag.lastInsertionIndex = updateDropIndicator(drag, target, drag.pointerY) } else { - drag.lastColumnId = null + drag.lastTarget = null drag.lastInsertionIndex = null clearDropTarget() hideDropIndicator() @@ -1865,10 +2368,12 @@ export function TasksKanban({ cardAutoScroll, clearDragPreview, clearDropTarget, - columnAtPoint, dndEnabled, + dropMutations, + dropTargetAtPoint, finishPointerDrag, hideDropIndicator, + lanesOn, markDropTarget, scheduleDragPreviewPosition, today, @@ -1878,10 +2383,33 @@ export function TasksKanban({ // Clamp focus on column/card if the data shifts under us. const safeColIdx = Math.min(colIdx, Math.max(0, columns.length - 1)) const focusedColumn = columns[safeColIdx] - const safeCardIdx = focusedColumn - ? Math.min(cardIdx, Math.max(0, focusedColumn.tasks.length - 1)) - : 0 - const focusedTask = focusedColumn?.tasks[safeCardIdx] + // In lane mode the cursor walks the focused column's lane headers and cells + // (#1027); a folded lane or a shrunk cell clamps it like a column does. + const laneShapes: LaneShape[] = lanes.map((lane) => ({ + cards: lane.columns[safeColIdx]?.tasks.length ?? 0, + collapsed: collapsedLanes.has(lane.id) + })) + const laneCursor: LaneCursor | null = lanesOn + ? clampLaneCursor({ laneIdx, cardIdx }, laneShapes) + : null + if (laneCursor) laneCursorNowRef.current = laneCursor + const focusedLane = laneCursor && laneCursor.laneIdx >= 0 ? lanes[laneCursor.laneIdx] : null + const focusedCell = focusedLane?.columns[safeColIdx] ?? null + const safeCardIdx = laneCursor + ? laneCursor.cardIdx + : focusedColumn + ? Math.min(cardIdx, Math.max(0, focusedColumn.tasks.length - 1)) + : 0 + const focusedTask = laneCursor + ? laneCursor.cardIdx >= 0 + ? focusedCell?.tasks[laneCursor.cardIdx] + : undefined + : focusedColumn?.tasks[safeCardIdx] + focusedTaskRef.current = focusedTask + // The lane's header under the cursor, or its empty cell: where Enter and + // the menu keys act on the lane. + const laneHeaderFocused = !!focusedLane && laneCursor!.cardIdx < 0 + const emptyCellFocused = !!focusedLane && laneCursor!.cardIdx >= 0 && !focusedTask const focusedSubtasks = (focusedTask && folded.subtasksByCard.get(focusedTask.id)) || NO_SUBTASKS const focusedSubIdx = subFocusId @@ -1893,8 +2421,11 @@ export function TasksKanban({ const actionTask = focusedSubtask ?? focusedTask // No card under the cursor: k above the first card, or an empty column. - // The header is what Enter and the menu keys act on then (#1025). - const headerFocused = !!focusedColumn && !focusedTask + // The header is what Enter and the menu keys act on then (#1025). In lane + // mode the column header is the row above the first lane. + const headerFocused = laneCursor + ? !!focusedColumn && laneCursor.laneIdx < 0 + : !!focusedColumn && !focusedTask // Shift+F10, the Context Menu key, or `m` in Vim mode: the right-click menu // of what the cursor is on, a card's task menu or its column's header menu. @@ -1916,6 +2447,7 @@ export function TasksKanban({ if (focusedHeader && domColIdx >= 0) { setColIdx(domColIdx) setCardIdx(-1) + if (lanesOnRef.current) setLaneIdx(-1) const { x, y } = belowHeader(focusedHeader) openColumnMenu(columnsRef.current[domColIdx], x, y) return @@ -1925,11 +2457,17 @@ export function TasksKanban({ setMenu({ x, y, items: buildTaskMenuItems(actionTask, { today, showKeyHints: vimMode }) }) return } + // A lane header or an empty cell: the lane's menu (#1027). + if (focusedLane && laneCursorElRef.current) { + const { x, y } = belowHeader(laneCursorElRef.current) + openLaneMenu(focusedLane, x, y) + return + } if (focusedColumn && headerRef.current) { const { x, y } = belowHeader(headerRef.current) openColumnMenu(focusedColumn, x, y) } - }, [actionTask, focusedColumn, openColumnMenu, today, vimMode]) + }, [actionTask, focusedColumn, focusedLane, openColumnMenu, openLaneMenu, today, vimMode]) // Switching the order keeps the cursor on its card, wherever the new order // puts it: the rebuild is flushed first, the way a move is. (#889) @@ -1938,6 +2476,17 @@ export function TasksKanban({ if (useStore.getState().kanbanCardSort === next) return flushSync(() => setCardSort(next)) if (!focusedColumn || !focusedTask) return + if (lanesOnRef.current) { + const cursor = cursorAfterLaneMove( + lanesRef.current, + focusedColumn.id, + taskIdentityKey(focusedTask) + ) + setLaneIdx(cursor.laneIdx) + setColIdx(cursor.colIdx) + setCardIdx(cursor.cardIdx) + return + } const cursor = cursorAfterCardMove( columnsRef.current, focusedColumn.id, @@ -1949,10 +2498,52 @@ export function TasksKanban({ [focusedColumn, focusedTask, setCardSort] ) + const focusedLaneIdx = laneCursor?.laneIdx ?? -1 useEffect(() => { if (headerFocused) headerRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) - else cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) - }, [headerFocused, safeColIdx, safeCardIdx, focusedTask?.id]) + else if (laneHeaderFocused) { + // A lane header spans the whole board, so scrollIntoView would also + // slide the board sideways to its left edge: only scroll up or down, + // clear of the sticky column headers. + const el = laneCursorElRef.current + const board = boardRef.current + if (el && board) { + const view = board.getBoundingClientRect() + const rect = el.getBoundingClientRect() + const top = + view.top + + (board.querySelector('[data-kanban-lane-columns]')?.getBoundingClientRect().height ?? 0) + if (rect.top < top) board.scrollTop -= top - rect.top + else if (rect.bottom > view.bottom) board.scrollTop += rect.bottom - view.bottom + } + } else if (emptyCellFocused) { + laneCursorElRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } else cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, [ + emptyCellFocused, + focusedLaneIdx, + headerFocused, + laneHeaderFocused, + safeColIdx, + safeCardIdx, + focusedTask?.id + ]) + + // A lane field change starts the cursor over in the first lane. + useEffect(() => { + setLaneIdx(0) + }, [groupBy, laneBy]) + + // A pressed `z` waits this long for the a / M / R of a lane fold. + const clearFoldPending = useCallback((): void => { + foldPendingRef.current = false + rootRef.current?.removeAttribute('data-kanban-key-pending') + if (foldTimerRef.current !== null) { + window.clearTimeout(foldTimerRef.current) + foldTimerRef.current = null + } + }, []) + useEffect(() => clearFoldPending, [clearFoldPending]) useEffect(() => clearDropTarget, [clearDropTarget]) @@ -2000,6 +2591,28 @@ export function TasksKanban({ // key just drops the prefix and acts normally. } + // A `z` waits for the a / M / R of a lane fold (#1027). A bare Shift + // (the M of zM) keeps it; any other key drops it, Vim-style, and acts. + if (foldPendingRef.current) { + if (e.key === 'Shift' || e.key === 'CapsLock') return + clearFoldPending() + if (!e.metaKey && !e.ctrlKey && !e.altKey) { + const fold = + e.key === 'a' ? 'toggle' : e.key === 'M' ? 'collapse' : e.key === 'R' ? 'expand' : null + if (fold) { + e.preventDefault() + e.stopImmediatePropagation() + const lane = lanesRef.current[laneCursorNowRef.current.laneIdx] + if (fold === 'toggle') { + if (lane) toggleLane(lane.id) + } else { + setAllLanesCollapsed(fold === 'collapse') + } + return + } + } + } + if (e.metaKey || e.ctrlKey || e.altKey) return // The menu keys work in either mode (Shift+F10 and the Context Menu // key are not letters); `m` is Vim's, as in the sidebar and the lists. @@ -2058,19 +2671,53 @@ export function TasksKanban({ setSubFocusId(null) return case 'H': - if (dndEnabled && focusedTask) { + if (columnsMovable && focusedTask) { consume() setSubFocusId(null) moveFocusedCard(-1) } return case 'L': - if (dndEnabled && focusedTask) { + if (columnsMovable && focusedTask) { consume() setSubFocusId(null) moveFocusedCard(1) } return + // Lanes (#1027). Shift+J/K move the card a lane down or up, as + // Shift+H/L move it across; `{` / `}` hop lane header to lane header + // the way they hop note to note in the Tags view. + case 'J': + case 'K': + if (laneCursor && lanesMovable && focusedTask) { + consume() + setSubFocusId(null) + moveFocusedCardToLane(e.key === 'J' ? 1 : -1) + } + return + case '{': + case '}': + if (laneCursor) { + consume() + const next = laneJump(laneCursor, e.key === '}' ? 1 : -1, lanes.length) + setLaneIdx(next.laneIdx) + setCardIdx(next.cardIdx) + setSubFocusId(null) + } + return + case 'z': + if (laneCursor) { + consume() + foldPendingRef.current = true + // TasksView's own keys (`a` is New task) stand aside meanwhile. + rootRef.current?.setAttribute('data-kanban-key-pending', '') + foldTimerRef.current = window.setTimeout(clearFoldPending, LANE_FOLD_PENDING_MS) + } + return + case 'S': + consume() + cycleLanes() + return case 's': consume() chooseCardSort(useStore.getState().kanbanCardSort === 'due' ? 'manual' : 'due') @@ -2085,16 +2732,20 @@ export function TasksKanban({ return case 'j': case 'ArrowDown': - consume() - setCardIdx(kanbanCardStep(safeCardIdx, 1, focusedColumn?.tasks.length ?? 0)) - setSubFocusId(null) - return case 'k': - case 'ArrowUp': + case 'ArrowUp': { consume() - setCardIdx(kanbanCardStep(safeCardIdx, -1, focusedColumn?.tasks.length ?? 0)) + const delta = e.key === 'j' || e.key === 'ArrowDown' ? 1 : -1 + if (laneCursor) { + const next = laneCursorStep(laneCursor, delta, laneShapes) + setLaneIdx(next.laneIdx) + setCardIdx(next.cardIdx) + } else { + setCardIdx(kanbanCardStep(safeCardIdx, delta, focusedColumn?.tasks.length ?? 0)) + } setSubFocusId(null) return + } // Into the focused card's subtasks and back out (#1028). A card // without subtasks leaves Tab to the page, as before. case 'Tab': @@ -2118,6 +2769,10 @@ export function TasksKanban({ if (actionTask) { consume() onOpenTask(actionTask) + } else if (focusedLane) { + // A lane header folds; an empty cell has nothing to open. + consume() + if (laneHeaderFocused) toggleLane(focusedLane.id) } else if (focusedColumn) { // On a header, Enter renames the column, as a click on its title does. consume() @@ -2136,6 +2791,9 @@ export function TasksKanban({ if (actionTask) { consume() onToggleTask(actionTask) + } else if (e.key === ' ' && focusedLane && laneHeaderFocused) { + consume() + toggleLane(focusedLane.id) } return // Same task states the list and the right-click menu offer, so a card @@ -2170,12 +2828,23 @@ export function TasksKanban({ }, [ beginColumnRename, chooseCardSort, + clearFoldPending, clearGroupByPending, columns.length, + columnsMovable, cycleGroupBy, + cycleLanes, actionTask, - dndEnabled, focusedColumn, + focusedLane, + laneCursor, + laneHeaderFocused, + laneShapes, + lanes.length, + lanesMovable, + moveFocusedCardToLane, + setAllLanesCollapsed, + toggleLane, focusedSubIdx, focusedSubtask, focusedSubtasks, @@ -2190,8 +2859,198 @@ export function TasksKanban({ vimMode ]) + const fieldKey = fieldKeyOf(groupBy) + // A limit counts the column's whole load, across every lane and before the + // filter; with no limit the count stays what the column shows. + const columnWip = (column: Column): WipState | null => + wipState( + wipLoads.get(column.id) ?? column.tasks.length, + kanbanWipLimits[columnOrderKey(groupBy, column.id)] + ) + + /** A column's header: its title (renamed in place), its WIP or count, its + * overdue badge. Without lanes it tops its column; with lanes it stands + * alone in the row above them, `alone`. */ + const renderColumnHeader = (column: Column, ci: number, alone: boolean): JSX.Element => { + const isColumnFocused = ci === safeColIdx + const accent = fieldKey ? columnAccent(column.id) : null + const isMovableColumn = column.id !== NO_VALUE_COLUMN_ID + // Reveal the underlying field value when a custom title would otherwise + // hide it, so a renamed column can't masquerade as the stored value. (#389) + const rawValueHint = + fieldKey && isMovableColumn && kanbanColumnTitles[columnOrderKey(groupBy, column.id)] + ? column.id + : null + const wip = columnWip(column) + const isHeaderFocused = isColumnFocused && headerFocused + return ( + <div + ref={isHeaderFocused ? headerRef : undefined} + data-kanban-column-header + data-focused={isHeaderFocused ? '' : undefined} + onPointerDown={(e) => beginColumnDrag(column.id, e)} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + // The cursor moves onto the header first, so the menu and + // the keyboard agree on which column is being acted on. + setColIdx(ci) + setCardIdx(-1) + if (lanesOn) setLaneIdx(-1) + openColumnMenu(column, e.clientX, e.clientY) + }} + className={[ + 'flex shrink-0 select-none items-center justify-between gap-2 px-3 py-2', + alone ? 'rounded-lg' : 'rounded-t-lg border-b', + wip?.over ? 'border-danger/35' : 'border-paper-300/45', + isHeaderFocused ? 'ring-1 ring-inset ring-accent/60' : '', + isMovableColumn && editingColumnId !== column.id + ? 'cursor-grab active:cursor-grabbing' + : '' + ].join(' ')} + > + <div className="min-w-0 flex-1"> + {editingColumnId === column.id ? ( + <input + ref={columnTitleInputRef} + value={editingTitle} + aria-label={`Rename ${column.label} column`} + onChange={(e) => setEditingTitle(e.target.value)} + onBlur={() => commitColumnRename(column.id)} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return + if (e.key === 'Enter') { + e.preventDefault() + commitColumnRename(column.id) + } else if (e.key === 'Escape') { + e.preventDefault() + cancelColumnRename() + } + }} + className="h-6 w-full min-w-0 rounded border border-accent/60 bg-paper-200/80 px-1.5 text-xs font-semibold text-current/90 outline-none ring-1 ring-accent/25" + /> + ) : ( + <button + type="button" + aria-label={`Rename ${column.label} column`} + title="Rename column" + onClick={(e) => { + e.stopPropagation() + if (shouldSuppressColumnRename()) return + beginColumnRename(column) + }} + className="group/title flex max-w-full items-center gap-1 rounded-sm text-left outline-none focus-visible:ring-1 focus-visible:ring-accent/60" + > + {accent && ( + <span + className="h-2 w-2 shrink-0 rounded-full" + style={{ backgroundColor: accent }} + aria-hidden="true" + /> + )} + <span className="truncate text-xs font-semibold uppercase tracking-wide text-current/70"> + {column.label} + </span> + <PencilIcon + width={12} + height={12} + className="shrink-0 text-current/45 opacity-0 transition-opacity group-hover/title:opacity-100 group-focus-visible/title:opacity-100" + /> + </button> + )} + {rawValueHint && editingColumnId !== column.id && ( + <span + className="mt-0.5 block truncate font-mono text-2xs lowercase text-current/40" + title={`Tasks in this column are tagged @${fieldKey}:${rawValueHint}`} + > + @{fieldKey}:{rawValueHint} + </span> + )} + </div> + <div className="flex items-center gap-1.5 text-xs text-current/50"> + {wip ? ( + <span + data-kanban-wip + title={ + `WIP limit ${wip.limit}` + + (wip.over ? `, ${wip.load - wip.limit} over` : '') + + (wip.load !== column.tasks.length ? ` (${column.tasks.length} shown)` : '') + } + className={ + wip.over + ? 'flex items-center gap-1 rounded bg-danger/15 px-1.5 py-0.5 text-2xs font-medium text-danger' + : '' + } + > + {wip.over && <AlertTriangleIcon width={12} height={12} aria-hidden="true" />} + {wip.load} / {wip.limit} + </span> + ) : ( + <span>{column.tasks.length}</span> + )} + {column.badge?.kind === 'overdue' && column.badge.value > 0 && ( + <span className="rounded bg-rose-500/15 px-1.5 py-0.5 text-2xs font-medium text-rose-300"> + {column.badge.value} overdue + </span> + )} + </div> + </div> + ) + } + + /** One card. `laneIdx` is its lane in lane mode, null without lanes. */ + const renderCard = ( + task: VaultTask, + ti: number, + ci: number, + laneIdxOfCard: number | null, + isFocused: boolean + ): JSX.Element => { + const subtasks = folded.subtasksByCard.get(task.id) ?? NO_SUBTASKS + const focusCard = (subtaskId: string | null): void => { + if (laneIdxOfCard !== null) setLaneIdx(laneIdxOfCard) + setColIdx(ci) + setCardIdx(ti) + setSubFocusId(subtaskId) + } + return ( + <TaskCard + key={task.id} + taskDomId={task.id} + task={task} + isOverdue={isTaskOverdue(task, today)} + isFocused={isFocused} + isDragging={draggingId === task.id} + draggable={dndEnabled} + belowStickyRow={laneIdxOfCard !== null} + cardRef={isFocused ? cardRef : null} + onClickRow={() => focusCard(null)} + onOpen={() => onOpenTask(task)} + shouldSuppressClick={shouldSuppressCardClick} + onToggle={() => onToggleTask(task)} + onPointerDown={(e) => beginPointerDrag(task, e)} + onContextMenu={openTaskMenu} + toggleKey={vimMode ? 'x' : null} + subtasks={subtasks} + progress={subtaskProgress(task, subtasks)} + subtasksOpen={openSubtaskCards.has(task.id) || (isFocused && focusedSubtask != null)} + onToggleSubtasks={() => { + focusCard(null) + toggleSubtaskList(task.id) + }} + focusedSubtaskId={isFocused ? (focusedSubtask?.id ?? null) : null} + onFocusSubtask={(subtask) => focusCard(subtask.id)} + onToggleSubtask={onToggleTask} + onOpenSubtask={onOpenTask} + /> + ) + } + return ( - <div className="flex min-h-0 flex-1 flex-col"> + <div ref={rootRef} className="flex min-h-0 flex-1 flex-col"> {/* Group by, Order and the View menu; the keys sit behind Keys. On a narrow pane each group wraps whole, never a label inside it. */} <div @@ -2244,9 +3103,18 @@ export function TasksKanban({ <option value="due">Due date</option> </select> </div> - <TasksViewOptions hidden={hiddenEmpty} /> + <TasksViewOptions + hidden={hiddenEmpty} + lanes={{ value: laneBy, options: laneFieldOptions, onChange: chooseLanes }} + /> <div className="ml-auto shrink-0"> - <TasksKeysButton view="kanban" dndEnabled={dndEnabled} subtasks /> + <TasksKeysButton + view="kanban" + dndEnabled={columnsMovable} + subtasks + lanes={lanesOn} + laneMoves={lanesMovable} + /> </div> </div> @@ -2264,218 +3132,208 @@ export function TasksKanban({ </div> )} - <div ref={boardRef} className="flex min-h-0 flex-1 gap-2 overflow-x-auto px-3 py-3"> - {columns.map((column, ci) => { - const isColumnFocused = ci === safeColIdx - const fieldKey = fieldKeyOf(groupBy) - const accent = fieldKey ? columnAccent(column.id) : null - const isMovableColumn = column.id !== NO_VALUE_COLUMN_ID - const isColumnDragging = draggingColumnId === column.id - const isColumnDropTarget = columnDropTarget?.id === column.id - // Reveal the underlying field value when a custom title would otherwise - // hide it, so a renamed column can't masquerade as the stored value. (#389) - const rawValueHint = - fieldKey && isMovableColumn && kanbanColumnTitles[columnOrderKey(groupBy, column.id)] - ? column.id - : null - // A limit counts the column's whole load, not the filtered view of - // it; with no limit the count stays what the column shows. - const wip = wipState( - wipLoads.get(column.id) ?? column.tasks.length, - kanbanWipLimits[columnOrderKey(groupBy, column.id)] - ) - const isHeaderFocused = isColumnFocused && headerFocused - return ( - <div - key={column.id} - data-kanban-column-id={column.id} - data-wip-over={wip?.over ? '' : undefined} - className={[ - 'task-kanban-column flex w-72 shrink-0 flex-col rounded-lg border transition-opacity', - wip?.over - ? `bg-danger/5 ${isColumnFocused ? 'border-danger' : 'border-danger/60'}` - : `bg-paper-100/60 ${isColumnFocused ? 'border-paper-400/70' : 'border-paper-300/60'}`, - isColumnDragging ? 'opacity-40' : '', - isColumnDropTarget ? 'ring-2 ring-accent/70' : '' - ].join(' ')} - > + {!lanesOn && ( + <div ref={boardRef} className="flex min-h-0 flex-1 gap-2 overflow-x-auto px-3 py-3"> + {columns.map((column, ci) => { + const isColumnFocused = ci === safeColIdx + const wip = columnWip(column) + return ( <div - ref={isHeaderFocused ? headerRef : undefined} - data-kanban-column-header - data-focused={isHeaderFocused ? '' : undefined} - onPointerDown={(e) => beginColumnDrag(column.id, e)} - onContextMenu={(e) => { - e.preventDefault() - e.stopPropagation() - // The cursor moves onto the header first, so the menu and - // the keyboard agree on which column is being acted on. - setColIdx(ci) - setCardIdx(-1) - openColumnMenu(column, e.clientX, e.clientY) - }} + key={column.id} + data-kanban-column-id={column.id} + data-wip-over={wip?.over ? '' : undefined} className={[ - 'flex shrink-0 select-none items-center justify-between gap-2 rounded-t-lg border-b px-3 py-2', - wip?.over ? 'border-danger/35' : 'border-paper-300/45', - isHeaderFocused ? 'ring-1 ring-inset ring-accent/60' : '', - isMovableColumn && editingColumnId !== column.id - ? 'cursor-grab active:cursor-grabbing' - : '' + 'task-kanban-column flex w-72 shrink-0 flex-col rounded-lg border transition-opacity', + wip?.over + ? `bg-danger/5 ${isColumnFocused ? 'border-danger' : 'border-danger/60'}` + : `bg-paper-100/60 ${isColumnFocused ? 'border-paper-400/70' : 'border-paper-300/60'}`, + draggingColumnId === column.id ? 'opacity-40' : '', + columnDropTarget?.id === column.id ? 'ring-2 ring-accent/70' : '' ].join(' ')} > - <div className="min-w-0 flex-1"> - {editingColumnId === column.id ? ( - <input - ref={columnTitleInputRef} - value={editingTitle} - aria-label={`Rename ${column.label} column`} - onChange={(e) => setEditingTitle(e.target.value)} - onBlur={() => commitColumnRename(column.id)} - onClick={(e) => e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onKeyDown={(e) => { - // While composing (IME), let the input own Enter/Arrows. (#183) - if (isImeComposing(e)) return - if (e.key === 'Enter') { - e.preventDefault() - commitColumnRename(column.id) - } else if (e.key === 'Escape') { - e.preventDefault() - cancelColumnRename() - } - }} - className="h-6 w-full min-w-0 rounded border border-accent/60 bg-paper-200/80 px-1.5 text-xs font-semibold text-current/90 outline-none ring-1 ring-accent/25" - /> + {renderColumnHeader(column, ci, false)} + <div + onClick={() => setColIdx(ci)} + data-kanban-column-body + className="min-h-0 flex-1 overflow-y-auto p-2" + > + {column.tasks.length === 0 ? ( + <div className="rounded-md border border-dashed border-paper-300/60 px-2 py-3 text-center text-xs text-current/40"> + nothing here + </div> ) : ( + <div className="space-y-1.5"> + {column.tasks.map((task, ti) => + renderCard(task, ti, ci, null, isColumnFocused && ti === safeCardIdx) + )} + </div> + )} + </div> + </div> + ) + })} + </div> + )} + + {/* Swimlanes (#1027): the column headers stay on top as the board + scrolls down, each lane a header row (sticky on the left while it + scrolls across) above one cell per column. */} + {lanesOn && ( + <div ref={boardRef} data-kanban-lanes className="min-h-0 flex-1 overflow-auto"> + <div className="flex min-w-max flex-col px-3 pb-3"> + <div + data-kanban-lane-columns + className="sticky top-0 z-10 flex gap-2 bg-paper-100 pb-2 pt-3" + > + {columns.map((column, ci) => { + const isColumnFocused = ci === safeColIdx + const wip = columnWip(column) + return ( + <div + key={column.id} + data-kanban-column-id={column.id} + data-wip-over={wip?.over ? '' : undefined} + className={[ + 'task-kanban-column w-72 shrink-0 rounded-lg border transition-opacity', + wip?.over + ? `bg-danger/5 ${isColumnFocused ? 'border-danger' : 'border-danger/60'}` + : `bg-paper-100 ${isColumnFocused ? 'border-paper-400/70' : 'border-paper-300/60'}`, + draggingColumnId === column.id ? 'opacity-40' : '', + columnDropTarget?.id === column.id ? 'ring-2 ring-accent/70' : '' + ].join(' ')} + > + {renderColumnHeader(column, ci, true)} + </div> + ) + })} + </div> + <div className="flex flex-col gap-2"> + {lanes.map((lane, li) => { + const collapsed = collapsedLanes.has(lane.id) + const isLaneHeaderFocused = laneHeaderFocused && focusedLaneIdx === li + const dot = laneDotOf(laneBy, lane.id) + return ( + <section key={lane.id} data-kanban-lane={lane.id} aria-label={`${lane.label} lane`}> <button type="button" - aria-label={`Rename ${column.label} column`} - title="Rename column" - onClick={(e) => { + tabIndex={-1} + ref={isLaneHeaderFocused ? setLaneCursorEl : undefined} + data-kanban-lane-header + data-kanban-lane-id={lane.id} + data-focused={isLaneHeaderFocused ? '' : undefined} + aria-expanded={!collapsed} + title={collapsed ? 'Expand lane' : 'Collapse lane'} + // The board's own cursor follows the click; DOM focus + // stays where the keys are read. + onMouseDown={(e) => e.preventDefault()} + onClick={() => { + setLaneIdx(li) + setCardIdx(-1) + setSubFocusId(null) + toggleLane(lane.id) + }} + onContextMenu={(e) => { + e.preventDefault() e.stopPropagation() - if (shouldSuppressColumnRename()) return - beginColumnRename(column) + setLaneIdx(li) + setCardIdx(-1) + openLaneMenu(lane, e.clientX, e.clientY) }} - className="group/title flex max-w-full items-center gap-1 rounded-sm text-left outline-none focus-visible:ring-1 focus-visible:ring-accent/60" + className={[ + 'task-kanban-lane-header flex w-full scroll-mt-16 items-center rounded-md border py-1 text-left outline-none transition-colors', + isLaneHeaderFocused + ? 'border-accent/60 bg-paper-200/60' + : 'border-transparent hover:bg-paper-200/50' + ].join(' ')} > - {accent && ( - <span - className="h-2 w-2 shrink-0 rounded-full" - style={{ backgroundColor: accent }} + <span className="sticky left-3 flex items-center gap-1.5 whitespace-nowrap px-2"> + <ChevronRightIcon + width={12} + height={12} aria-hidden="true" + className={[ + 'shrink-0 text-current/50 transition-transform duration-150', + collapsed ? '' : 'rotate-90' + ].join(' ')} /> - )} - <span className="truncate text-xs font-semibold uppercase tracking-wide text-current/70"> - {column.label} + {dot && ( + <span + className={['h-2 w-2 shrink-0 rounded-full', dot.className ?? ''].join(' ')} + style={dot.color ? { backgroundColor: dot.color } : undefined} + aria-hidden="true" + /> + )} + <span className="text-xs font-semibold text-current/80">{lane.label}</span> + <span className="text-2xs tabular-nums text-current/45">{lane.count}</span> </span> - <PencilIcon - width={12} - height={12} - className="shrink-0 text-current/45 opacity-0 transition-opacity group-hover/title:opacity-100 group-focus-visible/title:opacity-100" - /> </button> - )} - {rawValueHint && editingColumnId !== column.id && ( - <span - className="mt-0.5 block truncate font-mono text-2xs lowercase text-current/40" - title={`Tasks in this column are tagged @${fieldKey}:${rawValueHint}`} - > - @{fieldKey}:{rawValueHint} - </span> - )} - </div> - <div className="flex items-center gap-1.5 text-xs text-current/50"> - {wip ? ( - <span - data-kanban-wip - title={ - `WIP limit ${wip.limit}` + - (wip.over ? `, ${wip.load - wip.limit} over` : '') + - (wip.load !== column.tasks.length ? ` (${column.tasks.length} shown)` : '') - } - className={ - wip.over - ? 'flex items-center gap-1 rounded bg-danger/15 px-1.5 py-0.5 text-2xs font-medium text-danger' - : '' - } - > - {wip.over && <AlertTriangleIcon width={12} height={12} aria-hidden="true" />} - {wip.load} / {wip.limit} - </span> - ) : ( - <span>{column.tasks.length}</span> - )} - {column.badge?.kind === 'overdue' && column.badge.value > 0 && ( - <span className="rounded bg-rose-500/15 px-1.5 py-0.5 text-2xs font-medium text-rose-300"> - {column.badge.value} overdue - </span> - )} - </div> - </div> - <div - onClick={() => setColIdx(ci)} - data-kanban-column-body - className="min-h-0 flex-1 overflow-y-auto p-2" - > - {column.tasks.length === 0 ? ( - <div className="rounded-md border border-dashed border-paper-300/60 px-2 py-3 text-center text-xs text-current/40"> - nothing here - </div> - ) : ( - <div className="space-y-1.5"> - {column.tasks.map((task, ti) => { - const isFocused = isColumnFocused && ti === safeCardIdx - const isDragging = draggingId === task.id - const subtasks = folded.subtasksByCard.get(task.id) ?? NO_SUBTASKS - return ( - <TaskCard - key={task.id} - taskDomId={task.id} - task={task} - isOverdue={isTaskOverdue(task, today)} - isFocused={isFocused} - isDragging={isDragging} - draggable={dndEnabled} - cardRef={isFocused ? cardRef : null} - onClickRow={() => { - setColIdx(ci) - setCardIdx(ti) - setSubFocusId(null) - }} - onOpen={() => onOpenTask(task)} - shouldSuppressClick={shouldSuppressCardClick} - onToggle={() => onToggleTask(task)} - onPointerDown={(e) => beginPointerDrag(task, e)} - onContextMenu={openTaskMenu} - toggleKey={vimMode ? 'x' : null} - subtasks={subtasks} - progress={subtaskProgress(task, subtasks)} - subtasksOpen={ - openSubtaskCards.has(task.id) || (isFocused && focusedSubtask != null) - } - onToggleSubtasks={() => { - setColIdx(ci) - setCardIdx(ti) - setSubFocusId(null) - toggleSubtaskList(task.id) - }} - focusedSubtaskId={isFocused ? (focusedSubtask?.id ?? null) : null} - onFocusSubtask={(subtask) => { - setColIdx(ci) - setCardIdx(ti) - setSubFocusId(subtask.id) - }} - onToggleSubtask={onToggleTask} - onOpenSubtask={onOpenTask} - /> - ) - })} - </div> - )} - </div> + {!collapsed && ( + <div className="mt-1 flex gap-2"> + {lane.columns.map((cell, ci) => { + const isCellFocused = ci === safeColIdx && focusedLaneIdx === li + const isEmptyFocused = isCellFocused && emptyCellFocused + const over = columnWip(columns[ci])?.over + return ( + <div + key={cell.id} + data-kanban-column-id={cell.id} + data-kanban-lane-id={lane.id} + data-kanban-lane-cell + className={[ + 'task-kanban-lane-cell w-72 shrink-0 rounded-lg border transition-opacity', + over + ? 'border-danger/40 bg-danger/5' + : `bg-paper-100/60 ${ci === safeColIdx ? 'border-paper-400/50' : 'border-paper-300/50'}`, + draggingColumnId === cell.id ? 'opacity-40' : '' + ].join(' ')} + > + <div + data-kanban-column-body + onClick={() => { + setLaneIdx(li) + setColIdx(ci) + if (cell.tasks.length === 0) setCardIdx(0) + }} + className="p-2" + > + {cell.tasks.length === 0 ? ( + <div + ref={isEmptyFocused ? setLaneCursorEl : undefined} + data-kanban-empty-cell + data-focused={isEmptyFocused ? '' : undefined} + className={[ + 'h-8 scroll-mt-16 rounded-md border', + isEmptyFocused + ? 'border-dashed border-accent/60 bg-paper-200/50' + : 'border-transparent' + ].join(' ')} + /> + ) : ( + <div className="space-y-1.5"> + {cell.tasks.map((task, ti) => + renderCard( + task, + ti, + ci, + li, + isCellFocused && laneCursor?.cardIdx === ti + ) + )} + </div> + )} + </div> + </div> + ) + })} + </div> + )} + </section> + ) + })} </div> - ) - })} - </div> + </div> + </div> + )} {!dndEnabled && ( <div className="shrink-0 border-t border-paper-300/45 px-3 py-1.5 text-xs text-current/40"> Folder grouping is read-only: move a task across folders by moving its source note in @@ -2542,6 +3400,9 @@ interface CardProps { onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void /** Right-click actions. Omitted on the drag preview, which is not a real card. */ onContextMenu?: (e: React.MouseEvent, task: VaultTask) => void + /** In lane mode the column headers stay on top of the scrolling board; a + * card scrolled into view stops below them. */ + belowStickyRow?: boolean /** The key the checkbox tooltip names; none with Vim mode off. */ toggleKey?: string | null /** Subtasks folded into this card, in note order (#1028). */ @@ -2746,6 +3607,7 @@ function TaskCard({ onToggle, onPointerDown, onContextMenu, + belowStickyRow = false, toggleKey = null, subtasks = NO_SUBTASKS, progress = null, @@ -2761,6 +3623,7 @@ function TaskCard({ ref={cardRef ?? undefined} hidden={isDragging} data-kanban-task-id={taskDomId} + data-focused={isFocused ? '' : undefined} onContextMenu={ onContextMenu ? (e) => { @@ -2785,6 +3648,7 @@ function TaskCard({ // stays a touch heavier and turns rose for overdue tasks. 'group rounded-md border border-l-2 border-paper-300/50 bg-paper-100/85 px-2.5 py-1.5 transition-colors select-none', isOverdue ? 'border-l-rose-500/70' : '', + belowStickyRow ? 'scroll-mt-16' : '', isFocused ? 'ring-1 ring-accent/60' : 'hover:border-paper-300/75 hover:bg-paper-200/60', draggable ? 'cursor-grab active:cursor-grabbing' : '' ].join(' ')} diff --git a/packages/app-core/src/components/TasksToolbar.tsx b/packages/app-core/src/components/TasksToolbar.tsx index 695ac52d4..d73a2697a 100644 --- a/packages/app-core/src/components/TasksToolbar.tsx +++ b/packages/app-core/src/components/TasksToolbar.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { createPortal } from 'react-dom' -import { useStore } from '../store' +import { useStore, type KanbanLaneBy } from '../store' import { emptyTasksShowLabel } from '../lib/empty-tasks' import { isKeyboardContextMenuKey } from '../lib/keyboard-context-menu' import { tasksKeySheet, type TasksKeysView } from '../lib/tasks-keys' -import { ContextMenu } from './ContextMenu' +import type { LaneOption } from '../lib/kanban-lanes' +import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { KeyboardIcon, SlidersIcon } from './icons' /** @@ -58,13 +59,27 @@ export function tasksToolbarOwnsKey(e: KeyboardEvent): boolean { return true } +/** The board's lane choice for the View menu (#1027). */ +export interface TasksViewLanes { + value: KanbanLaneBy + options: readonly LaneOption[] + onChange: (next: KanbanLaneBy) => void +} + /** * The View menu (#1026): display options for the Tasks views, today Hide - * empty tasks; later board options belong in it too, not as more toolbar + * empty tasks, and on the board its lanes (#1027, "Lanes: None", "Lanes: + * Priority" and on); board options belong in it, not as more toolbar * controls. Beside it, only when something is hidden, a quiet "3 hidden" * that shows them on a click. */ -export function TasksViewOptions({ hidden }: { hidden: number }): JSX.Element { +export function TasksViewOptions({ + hidden, + lanes +}: { + hidden: number + lanes?: TasksViewLanes +}): JSX.Element { const hide = useStore((s) => s.hideEmptyTasks) const setHide = useStore((s) => s.setHideEmptyTasks) const vimMode = useStore((s) => s.vimMode) @@ -119,7 +134,8 @@ export function TasksViewOptions({ hidden }: { hidden: number }): JSX.Element { hint: vimMode ? 'E' : undefined, keywords: ['empty', 'blank', 'placeholder', 'template'], onSelect: () => setHide(!hide) - } + }, + ...(lanes ? laneMenuItems(lanes) : []) ]} onClose={() => setMenu(null)} /> @@ -128,6 +144,21 @@ export function TasksViewOptions({ hidden }: { hidden: number }): JSX.Element { ) } +/** The lane fields as View menu rows, one checked: "Lanes: Priority". */ +function laneMenuItems({ value, options, onChange }: TasksViewLanes): ContextMenuItem[] { + return [ + { kind: 'separator' }, + ...options.map( + (option): ContextMenuItem => ({ + label: `Lanes: ${option.label}`, + checked: option.value === value, + keywords: ['lanes', 'swimlanes', 'rows', 'split'], + onSelect: () => onChange(option.value) + }) + ) + ] +} + const SHEET_TITLES: Record<TasksKeysView, string> = { list: 'List keys', calendar: 'Calendar keys', @@ -142,12 +173,18 @@ const SHEET_TITLES: Record<TasksKeysView, string> = { export function TasksKeysButton({ view, dndEnabled = true, - subtasks = false + subtasks = false, + lanes = false, + laneMoves = false }: { view: TasksKeysView dndEnabled?: boolean /** The board's Subtasks group (Tab / Shift+Tab); see tasksKeySheet. */ subtasks?: boolean + /** The board's Lanes group, while it has lanes (#1027). */ + lanes?: boolean + /** Cards move between those lanes (Shift+J / K). */ + laneMoves?: boolean }): JSX.Element { const vimMode = useStore((s) => s.vimMode) const listOrder = useStore((s) => s.tasksListOrder) @@ -196,7 +233,7 @@ export function TasksKeysButton({ } }, [anchor, close]) - const sheet = tasksKeySheet(view, { vimMode, dndEnabled, listOrder, subtasks }) + const sheet = tasksKeySheet(view, { vimMode, dndEnabled, listOrder, subtasks, lanes, laneMoves }) return ( <> diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 4a162c1bf..48c207346 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -8,6 +8,7 @@ import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' import { TasksKanban, parseWipLimitInput, type KanbanControl } from './TasksKanban' import { TasksKeysButton, TasksViewOptions, tasksToolbarOwnsKey } from './TasksToolbar' +import { laneFieldLabel, parseLanesArg } from '../lib/kanban-lanes' import { isEmptyTask, withoutEmptyTasks } from '../lib/empty-tasks' import { CalendarIcon, CheckSquareIcon, KanbanIcon, ListIcon } from './icons' import { advanceSequence, getKeymapBinding, matchesSequenceToken, type KeymapId } from '../lib/keymaps' @@ -567,6 +568,25 @@ export function TasksView(): JSX.Element { toast(parsed.limit === null ? `${label}: no WIP limit` : `${label}: WIP limit ${parsed.limit}`) return } + // `:lanes priority` splits the board into lanes by a second field + // (priority, folder, tag, or any @field); bare `:lanes` turns them off, + // like Lanes: None in the View menu. (#1027) + if (head === 'lanes') { + const store = useStore.getState() + const wanted = parseLanesArg(arg) + if (wanted === null) { + toast('Usage: :lanes priority | folder | tag | @field, or :lanes to turn off') + return + } + if (wanted !== 'none' && wanted === store.kanbanGroupBy) { + toast('The columns already use that field') + return + } + store.setKanbanLanes(wanted) + setViewMode('kanban') + toast(wanted === 'none' ? 'Lanes off' : `Lanes: ${laneFieldLabel(wanted)}`) + return + } if (head === 'delfilter' || head === 'df') { if (!arg) { toast('Usage: :delfilter <name>') @@ -701,6 +721,9 @@ export function TasksView(): JSX.Element { } // A toolbar button reached with Tab takes its own keys. if (tasksToolbarOwnsKey(e)) return + // The board holds a `z` for a lane fold (#1027): its a / M / R are the + // board's, not New task's `a`, whichever handler runs first. + if (rootRef.current?.querySelector('[data-kanban-key-pending]')) return const overrides = keymapOverrides // A chord gets through only as the key of one of this view's actions, // and never past a global shortcut (#972, lib/list-view-keys.ts). diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index b791250ec..3905860dd 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -12,7 +12,8 @@ import { isTasksViewActive, isTrashViewActive, isUnresolvedLinksViewActive, - useStore + useStore, + type KanbanLaneBy } from '../store' import { openLinkPopoverAtCursor } from './link-popover-open' import { confirmApp } from './confirm-requests' @@ -1893,6 +1894,26 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma run: () => getState().setKanbanCardSort(getState().kanbanCardSort === 'due' ? 'manual' : 'due') }) + // Kanban lanes (#1027), from any note: the next board opened uses them. The + // @field lanes are picked on the board (its View menu, S, :lanes). + const laneCommands: [KanbanLaneBy, string][] = [ + ['priority', 'Kanban: Lanes by Priority'], + ['folder', 'Kanban: Lanes by Folder'], + ['tag', 'Kanban: Lanes by Tag'], + ['none', 'Kanban: Lanes Off'] + ] + for (const [laneBy, title] of laneCommands) { + cmds.push({ + id: `view.kanban-lanes.${laneBy}`, + unavailableReason: () => 'The board already shows these lanes', + title, + category: 'View', + keywords: 'tasks board kanban lanes swimlanes rows split priority folder tag', + when: () => getState().kanbanLanes !== laneBy, + run: () => getState().setKanbanLanes(laneBy) + }) + } + // Empty tasks (#1026), from any note: the list, calendar and board follow it. cmds.push({ id: 'view.hide-empty-tasks', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 6bc22d7bb..703a3a0db 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -399,6 +399,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'A WIP limit caps how many cards a column should hold. Right-click a column header and choose **Set WIP limit…**. The header then reads `2 / 3`; past the limit the column turns red, as in `4 / 3`. From the keyboard, `↑` on a column’s first card reaches its header, where `Shift+F10` opens the menu and `Enter` renames the column. In Vim mode, `W` sets the focused column’s limit, `:wip 3` does too, and a bare `:wip` clears it. Limits are kept per board and column, in config.toml’s `[kanban_wip_limits]` table, such as `"status:in-progress" = 3`. The count ignores the filter and the hidden empty tasks.' }, + { + title: 'Swimlanes on the Kanban board', + body: + 'Split the board into horizontal lanes by a second field: in the **View** menu (`v` in Vim mode), pick Lanes: Priority, Folder, Tag, or any `@field` you use. Each lane is a row across the same columns, with its own count. Drag a card into another lane to change that field on its task line, the way a column drop changes the column’s: a priority lane writes `!high`, an `@sprint` lane `@sprint:24`, and a tag lane makes its tag the task’s first (a task sits in the lane of its first tag; No tag removes them). Folder lanes are read-only, like folder columns. Click a lane’s header to collapse it; collapsed lanes are kept per board. WIP limits still count the whole column. In Vim mode, `S` steps through the lane fields, `{` / `}` hop between lanes, `Shift+J` / `Shift+K` move the card a lane down or up, `za` collapses or expands a lane, and `zM` / `zR` do all of them. With Vim mode off, the arrows reach each lane’s header, where Enter or Space collapses it. On the ex line, `:lanes tag` (a bare `:lanes` turns them off). Config key: `kanban_lanes` under `[view]`.' + }, { title: 'Keys for the Tasks views', body: @@ -804,23 +809,25 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'j / k', action: 'Move row cursor', detail: 'Step through task rows, tagged notes and their tagged blocks, or trashed notes.' }, { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Move to the first or last visible result.' }, { keys: 'Enter / o', action: 'Open current result', detail: 'Open the selected task source note, tagged note, or trashed note. On a tagged block, open its note at that line, highlighted.' }, - { keys: '} / {', action: 'Next or previous note', detail: 'Tags view only: hop to the next note past the current note\'s tagged blocks, or back to the previous note (from a block, to its own note).' }, + { keys: '} / {', action: 'Next or previous note or lane', detail: 'Tags view: hop to the next note past the current note\'s tagged blocks, or back to the previous note (from a block, to its own note). On a Kanban board with lanes: hop to the next or previous lane\'s header (from a card, to its own lane).' }, { keys: 'x', action: 'Toggle task', detail: 'Tasks view only: check or uncheck the selected task. A checked task lingers in place for a couple of seconds before it drops into Done, so you can toggle it again to undo. Space also toggles unless Space is your Vim leader key, in which case it starts a leader sequence.' }, { keys: '>', action: 'Forward task', detail: 'Tasks list only: forward the selected task to another note. Opens a note picker that filters as you type and preselects the first match, so Enter forwards without reaching for an arrow key. The original becomes a forwarded record (`[>]`) linking to the target, and a fresh copy is added there, backlinked home, subtasks and all. Forwarded tasks live under a “Forwarded” group.' }, { keys: 'i', action: 'Mark task in progress', detail: 'Tasks list only: mark the selected task as started (`- [/]`), or set it back to open. In-progress tasks keep their group and their day on the calendar, so the row keeps its place.' }, { keys: 'c', action: 'Cancel task', detail: 'Tasks list only: mark the selected task as intentionally abandoned (`- [-]`), or un-cancel it. Cancelled tasks live under a “Cancelled” group, out of Today and Done.' }, { keys: '@', action: 'Choose due date', detail: 'Tasks list, board, and calendar: give the selected task any due date from the month calendar that typing `@date` opens in a note, the same as Choose a date… in its right-click menu. It starts on the task’s due date (today without one); arrows move, Enter sets the day, or type a date.' }, { keys: 'Tab / Shift+Tab', action: 'Step through a card’s subtasks', detail: 'Kanban board: step onto a card’s subtasks and back. Space (or x), Enter, i, c and @ act on that subtask; Esc returns to the card. Works with Vim mode on or off.' }, - { keys: 'K / J', action: 'Move task up / down', detail: 'Tasks list only: reorder the selected task within its group. Works with Vim mode on or off, in Note order (see `s`).' }, + { keys: 'K / J', action: 'Move task up / down', detail: 'Tasks list: reorder the selected task within its group. Works with Vim mode on or off, in Note order (see `s`). On a Kanban board with lanes, in Vim mode: move the card a lane up or down, writing that lane\'s field.' }, { keys: 's', action: 'Order the list', detail: 'Tasks list only: switch the order inside each group between the notes’ line order and priority (high, then medium, low, and none, ties by due date). Same as the Order menu above the list, `:order priority` / `:order note`, and `tasks_list_order` under `[view]` in config.toml.' }, { keys: 'r', action: 'Restore trashed note', detail: 'Trash view only: restore the selected trashed note.' }, { keys: 'x / d', action: 'Delete forever', detail: 'Trash view only: permanently delete the selected trashed note after confirmation.' }, { keys: '/', action: 'Filter the view', detail: 'Focus the local filter box for tasks, tag matches, or trashed notes. In Tags, a word from a tagged block narrows the list to that block.' }, { keys: 'F', action: 'Pick a saved Tasks filter', detail: 'Tasks view: open the list of saved filters and apply one. Save the current query with the Save filter… chip or `:savefilter <name>`.' }, { keys: 'E', action: 'Show or hide empty tasks', detail: 'Tasks view: show or hide bare `- [ ]` tasks in the list, calendar, and board.' }, - { keys: 'v', action: 'View menu', detail: 'Tasks view: open the View menu (Hide empty tasks).' }, + { keys: 'v', action: 'View menu', detail: 'Tasks view: open the View menu (Hide empty tasks, and the board\'s Lanes).' }, { keys: '?', action: 'Keys', detail: 'Tasks view: list the current view’s keys. Esc closes it.' }, { keys: 'W', action: 'Set a WIP limit', detail: 'Tasks board only: set the focused column’s WIP limit. Same as `:wip <limit>`.' }, + { keys: 'S', action: 'Pick the board\'s lanes', detail: 'Tasks board only: step the lanes through None, Priority, Folder, Tag and your @fields. Same as Lanes in the View menu, or `:lanes <field>`.' }, + { keys: 'za / zM / zR', action: 'Collapse lanes', detail: 'Tasks board with lanes: `za` collapses or expands the lane under the cursor, `zM` collapses every lane, `zR` expands them all. Enter on a lane\'s header does what `za` does.' }, { keys: ':', action: 'Open local ex prompt', detail: 'Run the view-specific command line inside Tasks or Tags.' }, { keys: 'Esc', action: 'Clear the filter', detail: 'Clears an active filter. These views are tabs, so Esc no longer closes them. Close with :q or the ✕ in the tab header.' } ] @@ -954,6 +961,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Set the focused Kanban column’s WIP limit', detail: 'On the board, `:wip 3` caps the focused column at three cards; past that it turns red. A bare `:wip` clears it. Same as Set WIP limit… in the column menu, or `W`.' }, + { + command: ':lanes <field>', + summary: 'Split the Kanban board into lanes', + detail: 'On the board, `:lanes priority` splits it into a lane per priority; `folder`, `tag`, `status` (Custom status) and any `@field` work too. A bare `:lanes` turns lanes off. Same as Lanes in the View menu, or `S` to step through them.' + }, { command: ':delfilter <name>', summary: 'Delete a saved Tasks filter', @@ -1403,6 +1415,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ items: [ { label: 'Show tasks from archived notes', detail: 'Off by default: archiving a note retires its tasks from the Tasks list, calendar, and Kanban. Turn this on to keep them visible everywhere, Archive column included. The raw key is `show_archived_tasks` in `config.toml`.' }, { label: 'Hide empty tasks', detail: 'On by default: bare `- [ ]` tasks stay out of the Tasks views. Same as the View menu; config key `hide_empty_tasks`.' }, + { label: 'Kanban lanes', detail: 'Splits the Kanban board into rows by Priority, Folder, or Tag; an `@field` picked on the board shows here too. Same as Lanes in the View menu; config key `kanban_lanes`.' }, { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' }, { label: 'Tasks list order', detail: 'Note order keeps each group of the Tasks list in its notes’ line order, which Shift+J/K and dragging rearrange; Priority puts high priority first, then medium, low, and none, ties by due date. The list’s Order menu sets the same thing, and the raw key is `tasks_list_order` (`note` or `priority`) under `[view]` in `config.toml`.' }, { label: 'Kanban card order', detail: 'Manual keeps the cards in the order you drag them into; Due date sorts every Kanban column by due date, cards without one last, and keeps your manual order for when you switch back. The board\'s Order menu sets the same thing, and the raw key is `kanban_card_sort` (`manual` or `due`) under `[view]` in `config.toml`.' } diff --git a/packages/app-core/src/lib/kanban-lanes.test.ts b/packages/app-core/src/lib/kanban-lanes.test.ts new file mode 100644 index 000000000..a5aecf034 --- /dev/null +++ b/packages/app-core/src/lib/kanban-lanes.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest' +import type { VaultTask } from '@shared/tasks' +import { + activeLaneBy, + clampLaneCursor, + laneCursorStep, + laneFieldLabel, + laneJump, + laneOptions, + nextLaneBy, + parseLanesArg, + type LaneShape +} from './kanban-lanes' +import { boardDropMutations, laneDropMutationsFor } from './task-column-mutations' + +const TODAY = new Date('2026-10-30T12:00:00') + +function task(over: Partial<VaultTask> = {}): VaultTask { + return { + id: 'inbox/a.md#0', + sourcePath: 'inbox/a.md', + noteTitle: 'a', + noteFolder: 'inbox', + lineNumber: 0, + taskIndex: 0, + rawText: '- [ ] a', + content: 'a', + checked: false, + forwarded: false, + cancelled: false, + inProgress: false, + waiting: false, + tags: [], + ...over + } +} + +describe('the lane fields a board offers (#1027)', () => { + it('offers None, Priority, Folder, Tag, then the @fields, never the column field', () => { + const values = laneOptions('status', ['sprint', 'area'], { customStatus: false, current: 'none' }).map( + (o) => o.value + ) + expect(values).toEqual(['none', 'priority', 'folder', 'tag', 'field:area', 'field:sprint']) + const onPriority = laneOptions('priority', [], { customStatus: false, current: 'none' }).map((o) => o.value) + expect(onPriority).not.toContain('priority') + const onSprint = laneOptions('field:sprint', ['sprint', 'area'], { customStatus: false, current: 'none' }) + expect(onSprint.map((o) => o.value)).not.toContain('field:sprint') + }) + + it('offers Custom status once statuses exist, and keeps a chosen field nobody uses now', () => { + const values = laneOptions('status', [], { customStatus: true, current: 'field:sprint' }).map((o) => o.value) + expect(values).toEqual(['none', 'priority', 'folder', 'tag', 'field:status', 'field:sprint']) + }) + + it('labels each field in a few words', () => { + expect(laneFieldLabel('none')).toBe('None') + expect(laneFieldLabel('tag')).toBe('Tag') + expect(laneFieldLabel('field:status')).toBe('Custom status') + expect(laneFieldLabel('field:sprint')).toBe('@sprint') + }) + + it('shows no lanes while the columns use the lane field, and keeps the choice for later', () => { + expect(activeLaneBy('priority', 'priority')).toBe('none') + expect(activeLaneBy('status', 'priority')).toBe('priority') + expect(activeLaneBy('field:sprint', 'field:sprint')).toBe('none') + }) + + it('cycles through the offered fields and back to None', () => { + const options = laneOptions('status', [], { customStatus: false, current: 'none' }) + expect(nextLaneBy(options, 'none')).toBe('priority') + expect(nextLaneBy(options, 'tag')).toBe('none') + // A field the board no longer offers starts the cycle over. + expect(nextLaneBy(options, 'field:gone')).toBe('none') + }) +}) + +describe(':lanes (#1027)', () => { + it('reads a lane field the way people type it', () => { + expect(parseLanesArg('')).toBe('none') + expect(parseLanesArg('off')).toBe('none') + expect(parseLanesArg('Priority')).toBe('priority') + expect(parseLanesArg('tags')).toBe('tag') + expect(parseLanesArg('status')).toBe('field:status') + expect(parseLanesArg('@sprint')).toBe('field:sprint') + expect(parseLanesArg('field:area')).toBe('field:area') + expect(parseLanesArg('two words')).toBeNull() + }) +}) + +describe('the lane cursor (#1027)', () => { + // The focused column's cell in three lanes: 2 cards, an empty cell, 1 card. + const lanes: LaneShape[] = [ + { cards: 2, collapsed: false }, + { cards: 0, collapsed: false }, + { cards: 1, collapsed: false } + ] + const walk = (start: { laneIdx: number; cardIdx: number }, delta: -1 | 1, steps: number, shape = lanes) => { + const seen = [start] + let at = start + for (let i = 0; i < steps; i += 1) { + at = laneCursorStep(at, delta, shape) + seen.push(at) + } + return seen.map((c) => `${c.laneIdx}:${c.cardIdx}`) + } + + it('walks down from the column header through every lane header and cell', () => { + // -1:-1 is the column header, L:-1 a lane header, L:0 an empty cell. + expect(walk({ laneIdx: -1, cardIdx: -1 }, 1, 8)).toEqual([ + '-1:-1', + '0:-1', + '0:0', + '0:1', + '1:-1', + '1:0', + '2:-1', + '2:0', + '2:0' + ]) + }) + + it('walks back up the same stops to the column header, and stays there', () => { + expect(walk({ laneIdx: 2, cardIdx: 0 }, -1, 8)).toEqual([ + '2:0', + '2:-1', + '1:0', + '1:-1', + '0:1', + '0:0', + '0:-1', + '-1:-1', + '-1:-1' + ]) + }) + + it('passes over a collapsed lane on its header', () => { + const folded: LaneShape[] = [lanes[0], { cards: 4, collapsed: true }, lanes[2]] + expect(walk({ laneIdx: 0, cardIdx: 1 }, 1, 3, folded)).toEqual(['0:1', '1:-1', '2:-1', '2:0']) + expect(walk({ laneIdx: 2, cardIdx: -1 }, -1, 2, folded)).toEqual(['2:-1', '1:-1', '0:1']) + }) + + it('jumps lane header to lane header with { and }', () => { + expect(laneJump({ laneIdx: -1, cardIdx: -1 }, 1, 3)).toEqual({ laneIdx: 0, cardIdx: -1 }) + expect(laneJump({ laneIdx: 0, cardIdx: 1 }, 1, 3)).toEqual({ laneIdx: 1, cardIdx: -1 }) + expect(laneJump({ laneIdx: 2, cardIdx: -1 }, 1, 3)).toEqual({ laneIdx: 2, cardIdx: -1 }) + // From a card, { goes to its own lane's header first, as in the Tags view. + expect(laneJump({ laneIdx: 2, cardIdx: 0 }, -1, 3)).toEqual({ laneIdx: 2, cardIdx: -1 }) + expect(laneJump({ laneIdx: 2, cardIdx: -1 }, -1, 3)).toEqual({ laneIdx: 1, cardIdx: -1 }) + expect(laneJump({ laneIdx: 0, cardIdx: -1 }, -1, 3)).toEqual({ laneIdx: -1, cardIdx: -1 }) + }) + + it('clamps a cursor the board shrank under: a folded lane holds it on the header', () => { + expect(clampLaneCursor({ laneIdx: 5, cardIdx: 3 }, lanes)).toEqual({ laneIdx: 2, cardIdx: 0 }) + expect(clampLaneCursor({ laneIdx: 0, cardIdx: 1 }, [{ cards: 2, collapsed: true }])).toEqual({ + laneIdx: 0, + cardIdx: -1 + }) + expect(clampLaneCursor({ laneIdx: 1, cardIdx: 0 }, [])).toEqual({ laneIdx: -1, cardIdx: -1 }) + }) +}) + +describe('dropping a card on a lane (#1027)', () => { + it('sets the priority of a priority lane, like the Priority board', () => { + expect(laneDropMutationsFor('priority', 'high', task())).toEqual([{ kind: 'set-priority', priority: 'high' }]) + expect(laneDropMutationsFor('priority', 'none', task({ priority: 'low' }))).toEqual([ + { kind: 'set-priority', priority: null } + ]) + }) + + it('sets or clears the @field of a field lane', () => { + expect(laneDropMutationsFor('field:sprint', '24', task())).toEqual([ + { kind: 'set-field', key: 'sprint', value: '24' } + ]) + expect(laneDropMutationsFor('field:sprint', '__none__', task())).toEqual([ + { kind: 'set-field', key: 'sprint', value: null } + ]) + }) + + it('makes the lane tag the first tag, or strips them for No tag', () => { + expect(laneDropMutationsFor('tag', 'home', task({ tags: ['work'] }))).toEqual([{ kind: 'set-tag', tag: 'home' }]) + expect(laneDropMutationsFor('tag', '__none__', task({ tags: ['work'] }))).toEqual([{ kind: 'set-tag', tag: null }]) + }) + + it('refuses folder lanes, and a task note whose tags live in its frontmatter', () => { + expect(laneDropMutationsFor('folder', 'Projects', task())).toBeNull() + expect(laneDropMutationsFor('tag', 'home', task({ kind: 'file', tags: ['work'] }))).toBeNull() + }) + + it('changes only what the drop crossed: the column, the lane, or both', () => { + const t = task({ due: '2026-10-30' }) + const from = { columnId: 'today', laneId: 'none' } + // Same cell: a reorder, nothing to write. + expect(boardDropMutations('status', 'priority', from, from, t, TODAY)).toEqual([]) + // Another lane, same column: only the priority. + expect(boardDropMutations('status', 'priority', from, { columnId: 'today', laneId: 'high' }, t, TODAY)).toEqual([ + { kind: 'set-priority', priority: 'high' } + ]) + // Another column, same lane: only the column's own tokens. + const columnOnly = boardDropMutations('status', 'priority', from, { columnId: 'waiting', laneId: 'none' }, t, TODAY) + expect(columnOnly).toEqual([ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: true } + ]) + // Both, in one write. + expect(boardDropMutations('status', 'priority', from, { columnId: 'done', laneId: 'low' }, t, TODAY)).toEqual([ + { kind: 'set-checked', checked: true }, + { kind: 'set-priority', priority: 'low' } + ]) + }) + + it('refuses the whole drop when either half cannot land', () => { + const from = { columnId: 'today', laneId: 'inbox' } + // Folder lanes are read-only: another lane is refused, even with a column change. + expect(boardDropMutations('status', 'folder', from, { columnId: 'done', laneId: 'Projects' }, task(), TODAY)).toBeNull() + // Within the lane, the column still moves. + expect(boardDropMutations('status', 'folder', from, { columnId: 'done', laneId: 'inbox' }, task(), TODAY)).toEqual([ + { kind: 'set-checked', checked: true } + ]) + // A Folder board's columns stay read-only, but its lanes do not. + expect( + boardDropMutations('folder', 'priority', { columnId: 'a', laneId: 'none' }, { columnId: 'a', laneId: 'high' }, task(), TODAY) + ).toEqual([{ kind: 'set-priority', priority: 'high' }]) + expect( + boardDropMutations('folder', 'priority', { columnId: 'a', laneId: 'none' }, { columnId: 'b', laneId: 'none' }, task(), TODAY) + ).toBeNull() + }) + + it('reads a lane header drop as the lane alone, and a column header drop as the column alone', () => { + const from = { columnId: 'today', laneId: 'none' } + expect(boardDropMutations('status', 'priority', from, { columnId: null, laneId: 'med' }, task(), TODAY)).toEqual([ + { kind: 'set-priority', priority: 'med' } + ]) + expect(boardDropMutations('status', 'priority', from, { columnId: 'done', laneId: null }, task(), TODAY)).toEqual([ + { kind: 'set-checked', checked: true } + ]) + }) +}) diff --git a/packages/app-core/src/lib/kanban-lanes.ts b/packages/app-core/src/lib/kanban-lanes.ts new file mode 100644 index 000000000..3c514ccb6 --- /dev/null +++ b/packages/app-core/src/lib/kanban-lanes.ts @@ -0,0 +1,152 @@ +/** + * Swimlanes on the Kanban board (#1027): the pieces of the lane model that + * need nothing from the board itself. The board splits its columns into + * horizontal lanes by a second field (Priority, Folder, Tag, or any `@field`), + * each lane a row across the same columns; see `laneDefsFor` and + * `splitIntoLanes` in TasksKanban for how lanes are built. + * + * The keyboard cursor in lane mode is a `LaneCursor` over one column: the + * column's header on top (lane -1), then per lane its header (card -1) and its + * cell's cards, or the cell itself when it holds none, so an empty cell is a + * place to stand and drop. A collapsed lane is its header alone. + */ +import type { KanbanGroupBy, KanbanLaneBy } from '../store' + +export interface LaneOption { + value: KanbanLaneBy + label: string +} + +/** The few words a lane field goes by in the View menu and its toast. */ +export function laneFieldLabel(laneBy: KanbanLaneBy): string { + switch (laneBy) { + case 'none': + return 'None' + case 'priority': + return 'Priority' + case 'folder': + return 'Folder' + case 'tag': + return 'Tag' + default: + return laneBy === 'field:status' ? 'Custom status' : `@${laneBy.slice('field:'.length)}` + } +} + +/** + * The lane fields a board offers: None, Priority, Folder, Tag, Custom status + * when there are statuses to show, then one per `@field` the tasks use. The + * board's own column field is never one: lanes by the field the columns + * already split would put every card on the diagonal. A chosen field nobody + * uses at the moment stays listed, so the menu can show what is in force. + */ +export function laneOptions( + groupBy: KanbanGroupBy, + fieldKeys: Iterable<string>, + { customStatus, current }: { customStatus: boolean; current: KanbanLaneBy } +): LaneOption[] { + const values: KanbanLaneBy[] = ['none', 'priority', 'folder', 'tag'] + if (customStatus) values.push('field:status') + for (const key of [...new Set(fieldKeys)].sort()) { + if (key !== 'status') values.push(`field:${key}`) + } + if (!values.includes(current)) values.push(current) + return values + .filter((value) => value !== groupBy) + .map((value) => ({ value, label: laneFieldLabel(value) })) +} + +/** The lanes a board shows: none while its columns split by the lane field. + * The choice itself is kept, so the lanes come back with another group-by. */ +export function activeLaneBy(groupBy: KanbanGroupBy, laneBy: KanbanLaneBy): KanbanLaneBy { + return laneBy === groupBy ? 'none' : laneBy +} + +/** The next lane field for `S`, wrapping back to None. */ +export function nextLaneBy(options: readonly LaneOption[], current: KanbanLaneBy): KanbanLaneBy { + if (options.length === 0) return 'none' + const at = options.findIndex((option) => option.value === current) + if (at < 0) return options[0].value + return options[(at + 1) % options.length].value +} + +/** Where the keyboard cursor sits in lane mode: `laneIdx` -1 is the column's + * header; otherwise `cardIdx` -1 is the lane's header, and 0 or more a card + * of the lane's cell in the focused column (0 alone: the cell, when empty). */ +export interface LaneCursor { + laneIdx: number + cardIdx: number +} + +/** One lane as the cursor sees it: its cell in the focused column. */ +export interface LaneShape { + cards: number + collapsed: boolean +} + +const COLUMN_HEADER: LaneCursor = { laneIdx: -1, cardIdx: -1 } + +/** The last stop of a lane: its header when collapsed, else its last card + * (or its empty cell). */ +function lastStop(laneIdx: number, lane: LaneShape): LaneCursor { + return lane.collapsed ? { laneIdx, cardIdx: -1 } : { laneIdx, cardIdx: Math.max(1, lane.cards) - 1 } +} + +/** `j` / `k` and the arrows in lane mode: one stop down or up the focused + * column, through lane headers and cells, over a collapsed lane on its + * header. Stays put at either end. */ +export function laneCursorStep(cursor: LaneCursor, delta: -1 | 1, lanes: readonly LaneShape[]): LaneCursor { + const at = clampLaneCursor(cursor, lanes) + if (delta === 1) { + if (at.laneIdx < 0) return lanes.length > 0 ? { laneIdx: 0, cardIdx: -1 } : at + const lane = lanes[at.laneIdx] + if (!lane.collapsed && at.cardIdx < Math.max(1, lane.cards) - 1) { + return { laneIdx: at.laneIdx, cardIdx: at.cardIdx + 1 } + } + return at.laneIdx + 1 < lanes.length ? { laneIdx: at.laneIdx + 1, cardIdx: -1 } : at + } + if (at.laneIdx < 0) return at + if (at.cardIdx >= 0) return { laneIdx: at.laneIdx, cardIdx: at.cardIdx - 1 } + if (at.laneIdx === 0) return COLUMN_HEADER + return lastStop(at.laneIdx - 1, lanes[at.laneIdx - 1]) +} + +/** `}` / `{`: to the next or previous lane's header. From inside a lane, `{` + * first goes to that lane's own header, as it goes from a block to its note + * in the Tags view; above the first lane is the column header. */ +export function laneJump(cursor: LaneCursor, delta: -1 | 1, laneCount: number): LaneCursor { + if (laneCount === 0) return COLUMN_HEADER + const laneIdx = Math.min(cursor.laneIdx, laneCount - 1) + if (delta === 1) { + if (laneIdx < 0) return { laneIdx: 0, cardIdx: -1 } + return { laneIdx: Math.min(laneCount - 1, laneIdx + 1), cardIdx: -1 } + } + if (laneIdx < 0) return COLUMN_HEADER + if (cursor.cardIdx >= 0) return { laneIdx, cardIdx: -1 } + return laneIdx === 0 ? COLUMN_HEADER : { laneIdx: laneIdx - 1, cardIdx: -1 } +} + +const FIELD_KEY_RE = /^[a-z][a-z0-9_-]*$/ + +/** The lane field `:lanes <arg>` names: none (bare, `none`, `off`), priority, + * folder, tag, `status` for Custom status, or any field as `@key`, `field:key` + * or the bare key. Null when it names nothing a board can split by. */ +export function parseLanesArg(raw: string): KanbanLaneBy | null { + const arg = raw.trim().toLowerCase() + if (!arg || arg === 'none' || arg === 'off') return 'none' + if (arg === 'priority' || arg === 'folder' || arg === 'tag') return arg + if (arg === 'tags') return 'tag' + const key = arg.replace(/^@/, '').replace(/^field:/, '') + return FIELD_KEY_RE.test(key) ? `field:${key}` : null +} + +/** The cursor the board can draw after its lanes changed under it: a lane + * that went away clamps to the last one, a collapsed lane holds it on its + * header, a shorter cell on its last card. */ +export function clampLaneCursor(cursor: LaneCursor, lanes: readonly LaneShape[]): LaneCursor { + if (lanes.length === 0 || cursor.laneIdx < 0) return COLUMN_HEADER + const laneIdx = Math.min(cursor.laneIdx, lanes.length - 1) + const lane = lanes[laneIdx] + if (cursor.cardIdx < 0 || lane.collapsed) return { laneIdx, cardIdx: -1 } + return { laneIdx, cardIdx: Math.min(cursor.cardIdx, Math.max(0, lane.cards - 1)) } +} diff --git a/packages/app-core/src/lib/task-column-mutations.ts b/packages/app-core/src/lib/task-column-mutations.ts index 675030549..5a1e62184 100644 --- a/packages/app-core/src/lib/task-column-mutations.ts +++ b/packages/app-core/src/lib/task-column-mutations.ts @@ -1,4 +1,4 @@ -import type { KanbanGroupBy, TaskMutation } from '../store' +import type { KanbanGroupBy, KanbanLaneBy, TaskMutation } from '../store' import { toIsoDateLocal, type VaultTask } from '@shared/tasks' /** Map a (groupBy, columnId) drop target to the task-line mutations @@ -86,3 +86,68 @@ export function dropMutationsFor( // the sidebar. return null } + +/** Sentinel id of a lane for cards without the field: No tag, No <field>. + * The column's NO_VALUE_COLUMN_ID, which no tag or field value can be. */ +const NO_LANE_ID = '__none__' + +/** + * The task-line mutations a drop on a swimlane writes (#1027): the lane's own + * field and nothing else. Priority and `@field` lanes reuse the column drops, + * so a lane writes exactly what its column twin would. A tag lane makes its tag + * the card's first one (No tag strips them). Returns null when the lane cannot + * take the card: folder lanes are read-only like folder columns (moving a note + * is a move, not a drag), and a task note keeps its tags in a frontmatter list. + */ +export function laneDropMutationsFor( + laneBy: KanbanLaneBy, + laneId: string, + task: VaultTask +): TaskMutation[] | null { + // Only the Status board reads the date, and Status is never a lane field. + if (laneBy === 'priority') return dropMutationsFor('priority', laneId, task, new Date()) + if (laneBy === 'tag') { + if (task.kind === 'file') return null + return [{ kind: 'set-tag', tag: laneId === NO_LANE_ID ? null : laneId }] + } + if (laneBy.startsWith('field:')) return dropMutationsFor(laneBy as KanbanGroupBy, laneId, task, new Date()) + return null +} + +/** One place on the board: a column, a lane, or a cell of both. A lane + * header is a lane alone; a column header (or a board without lanes) is a + * column alone. */ +export interface BoardCell { + columnId: string | null + laneId: string | null +} + +/** + * What a card dropped from `from` onto `to` writes: the column's tokens when it + * crossed into another column, the lane's when it crossed into another lane, + * both in one write when it crossed both, and nothing for a reorder inside its + * cell. When either half cannot land the whole drop is refused, as the + * undated drop is (#990): a card that took only half a move would sit + * somewhere other than where it was dropped. + */ +export function boardDropMutations( + groupBy: KanbanGroupBy, + laneBy: KanbanLaneBy, + from: BoardCell, + to: BoardCell, + task: VaultTask, + today: Date +): TaskMutation[] | null { + const mutations: TaskMutation[] = [] + if (to.columnId !== null && to.columnId !== from.columnId) { + const column = dropMutationsFor(groupBy, to.columnId, task, today) + if (!column) return null + mutations.push(...column) + } + if (laneBy !== 'none' && to.laneId !== null && to.laneId !== from.laneId) { + const lane = laneDropMutationsFor(laneBy, to.laneId, task) + if (!lane) return null + mutations.push(...lane) + } + return mutations +} diff --git a/packages/app-core/src/lib/tasks-keys.test.ts b/packages/app-core/src/lib/tasks-keys.test.ts index d01084065..5fd6e5aa6 100644 --- a/packages/app-core/src/lib/tasks-keys.test.ts +++ b/packages/app-core/src/lib/tasks-keys.test.ts @@ -45,6 +45,25 @@ describe('the Tasks keys cheat sheet', () => { for (const key of keysOf(off)) expect(key).toMatch(/^(← \/ →|↑ \/ ↓|Enter|Space|Shift\+F10|Tab \/ Shift\+Tab|Esc)$/) }) + it('names the lane keys once the board has lanes (#1027)', () => { + const on = (laneMoves: boolean) => + tasksKeySheet('kanban', { vimMode: true, dndEnabled: true, listOrder: 'note', subtasks: true, lanes: true, laneMoves }) + expect(on(true).groups.map((g) => g.title)).toEqual(['Move', 'Cards', 'Subtasks', 'Lanes', 'Columns', 'Board']) + for (const key of ['{ / }', 'za', 'zM / zR', 'Shift+J / K', 'S']) expect(keysOf(on(true))).toContain(key) + // Folder lanes are read-only: no card move between them. + expect(keysOf(on(false))).not.toContain('Shift+J / K') + // Without lanes the group stays away, but S still turns them on. + const off = tasksKeySheet('kanban', { vimMode: true, dndEnabled: true, listOrder: 'note' }) + expect(off.groups.map((g) => g.title)).not.toContain('Lanes') + expect(keysOf(off)).toContain('S') + }) + + it('keeps the lane keys to the universal ones with Vim mode off (#1027)', () => { + const sheet = tasksKeySheet('kanban', { vimMode: false, dndEnabled: true, listOrder: 'note', lanes: true, laneMoves: true }) + expect(sheet.groups.map((g) => g.title)).toContain('Lanes') + for (const key of keysOf(sheet)) expect(key).toMatch(/^(← \/ →|↑ \/ ↓|Enter|Space|Enter \/ Space|Shift\+F10)$/) + }) + it('leaves card moves off a board that cannot move cards', () => { const sheet = tasksKeySheet('kanban', { vimMode: true, dndEnabled: false, listOrder: 'note' }) expect(keysOf(sheet)).not.toContain('Shift+H / L') diff --git a/packages/app-core/src/lib/tasks-keys.ts b/packages/app-core/src/lib/tasks-keys.ts index 77b347948..124a9d676 100644 --- a/packages/app-core/src/lib/tasks-keys.ts +++ b/packages/app-core/src/lib/tasks-keys.ts @@ -39,6 +39,10 @@ export interface TasksKeySheetOptions { * Reserved for the subtasks branch, which binds those keys; off until * then, so the sheet never names a key that does nothing. */ subtasks?: boolean + /** The board is split into swimlanes (#1027): the Lanes group shows. */ + lanes?: boolean + /** A card can move to another lane (every lane field but Folder). */ + laneMoves?: boolean } /** The board's Subtasks group, shown once `subtasks` is on. Tab and Esc are @@ -65,14 +69,38 @@ function viewRows(order: string | null): TasksKeyRow[] { ] } +/** The board's Lanes group (#1027), shown while the board has lanes. `{ }` + * hop lane to lane the way they hop note to note in the Tags view, and + * Shift+J/K move the card down or up a lane as Shift+H/L move it across. + * With Vim mode off the arrows walk the lane headers, and Enter or Space on + * one folds it. */ +function laneGroup({ vimMode, laneMoves }: TasksKeySheetOptions): TasksKeyGroup { + if (!vimMode) return { title: 'Lanes', rows: [{ keys: 'Enter / Space', label: 'toggle (header)' }] } + return { + title: 'Lanes', + rows: [ + { keys: '{ / }', label: 'lanes' }, + ...(laneMoves ? [{ keys: 'Shift+J / K', label: 'move card' }] : []), + { keys: 'za', label: 'toggle lane' }, + { keys: 'zM / zR', label: 'collapse, expand all' }, + { keys: 'Enter', label: 'toggle (header)' } + ] + } +} + function kanbanSheet(options: TasksKeySheetOptions): TasksKeySheet { const sheet = kanbanGroups(options) if (options.subtasks) sheet.groups.splice(2, 0, SUBTASK_GROUP) + if (options.lanes) { + const columns = sheet.groups.findIndex((group) => group.title === 'Columns') + sheet.groups.splice(columns, 0, laneGroup(options)) + } return sheet } -function kanbanGroups({ vimMode, dndEnabled }: TasksKeySheetOptions): TasksKeySheet { - const mouse = dndEnabled +function kanbanGroups({ vimMode, dndEnabled, lanes, laneMoves }: TasksKeySheetOptions): TasksKeySheet { + // A Folder board's cards still drag between priority, tag or field lanes. + const mouse = dndEnabled || (lanes && laneMoves) ? 'Drag cards and headers · right-click for menus' : 'Drag headers · right-click for menus' if (!vimMode) { @@ -133,7 +161,10 @@ function kanbanGroups({ vimMode, dndEnabled }: TasksKeySheetOptions): TasksKeySh { keys: 'Enter', label: 'rename (header)' } ] }, - { title: 'Board', rows: [{ keys: 'g', label: 'group by' }, ...viewRows('order')] } + { + title: 'Board', + rows: [{ keys: 'g', label: 'group by' }, { keys: 'S', label: 'lanes' }, ...viewRows('order')] + } ], mouse } diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 705d5a7cf..e40cec292 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -2716,6 +2716,74 @@ describe('applyTaskMutation write queue (#503)', () => { }) }) +describe('Kanban lanes (#1027)', () => { + it('starts with no lanes and keeps the lane field through a restart', async () => { + installZen() + const { useStore } = await loadStore() + expect(useStore.getState().kanbanLanes).toBe('none') + + useStore.getState().setKanbanLanes('priority') + expect(useStore.getState().kanbanLanes).toBe('priority') + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.kanbanLanes).toBe('priority') + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().kanbanLanes).toBe('priority') + }) + + it('reads a hand-edited lane field it can mean, and none for anything else', async () => { + const { normalizeKanbanLanes } = await loadStore() + expect(normalizeKanbanLanes('tag')).toBe('tag') + expect(normalizeKanbanLanes('field:sprint')).toBe('field:sprint') + expect(normalizeKanbanLanes('status')).toBe('none') + expect(normalizeKanbanLanes('field:Bad Key')).toBe('none') + expect(normalizeKanbanLanes(3)).toBe('none') + }) + + it('remembers collapsed lanes per board and lane field', async () => { + installZen() + const { useStore } = await loadStore() + useStore.getState().setKanbanCollapsedLanes('status', 'priority', ['low', 'none']) + useStore.getState().setKanbanCollapsedLanes('field:status', 'tag', ['home']) + expect(useStore.getState().kanbanCollapsedLanes).toEqual({ + 'status|priority': ['low', 'none'], + 'field:status|tag': ['home'] + }) + useStore.getState().setKanbanCollapsedLanes('field:status', 'tag', []) + expect(useStore.getState().kanbanCollapsedLanes).toEqual({ 'status|priority': ['low', 'none'] }) + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().kanbanCollapsedLanes).toEqual({ 'status|priority': ['low', 'none'] }) + }) + + it('drops collapsed-lane entries no board can build', async () => { + const { normalizeKanbanCollapsedLanes } = await loadStore() + expect( + normalizeKanbanCollapsedLanes({ + 'status|priority': ['low', 'low', '', 7], + 'priority|priority': ['high'], + 'bogus|tag': ['x'], + 'status|nope': ['x'], + 'folder|tag': 'home' + }) + ).toEqual({ 'status|priority': ['low'] }) + }) + + it('writes a tag lane move onto the task line, keeping its other tokens', async () => { + let disk = '- [ ] alpha #work due:2026-11-02 !high' + const readNote = vi.fn(async () => makeNote(disk)) + const writeNote = vi.fn(async (_path: string, body: string) => { + disk = body + }) + installZen({ readNote, writeNote }) + const { useStore } = await loadStore() + await useStore.getState().applyTaskMutation(makeTask('alpha', 0), { kind: 'set-tag', tag: 'home' }) + expect(disk).toBe('- [ ] alpha #home due:2026-11-02 !high') + }) +}) + describe('hidden workflow presets', () => { it('normalizes garbage and keeps unknown ids', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 543f4d24e..65ff09d8e 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -124,6 +124,9 @@ import { setTaskInProgressAtIndex, setTaskPriorityAtIndex, setTaskFieldAtIndex, + setTaskTagAtIndex, + setFirstTag, + withFirstTag, setTaskTextAtIndex, setTaskWaitingAtIndex, toggleTaskAtIndex, @@ -866,6 +869,16 @@ interface Prefs { * `${groupBy}:${columnId}` like the column titles. Portable as the * `[kanban_wip_limits]` table. (#1025) */ kanbanWipLimits: Record<string, number> + /** A second field that splits the Kanban board into horizontal lanes, or + * 'none'. Portable as `kanban_lanes` under [view], like the card order: a + * global display choice, not a per-vault override, which the Go server's + * vault.json mirror would have to learn. (#1027) */ + kanbanLanes: KanbanLaneBy + /** Collapsed Kanban lanes, keyed `${groupBy}|${laneBy}` to lane ids. + * Machine-local like the column and card arrangements: folding a lane is + * where you are looking today, not a setting worth a config.toml write on + * every `za`. (#1027) */ + kanbanCollapsedLanes: Record<string, string[]> /** Tasks list groups in their notes' line order or by priority. (#989) */ tasksListOrder: TasksListOrder /** How far ahead the Timeline panel reaches. (#1007) */ @@ -902,6 +915,10 @@ interface Prefs { export type TasksViewMode = 'list' | 'calendar' | 'kanban' export type KanbanGroupBy = 'status' | 'priority' | 'folder' | `field:${string}` export type KanbanCardSort = 'manual' | 'due' +/** What splits the Kanban board into horizontal lanes (#1027): a second field + * beside the columns' own, or 'none'. Status is not a lane field: its + * buckets come from dates and states, which is what columns are for. */ +export type KanbanLaneBy = 'none' | 'priority' | 'folder' | 'tag' | `field:${string}` /** `note` keeps each Tasks list group in its notes' line order (what J/K * rearrange); `priority` puts high before medium before low. (#989) */ export type TasksListOrder = 'note' | 'priority' @@ -916,6 +933,9 @@ export type TaskMutation = | { kind: 'set-priority'; priority: TaskLinePriority | null } | { kind: 'set-due'; due: string | null } | { kind: 'set-field'; key: string; value: string | null } + /** Make `tag` the line's first `#tag`, or strip them all with null: the + * Kanban tag lane a card sits in (#1027). */ + | { kind: 'set-tag'; tag: string | null } | { kind: 'set-text'; text: string } type AssetUndoEntry = { kind: 'delete-asset'; deleted: DeletedAsset; createdAt: number } @@ -948,6 +968,60 @@ export function normalizeKanbanGroupBy(raw: unknown): KanbanGroupBy { if (raw === 'custom') return 'field:status' return isKanbanGroupBy(raw) ? raw : 'status' } + +export function isKanbanLaneBy(value: unknown): value is KanbanLaneBy { + return ( + value === 'none' || + value === 'priority' || + value === 'folder' || + value === 'tag' || + (typeof value === 'string' && FIELD_GROUP_BY_RE.test(value)) + ) +} + +/** A persisted lane field, or 'none' for anything a board cannot split by. */ +export function normalizeKanbanLanes(raw: unknown): KanbanLaneBy { + return isKanbanLaneBy(raw) ? raw : 'none' +} + +const MAX_KANBAN_COLLAPSED_LANE_BOARDS = 64 +const MAX_KANBAN_COLLAPSED_LANES = 128 +const MAX_KANBAN_LANE_ID_LENGTH = 256 + +/** The collapsed-lanes key of one board split by one lane field. */ +export function kanbanCollapsedLanesKey(group: KanbanGroupBy, laneBy: KanbanLaneBy): string { + return `${group}|${laneBy}` +} + +// Collapsed lanes (#1027): `{ "<groupBy>|<laneBy>": ["<laneId>", ...] }`. Lane +// ids are tag names, field values and folder paths, so only their length is +// checked; a key whose two halves no board can build is dropped, as is a lane +// field equal to its board's own column field. +export function normalizeKanbanCollapsedLanes(raw: unknown): Record<string, string[]> { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + const out: Record<string, string[]> = {} + let boards = 0 + for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { + if (!Array.isArray(value)) continue + const bar = key.indexOf('|') + if (bar < 0) continue + const group = key.slice(0, bar) + const laneBy = key.slice(bar + 1) + if (!isKanbanGroupBy(group) || !isKanbanLaneBy(laneBy)) continue + if (laneBy === 'none' || laneBy === group) continue + const ids: string[] = [] + for (const entry of value) { + if (typeof entry !== 'string' || !entry || entry.length > MAX_KANBAN_LANE_ID_LENGTH) continue + if (!ids.includes(entry)) ids.push(entry) + if (ids.length >= MAX_KANBAN_COLLAPSED_LANES) break + } + if (ids.length === 0) continue + out[key] = ids + boards += 1 + if (boards >= MAX_KANBAN_COLLAPSED_LANE_BOARDS) break + } + return out +} const MAX_KANBAN_STATUSES = 24 const MAX_KANBAN_STATUS_ID_LENGTH = 32 const MAX_KANBAN_COLUMN_TITLE_LENGTH = 48 @@ -1346,6 +1420,8 @@ export const DEFAULT_PREFS: Prefs = { kanbanCardSort: 'manual', hideEmptyTasks: true, kanbanWipLimits: {}, + kanbanLanes: 'none', + kanbanCollapsedLanes: {}, tasksListOrder: 'note', timelineRange: DEFAULT_TIMELINE_RANGE, gallerySort: 'updated', @@ -1734,6 +1810,8 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { hideEmptyTasks: typeof p.hideEmptyTasks === 'boolean' ? p.hideEmptyTasks : DEFAULT_PREFS.hideEmptyTasks, kanbanWipLimits: normalizeKanbanWipLimits(p.kanbanWipLimits), + kanbanLanes: normalizeKanbanLanes(p.kanbanLanes), + kanbanCollapsedLanes: normalizeKanbanCollapsedLanes(p.kanbanCollapsedLanes), tasksListOrder: p.tasksListOrder === 'priority' ? 'priority' : 'note', timelineRange: normalizeTimelineRange(p.timelineRange), gallerySort: normalizeGallerySort(p.gallerySort), @@ -2344,6 +2422,11 @@ function applyTaskMutationsToTask(task: VaultTask, mutations: TaskMutation[]): V if (m.key === 'status') next = { ...next, status: value } break } + case 'set-tag': { + const tags = withFirstTag(next.tags ?? [], m.tag) + next = { ...next, tags, content: setFirstTag(next.content, m.tag).trim() } + break + } case 'set-text': { const content = m.text.trim() if (next.content !== content) next = { ...next, content } @@ -2383,6 +2466,10 @@ function fileTaskMutationUpdates( case 'set-field': updates[m.key] = m.value break + // A task note's tags are a frontmatter list this writer does not edit; + // the board refuses a tag-lane drop for one (laneDropMutationsFor). + case 'set-tag': + break case 'set-text': updates.title = m.text.trim() break @@ -2710,6 +2797,8 @@ function collectPrefs(s: { kanbanCardSort: KanbanCardSort hideEmptyTasks: boolean kanbanWipLimits: Record<string, number> + kanbanLanes: KanbanLaneBy + kanbanCollapsedLanes: Record<string, string[]> tasksListOrder: TasksListOrder timelineRange: TimelineRange gallerySort: GallerySort @@ -2833,6 +2922,8 @@ function collectPrefs(s: { kanbanCardSort: s.kanbanCardSort, hideEmptyTasks: s.hideEmptyTasks, kanbanWipLimits: s.kanbanWipLimits, + kanbanLanes: s.kanbanLanes, + kanbanCollapsedLanes: s.kanbanCollapsedLanes, tasksListOrder: s.tasksListOrder, timelineRange: s.timelineRange, gallerySort: s.gallerySort, @@ -3529,6 +3620,10 @@ interface Store { hideEmptyTasks: boolean /** Per-column Kanban WIP limits, keyed `${groupBy}:${columnId}`. (#1025) */ kanbanWipLimits: Record<string, number> + /** The field that splits the board into lanes, or 'none'. (#1027) */ + kanbanLanes: KanbanLaneBy + /** Collapsed lanes per board and lane field. (#1027) */ + kanbanCollapsedLanes: Record<string, string[]> /** Tasks list groups in note order or by priority. (#989) */ tasksListOrder: TasksListOrder /** How far ahead the Timeline panel reaches: two weeks, a month, or every @@ -3819,6 +3914,10 @@ interface Store { setHideEmptyTasks: (hide: boolean) => void /** Set one column's WIP limit, or clear it with null. (#1025) */ setKanbanWipLimit: (group: KanbanGroupBy, columnId: string, limit: number | null) => void + /** Split the board into lanes by a second field, or 'none'. (#1027) */ + setKanbanLanes: (laneBy: KanbanLaneBy) => void + /** The collapsed lanes of one board split by one field; empty clears. (#1027) */ + setKanbanCollapsedLanes: (group: KanbanGroupBy, laneBy: KanbanLaneBy, laneIds: string[]) => void setTasksListOrder: (order: TasksListOrder) => void setTimelineRange: (range: TimelineRange) => void setTimelineFilter: (query: string) => void @@ -6426,6 +6525,8 @@ export const useStore = create<Store>((set, get) => { kanbanCardSort: loadPrefs().kanbanCardSort, hideEmptyTasks: loadPrefs().hideEmptyTasks, kanbanWipLimits: loadPrefs().kanbanWipLimits, + kanbanLanes: loadPrefs().kanbanLanes, + kanbanCollapsedLanes: loadPrefs().kanbanCollapsedLanes, tasksListOrder: loadPrefs().tasksListOrder, timelineRange: loadPrefs().timelineRange, timelineFilter: '', @@ -7673,6 +7774,9 @@ export const useStore = create<Store>((set, get) => { case 'set-field': nextBody = setTaskFieldAtIndex(nextBody, task.taskIndex, m.key, m.value) break + case 'set-tag': + nextBody = setTaskTagAtIndex(nextBody, task.taskIndex, m.tag) + break case 'set-text': nextBody = setTaskTextAtIndex(nextBody, task.taskIndex, m.text) break @@ -7983,6 +8087,16 @@ export const useStore = create<Store>((set, get) => { set({ kanbanWipLimits: normalizeKanbanWipLimits(next) }) savePrefs(collectPrefs(get())) }, + setKanbanLanes: (laneBy) => { + set({ kanbanLanes: normalizeKanbanLanes(laneBy) }) + savePrefs(collectPrefs(get())) + }, + setKanbanCollapsedLanes: (group, laneBy, laneIds) => { + const next = { ...get().kanbanCollapsedLanes } + next[kanbanCollapsedLanesKey(group, laneBy)] = laneIds + set({ kanbanCollapsedLanes: normalizeKanbanCollapsedLanes(next) }) + savePrefs(collectPrefs(get())) + }, setTasksListOrder: (order) => { set({ tasksListOrder: order }) savePrefs(collectPrefs(get())) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 016a955c8..3b3b64f58 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -65,7 +65,9 @@ } } -.task-kanban-column.is-drop-target { +.task-kanban-column.is-drop-target, +.task-kanban-lane-cell.is-drop-target, +.task-kanban-lane-header.is-drop-target { border-color: rgb(var(--z-accent) / 0.62); box-shadow: 0 0 0 1px rgb(var(--z-accent) / 0.28); } diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index e67c4bfd1..e5f3b918e 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -107,6 +107,7 @@ export const PORTABLE_PREF_KEYS = [ 'kanbanCardSort', 'hideEmptyTasks', 'kanbanWipLimits', + 'kanbanLanes', 'tasksListOrder', 'timelineRange', 'gallerySort', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 45d4eab12..621ef4026 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -173,6 +173,7 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { kanbanCardSort: 'manual', hideEmptyTasks: true, kanbanWipLimits: {}, + kanbanLanes: 'none', tasksListOrder: 'note', timelineRange: 'two-weeks', gallerySort: 'updated', diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index 18b533796..30a307506 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -171,6 +171,10 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { id: "view.line-numbers.absolute", title: "Line Numbers: Absolute", category: "View" }, { id: "view.line-numbers.relative", title: "Line Numbers: Relative", category: "View" }, { id: "view.kanban-card-sort", title: "Kanban: Toggle Card Order", category: "View", keywords: "tasks board sort due date deadline order cards columns manual" }, + { id: "view.kanban-lanes.priority", title: "Kanban: Lanes by Priority", category: "View", keywords: "tasks board kanban lanes swimlanes rows split priority folder tag" }, + { id: "view.kanban-lanes.folder", title: "Kanban: Lanes by Folder", category: "View", keywords: "tasks board kanban lanes swimlanes rows split priority folder tag" }, + { id: "view.kanban-lanes.tag", title: "Kanban: Lanes by Tag", category: "View", keywords: "tasks board kanban lanes swimlanes rows split priority folder tag" }, + { id: "view.kanban-lanes.none", title: "Kanban: Lanes Off", category: "View", keywords: "tasks board kanban lanes swimlanes rows split priority folder tag" }, { id: "view.hide-empty-tasks", title: "Tasks: Toggle Empty Tasks", category: "View", keywords: "tasks empty blank placeholder template daily note kanban board list calendar hide show" }, { id: "view.tasks-list-order", title: "Tasks: Toggle List Order", category: "View", keywords: "tasks list sort priority high order note line" }, { id: "editor.vim.toggle", title: "Toggle Vim Mode", category: "Editor" }, From 5ce55fa94a5bd80a37877ea6460db1bc0183f986 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 13:37:47 -0500 Subject: [PATCH 46/53] Docs(whats-new): 2.80.0 brings swimlanes, subtasks on cards, WIP limits and a template folder Seven highlights, each a sentence or two, as Adib asked for UI copy today: swimlanes (#1027), subtasks folding into Kanban cards (#1028), WIP limits (#1025), the quieter Tasks views with the View menu and Keys (#1026), the template folder (#1024), MCP scopes with the bundled zn 0.13.0 (ZenNotes/tui#21), and Cloud setting aside a file it refuses (#1004). --- packages/app-core/src/lib/whats-new.ts | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 381b6de6a..1706bf414 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,39 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.80.0', + highlights: [ + { + title: 'Swimlanes on the board', + body: 'Split the Kanban board into rows by priority, folder, tag or any @field, from the View menu. Drag a card to another lane to change that field. S in Vim mode.' + }, + { + title: 'Subtasks fold into their card', + body: 'A task’s indented subtasks sit inside its Kanban card, with a progress bar. Check them off from the card; Tab steps onto them.' + }, + { + title: 'WIP limits', + body: 'Give a Kanban column a limit from its menu, or W in Vim mode. Past the limit, the column turns red.' + }, + { + title: 'Quieter Tasks views', + body: 'Empty - [ ] placeholders stay out of the list, calendar and board. Options live in one View menu, and Keys lists the shortcuts.' + }, + { + title: 'A template folder', + body: 'Keep templates as notes in a folder of your vault, the way Obsidian does: Settings, Templates. Their checkboxes stay out of Tasks.' + }, + { + title: 'Share part of a vault with an assistant', + body: 'Launch MCP with --allow-path, --deny-path or --read-only and it sees only what you share. The bundled zn is 0.13.0.' + }, + { + title: 'Cloud keeps syncing past a file it refuses', + body: 'One file Cloud won’t take stays on this device while the rest syncs, and Settings, Cloud says what to do with it.' + } + ] + }, { version: '2.79.0', highlights: [ From 0e8a921aa526694d9b73a5135caf01f45e1f5b1d Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 14:03:49 -0500 Subject: [PATCH 47/53] Fix(tasks): a line that only continues a task's text keeps its subtasks under it (#1028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Kanban card's `3/5` is meant to read what the note's chip reads, and it did not once a task's text wrapped onto a line at the margin. Markdown calls that line lazy continuation: `- [ ] Launch`, then `copy for the site` unindented, then ` - [x] Write copy` is still one item with a child, and the chip counts the child. The nesting walk closed the item at the unindented line, so each child became a card of its own and Launch showed no bar at all. The walk now keeps track of the item's open text (a paragraph) and lets a line that cannot start a block of its own continue it, closing nothing, the way Lezer's Markdown parser (the chip's) does. What may cut the text short is what Lezer lets interrupt a paragraph: a blank line, a heading, a fence, a `>` the text is not inside, a rule, an HTML block, a filled bullet (an empty one only inside a bulleted list) and a filled `1.` (any number only inside a numbered list). Quoted text continues lazily too, with or without its `>`. Following the same parser closes the neighbouring gaps a random comparison turned up: a setext underline (`===`) inside the item ends its text; a line four columns past its item is text while the text is open and code after a blank line, never a subtask; an empty item (`-` alone) takes its content from the column right after the marker, and it, like a lone `>` that starts a quote, opens text the next line can continue, while a lone `>` inside an open quote is a blank line. Not modelled: an HTML block swallowing the lines after it, and GFM tables. The task scan itself counts checkbox lines inside those, so the chip and the card already part there, as they do for a fence whose info string holds a backtick (the scan skips it as code, Markdown does not). Tests, red first: seven cases in the shared fixture bridge-contract/fixtures/task-subtasks.json, run by shared-domain's contract test and by the Go server's through sync-contract-fixtures, and run again by the chip-against-card parity test in kanban-subtasks.test.ts, which also gains four corpus lines (a lone tag, a table row, an empty `2.` in a numbered list, an item whose text is code). The lazy, empty-marker and lone-`>` cases failed in both tests before the change. A scratch comparison over 36,000 random bodies built from 41 line shapes found the card disagreeing with the chip on 4,043 of them with the walk as it shipped and on none after; 20,000 of them through the Go mirror (znserver feat/kanban-subtasks) give the TypeScript's parents exactly. Driven over CDP on a built app with both stores isolated: the note above shows `1/2` in the editor, and the board shows a single Launch card reading `1/2 · 50%`. How to test locally: build (`cd apps/desktop && npx electron-vite build`), launch, and write a note with `- [ ] Launch`, then `copy for the site` at the margin, then ` - [x] Write copy` and ` - [ ] Ship`. The note's chip reads 1/2. Open Tasks and switch to Kanban (3 in Vim mode). Before: three cards, Launch without a bar. After: one Launch card reading 1/2 · 50%. --- .../app-core/src/lib/kanban-subtasks.test.ts | 6 +- .../fixtures/task-subtasks.json | 114 +++++++++++ packages/shared-domain/src/task-nesting.ts | 185 ++++++++++++++++-- 3 files changed, 289 insertions(+), 16 deletions(-) diff --git a/packages/app-core/src/lib/kanban-subtasks.test.ts b/packages/app-core/src/lib/kanban-subtasks.test.ts index c2689abff..13c975e87 100644 --- a/packages/app-core/src/lib/kanban-subtasks.test.ts +++ b/packages/app-core/src/lib/kanban-subtasks.test.ts @@ -194,7 +194,11 @@ describe('the card counts what the note’s chip counts (#512, #1028)', () => { '- [ ]parent\n - [x] a', '- [x] parent\n - [ ] leftover', '- [ ] a\n - [ ] four-space child\n - [ ] eight-space grandchild', - '* [ ] star\n + [x] plus child\n- [ ] dash after star' + '* [ ] star\n + [x] plus child\n- [ ] dash after star', + '- [ ] parent\n<span>\n - [x] a tag alone is text here', + '- [ ] parent\n| a | b |\n - [x] a table row is text here', + '1. [ ] parent\n2.\n - [x] belongs to the empty 2.', + '- [ ] parent\n - code item\nnot text\n - [x] top level' ] it.each(corpus.map((body, i) => [i, body] as const))('corpus %i', (_i, body) => { diff --git a/packages/bridge-contract/fixtures/task-subtasks.json b/packages/bridge-contract/fixtures/task-subtasks.json index 5203e5fd5..6ea89928b 100644 --- a/packages/bridge-contract/fixtures/task-subtasks.json +++ b/packages/bridge-contract/fixtures/task-subtasks.json @@ -64,6 +64,120 @@ { "id": "inbox/Plan.md#2" } ] }, + { + "id": "a line at the margin that only continues the item's text keeps the item open", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\nwraps onto a second line\n - [x] child\n - [ ] another child\n- [ ] next\n - [ ] its child\nwraps under the child\n - [x] sibling of the child\n - [ ] grandchild\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#4", "parentId": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#5", "parentId": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#6", "parentId": "inbox/Plan.md#5" } + ] + }, + { + "id": "lines that cannot start a block continue the text: a number, an empty marker, a lone tag, an underline", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] a\n2) is still a's text\n - [ ] child of a\n- [ ] b\n1.\n - [ ] child of b\n- [ ] c\n#tag at the margin\n - [ ] child of c\n- [ ] d\n===\n - [ ] child of d\n- [ ] e\n -\nunder the empty marker\n - [ ] child of e\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3", "parentId": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#4" }, + { "id": "inbox/Plan.md#5", "parentId": "inbox/Plan.md#4" }, + { "id": "inbox/Plan.md#6" }, + { "id": "inbox/Plan.md#7", "parentId": "inbox/Plan.md#6" }, + { "id": "inbox/Plan.md#8" }, + { "id": "inbox/Plan.md#9", "parentId": "inbox/Plan.md#8" } + ] + }, + { + "id": "a blank line, a heading, a rule, a fence or HTML at the margin ends the item", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] a\n\nnot a's text\n - [ ] after a blank\n- [ ] b\n# Heading\n - [ ] after a heading\n- [ ] c\n***\n - [ ] after a rule\n- [ ] d\n~~~\nexample\n~~~\n - [ ] after a fence\n- [ ] e\n<!-- note -->\n - [ ] after a comment\n- [ ] f\n1. one\n - [ ] in the numbered list\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#4" }, + { "id": "inbox/Plan.md#5" }, + { "id": "inbox/Plan.md#6" }, + { "id": "inbox/Plan.md#7" }, + { "id": "inbox/Plan.md#8" }, + { "id": "inbox/Plan.md#9" }, + { "id": "inbox/Plan.md#10" }, + { "id": "inbox/Plan.md#11" } + ] + }, + { + "id": "an underline inside the item, or a heading or code as its first line, leaves no text to continue", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] a\n ===\nnot a's text\n - [ ] after the underline\n- [ ] b\n - # heading item\nnot b's text\n - [ ] after the heading item\n- [ ] c\n - code item\nnot c's text\n - [ ] after the code item\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#4" }, + { "id": "inbox/Plan.md#5" } + ] + }, + { + "id": "four columns past the item is its text, or code after a blank line, never a subtask", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n - [ ] deep, part of the text\n - [x] real\n- [ ] other\n\n - [ ] code, not a list\n - [ ] real too\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#2", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#3" }, + { "id": "inbox/Plan.md#4" }, + { "id": "inbox/Plan.md#5", "parentId": "inbox/Plan.md#3" } + ] + }, + { + "id": "a quoted item's text continues lazily, with or without its marker", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "> - [ ] parent\n> wraps in the quote\n> - [x] child\n> - [ ] second\nwraps without a marker\n> - [ ] its child\n> - [ ] another\nlazy under the child\n> - [x] sibling\n- [ ] outside\n > - [ ] quoted in the item\n> new quote, not lazy\n - [ ] not a child of outside\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3", "parentId": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#4", "parentId": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#5", "parentId": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#6" }, + { "id": "inbox/Plan.md#7" }, + { "id": "inbox/Plan.md#8" } + ] + }, + { + "id": "an empty marker's content starts right after it", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "- [ ] parent\n -\n - [ ] under the empty marker\n\n1.\n > - [ ] quoted inside the empty item\n> - [ ] a new quote\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3" } + ] + }, + { + "id": "a lone > inside a quote is a blank line, and one opening a quote starts text", + "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, + "body": "> - [ ] a\n>\n> - [ ] child after a quoted blank line\n\n>\n10. [ ] the new quote's text\n - [ ] more text, not a subtask\n", + "expectedTasks": [ + { "id": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#1", "parentId": "inbox/Plan.md#0" }, + { "id": "inbox/Plan.md#2" }, + { "id": "inbox/Plan.md#3" } + ] + }, { "id": "a fenced example inside the item neither counts nor ends it", "note": { "path": "inbox/Plan.md", "title": "Plan", "folder": "inbox" }, diff --git a/packages/shared-domain/src/task-nesting.ts b/packages/shared-domain/src/task-nesting.ts index 16faa42a5..1234b4040 100644 --- a/packages/shared-domain/src/task-nesting.ts +++ b/packages/shared-domain/src/task-nesting.ts @@ -11,9 +11,10 @@ * card can then show the same fraction the note does: * * - A list item's content starts after its marker and the spaces that follow - * (one to four; more than four, or none, means one). A later line belongs - * to the item only when it is indented at least that far, so a one-space - * "child" of `- ` is a sibling, and `10. ` needs four columns. + * (one to four; more than four means one; a marker that ends the line, an + * empty item, means none). A later line belongs to the item only when it is + * indented at least that far, so a one-space "child" of `- ` is a sibling, + * and `10. ` needs four columns. * - Blank lines never close an item (loose lists); the next non-blank line's * indent does. * - A non-task bullet in between owns the task under it: it is that bullet's @@ -25,10 +26,22 @@ * blank line closes them all. * - A fence is a block at its own indent; the lines inside it are skipped * by the caller, as the task scan already skips them. + * - Lazy continuation: while an item's text (a paragraph) is open, a line + * that cannot start a block of its own continues that text however little + * it is indented, so it closes nothing. `- [ ] parent`, then `wraps here` + * at the margin, then ` - [ ] child` keeps `child` a subtask, as the chip + * does. What may cut the text short is what Lezer's Markdown parser (the + * chip's) lets interrupt a paragraph: a blank line, a heading, a fence, a + * `>` the text is not inside, a rule, an HTML block, a filled bullet (an + * empty one only inside a bulleted list) and a filled `1.` (any number, + * filled or not, only inside a numbered list). A setext underline (`===`) + * inside the item ends its text too, and an empty item, or a lone `>` that + * starts a quote, opens text the next line can continue, as Lezer reads + * them (a lone `>` inside an open quote is a blank line). + * - A line indented four columns past the item it sits in is text while the + * item's text is open and code after a blank line: never a list item. * - * Lazy continuation (an unindented line continuing a list item's paragraph) - * is deliberately not modelled: such a line closes the item here, so a task - * below it reads as top level. Tabs advance to the next multiple of four. + * Tabs advance to the next multiple of four. * * Mirrored by `taskNesting` in the Go server (ZenNotes/znserver, * internal/vault/task_nesting.go). Both run the cases in @@ -42,12 +55,52 @@ interface OpenItem { contentCol: number /** The task this item is, or null for a plain bullet or a glued checkbox. */ taskId: string | null + /** A numbered item: what may interrupt text depends on the lists around. */ + ordered: boolean } const LIST_ITEM_RE = /^([ \t]*)([-+*]|\d+[.)])([ \t]+|$)/ const BLANK_RE = /^\s*$/ const NO_QUOTES: number[] = [] +// Block starts, read from a line's first character that is not a space or a +// tab: Lezer's isAtxHeading, isFencedCode, isBulletList, isOrderedList, +// isHorizontalRule, isSetextUnderline and isHTMLBlock, with [ \t] where its +// regexps say \s. The Go mirror spells the same patterns. +const HEADING_RE = /^#{1,6}(?: |$)/ +const FENCE_RE = /^(?:```|~~~)/ +const BULLET_RE = /^[-+*](?:[ \t]|$)/ +const FILLED_BULLET_RE = /^[-+*][ \t]+[^ \t]/ +const NUMBER_RE = /^\d{1,9}[.)](?:[ \t]|$)/ +const FILLED_ONE_RE = /^1[.)][ \t]+[^ \t]/ +const RULE_RE = /^(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/ +const SETEXT_UNDERLINE_RE = /^(?:=+|-+)[ \t]*$/ +/** HTML blocks that may interrupt a paragraph (CommonMark's types 1 to 6). */ +const HTML_INTERRUPTING_RES = [ + /^<(?:script|pre|style)(?:[ \t]|>|$)/i, + /^<!--/, + /^<\?/, + /^<![A-Z]/, + /^<!\[CDATA\[/, + /^<\/?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:[ \t]|\/?>|$)/i +] +/** The HTML block that starts only where no paragraph is open (type 7): a + * complete tag alone on its line. */ +const HTML_TAG_LINE_RE = + /^(?:<\/[a-z][\w-]*[ \t]*>|<[a-z][\w-]*(?:[ \t]+[a-z:_][\w.-]*(?:[ \t]*=[ \t]*(?:[^ \t"'=<>`]+|'[^']*'|"[^"]*"))?)*[ \t]*>)[ \t]*$/i + +/** Whether a line that opens no paragraph starts some other block that is + * not a list item: a heading, a fence, a rule or HTML. */ +function startsOtherBlock(text: string): boolean { + return ( + HEADING_RE.test(text) || + FENCE_RE.test(text) || + RULE_RE.test(text) || + HTML_INTERRUPTING_RES.some((re) => re.test(text)) || + HTML_TAG_LINE_RE.test(text) + ) +} + function advance(col: number, ch: string): number { return ch === '\t' ? col + 4 - (col % 4) : col + 1 } @@ -109,16 +162,99 @@ export interface TaskNesting { export function createTaskNesting(): TaskNesting { const open: OpenItem[] = [] + /** The quote depth of the text (paragraph) the lines so far leave open, or + * null when none is: after a blank line, a heading, a fence, a rule, code. */ + let paragraph: number | null = null + /** How many quotes the lines so far leave open. */ + let quotes = 0 const truncate = (at: number): void => { if (at >= 0 && at < open.length) open.length = at } + /** Whether each `>` sits inside the items of the level it opens from; one + * that does not starts a quote outside them. */ + const quotesStayInside = (markers: number[]): boolean => { + for (let d = 0; d < markers.length; d += 1) { + if (open.some((item) => item.depth === d && item.contentCol > markers[d])) return false + } + return true + } + + /** The items' content column a line at `col` and quote `depth` is still + * inside, deepest first (items are open outermost first), or 0. */ + const baseFor = (depth: number, col: number): number => { + let base = 0 + for (const item of open) { + if (item.depth !== depth) continue + if (item.contentCol > col) break + base = item.contentCol + } + return base + } + + /** Whether `text` may cut an open paragraph short (Lezer's endLeafBlock): + * a bullet only when filled or inside a bulleted list, a number only as a + * filled `1.` or inside a numbered list. */ + const interrupts = (text: string): boolean => { + if (HEADING_RE.test(text) || FENCE_RE.test(text) || RULE_RE.test(text)) return true + if (HTML_INTERRUPTING_RES.some((re) => re.test(text))) return true + if (BULLET_RE.test(text)) return FILLED_BULLET_RE.test(text) || open.some((i) => !i.ordered) + if (NUMBER_RE.test(text)) return FILLED_ONE_RE.test(text) || open.some((i) => i.ordered) + return false + } + + /** What a non-blank line does to the open paragraph: it goes on with the + * text (lazily, when it is indented less than the item holding the text), + * underlines it (which ends it), or ends it by starting something else. */ + const meetParagraph = ( + markers: number[], + col: number, + text: string + ): 'continues' | 'underlines' | 'ends' => { + if (paragraph === null) return 'ends' + const depth = markers.length + // A `>` past the text's own quotes, or one outside the items of its + // level, starts a quote, which interrupts. + if (depth > paragraph || !quotesStayInside(markers)) return 'ends' + if (col - baseFor(depth, col) >= 4) return 'continues' + if (interrupts(text)) return 'ends' + if (depth === paragraph && SETEXT_UNDERLINE_RE.test(text)) { + // Only a line inside the item holding the text underlines it; a lazy + // `===` is more text. + let holder: OpenItem | undefined + for (let i = open.length - 1; i >= 0 && !holder; i -= 1) { + if (open[i].depth === depth) holder = open[i] + } + if (!holder || col >= holder.contentCol) return 'underlines' + } + return 'continues' + } + return { visit(line, taskId) { const { markers, rest } = splitQuotes(line) const depth = markers.length + const blank = BLANK_RE.test(rest) + const item = blank ? null : rest.match(LIST_ITEM_RE) + const col = item ? columnsAfter(item[1]) : indentOf(rest) + const text = rest.replace(/^[ \t]+/, '') + + if (!blank) { + const met = meetParagraph(markers, col, text) + // More text closes nothing and opens nothing, even when it looks like + // a list item (`2) more`, a line indented deep under the item). + if (met === 'continues') return null + if (met === 'underlines') { + paragraph = null + return null + } + } + // A `>` past the quotes already open, or outside the items of its + // level, starts a quote of its own. + const startsQuote = depth > quotes || !quotesStayInside(markers) + quotes = depth // Quotes this line is not inside are over. while (open.length > 0 && open[open.length - 1].depth > depth) open.pop() // Each `>` closes the items of the level it opens from that it is not @@ -126,10 +262,13 @@ export function createTaskNesting(): TaskNesting { for (let d = 0; d < depth; d += 1) { truncate(open.findIndex((item) => item.depth === d && item.contentCol > markers[d])) } - if (BLANK_RE.test(rest)) return null + if (blank) { + // A lone `>` inside an open quote is a blank line in it. One that + // starts a quote opens its (empty) text, as an empty item does. + paragraph = startsQuote ? depth : null + return null + } - const item = rest.match(LIST_ITEM_RE) - const col = item ? columnsAfter(item[1]) : indentOf(rest) while ( open.length > 0 && open[open.length - 1].depth === depth && @@ -137,16 +276,32 @@ export function createTaskNesting(): TaskNesting { ) { open.pop() } - if (!item) return null - const top = open[open.length - 1] - const parent = top && top.depth === depth ? top.taskId : null + const inner = top && top.depth === depth ? top : undefined + // Four columns past the item it sits in, with no text open: code. + if (col - (inner ? inner.contentCol : 0) >= 4) { + paragraph = null + return null + } + if (!item) { + paragraph = startsOtherBlock(text) ? null : depth + return null + } + const afterMarker = col + item[2].length const afterSpaces = columnsAfter(item[3], afterMarker) const spaces = afterSpaces - afterMarker - const contentCol = spaces === 0 || spaces > 4 ? afterMarker + 1 : afterSpaces - open.push({ depth, contentCol, taskId }) - return taskId === null ? null : parent + // A marker ending the line (an empty item) takes content from the + // column right after it, as Lezer's getListIndent counts. + const contentCol = spaces > 4 ? afterMarker + 1 : afterSpaces + open.push({ depth, contentCol, taskId, ordered: /\d/.test(item[2]) }) + // The item's first line opens its text unless it starts another block + // there, or code (more than four spaces after the marker). An empty + // item opens text as well: Lezer lets the next line continue it. + const content = rest.slice(item[0].length) + const code = spaces > 4 && !BLANK_RE.test(content) + paragraph = code || startsOtherBlock(content) ? null : depth + return taskId === null ? null : inner ? inner.taskId : null } } } From eca34b233fd5b18501d568db5df44f8299816cc8 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 14:18:00 -0500 Subject: [PATCH 48/53] Fix(tasks): Esc puts back a task the calendar picked up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the Tasks calendar, `m` picks up the task under the cursor so h/j/k/l choose a day, Enter places it and Esc puts it back. Esc did nothing: the task stayed held and the toolbar kept saying Moving. The Tasks view and the calendar each listen for keys on the window in the capture phase, so the one that registered last runs second, and the calendar registers its handler again on every change it makes, a pick-up included. From then on the Tasks view saw each key first, took Esc to clear its filter and stopped it there. The same order let through keys the move is meant to hold: `E` showed or hid the empty tasks and `a` opened New task with a task still in hand. The board's subtask cursor met the same order in #1028 and was fixed for Esc alone. While a task is picked up, the calendar now marks its root (`data-tasks-calendar-moving`) and the Tasks view yields every key, so the calendar's own handler decides them all, whichever registered last. Outside a move nothing changes: Esc still clears the filter, and the calendar's other Esc uses (the inline edit, the quick-add box) sit in inputs the Tasks view already leaves alone. Tests, red first: TasksView-calendar-keys.test.ts renders the Tasks view on the calendar in jsdom with Vim on and presses keys at the page, so both window listeners run in their real order. `m` then Esc puts the task back, and `E` and `a` during a move reach neither the empty-tasks switch nor New task; both failed before the change. Esc still clears a filter when nothing is held. Driven over CDP on a built app with both stores isolated: before, `m` then Esc (three times) left "Moving “Pay rent”" in the toolbar, and `E` mid-move raised the "Empty tasks shown" toast; after, Esc brought back the toolbar's View and Keys, and `E` and `a` mid-move left no toast, no prompt and the task still held. How to test locally: build (`cd apps/desktop && npx electron-vite build`), launch with Vim mode on, give a task `due:` today, open Tasks and press 2 for the calendar. Press m: the toolbar says Moving. Press Esc. Before: it still says Moving. After: the task is back where it was and the toolbar shows View and Keys. --- .../app-core/src/components/TasksCalendar.tsx | 6 +- .../TasksView-calendar-keys.test.ts | 92 +++++++++++++++++++ .../app-core/src/components/TasksView.tsx | 6 ++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/app-core/src/components/TasksView-calendar-keys.test.ts diff --git a/packages/app-core/src/components/TasksCalendar.tsx b/packages/app-core/src/components/TasksCalendar.tsx index 06543b51f..ec96ebade 100644 --- a/packages/app-core/src/components/TasksCalendar.tsx +++ b/packages/app-core/src/components/TasksCalendar.tsx @@ -626,7 +626,11 @@ export function TasksCalendar({ }, [selectedIso, activeIdx]) return ( - <div ref={rootRef} className="flex min-h-0 flex-1 flex-col"> + <div + ref={rootRef} + data-tasks-calendar-moving={grabbedTask ? 'true' : undefined} + className="flex min-h-0 flex-1 flex-col" + > <div data-tasks-toolbar className="flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-1.5 whitespace-nowrap px-3 pt-3" diff --git a/packages/app-core/src/components/TasksView-calendar-keys.test.ts b/packages/app-core/src/components/TasksView-calendar-keys.test.ts new file mode 100644 index 000000000..cc0866df7 --- /dev/null +++ b/packages/app-core/src/components/TasksView-calendar-keys.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parseTasksFromBody, TASKS_TAB_PATH } from '@shared/tasks' +import { useStore } from '../store' +import { makeLeaf } from '../lib/pane-layout' +import { TasksView } from './TasksView' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function isoToday(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +/** A key as the app gets it: dispatched at the page, so the Tasks view's and + * the calendar's window capture listeners run in the order they registered. */ +function press(key: string): void { + act(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) +} + +describe('Tasks calendar keys while a task is picked up', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + const newTaskFile = vi.fn(async () => null) + const setHideEmptyTasks = vi.fn() + + beforeEach(() => { + originalState = useStore.getState() + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + const leaf = makeLeaf([TASKS_TAB_PATH], TASKS_TAB_PATH) + const tasks = parseTasksFromBody(`- [ ] Pay rent due:${isoToday()}\n- [ ] Call the bank due:${isoToday()}\n`, { + path: 'Calendar.md', + title: 'Calendar', + folder: 'inbox' + }) + useStore.setState({ + vimMode: true, + paneLayout: leaf, + activePaneId: leaf.id, + focusedPanel: 'tasks', + tasksViewMode: 'calendar', + vaultTasks: tasks, + tasksLoading: false, + tasksFilter: '', + refreshTasks: vi.fn(async () => {}), + newTaskFile, + setHideEmptyTasks + }) + newTaskFile.mockClear() + setHideEmptyTasks.mockClear() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + act(() => root.render(createElement(TasksView))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const moving = (): boolean => /Moving “Pay rent”/.test(host.textContent ?? '') + + it('puts the task back on Esc', () => { + press('m') + expect(moving()).toBe(true) + press('Escape') + expect(moving()).toBe(false) + }) + + it('keeps the keys it does not use from the Tasks view until the task is placed', () => { + press('m') + press('E') + press('a') + expect(setHideEmptyTasks).not.toHaveBeenCalled() + expect(newTaskFile).not.toHaveBeenCalled() + expect(moving()).toBe(true) + }) + + it('leaves Esc to clear the filter when nothing is picked up', () => { + act(() => useStore.setState({ tasksFilter: 'rent' })) + press('Escape') + expect(useStore.getState().tasksFilter).toBe('') + }) +}) diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 48c207346..78e0c0b2d 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -707,6 +707,12 @@ export function TasksView(): JSX.Element { // While the Vim hint overlay is open it owns the keyboard; don't let // task navigation (or Esc closing the view) steal its keys. (#151) if (document.querySelector('[data-vim-hint-overlay]')) return + // A task the calendar picked up (`m`) holds every key until Enter places + // it or Esc puts it back, and the calendar's handler takes them all. This + // one runs first whenever the calendar re-registered after it, which it + // does on every pick-up, so it yields: Esc used to clear the filter and + // leave the task held, and `a` opened New task mid-move. + if (rootRef.current?.querySelector('[data-tasks-calendar-moving]')) return // The Tasks tab can stay "active" while pane navigation (Ctrl+W h/j/k/l) // moves keyboard focus to another panel. Once focusedPanel is no longer // 'tasks', release the keys so the target panel (e.g. the sidebar) gets From 38a20451a3f9ad1b236718a78237f767ab644e04 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 14:18:00 -0500 Subject: [PATCH 49/53] Fix(templates): a template's tags stay out of the vault's tags, and local text search skips templates (#1024) The template folder (#1024) kept its notes out of Tasks and search and left their tags alone, as that change said. So every tag a template carries showed in the sidebar's tag list and the Tags view as the vault's own: a Meeting template tagged #meeting made the tag count two and listed the template among the meetings, and a tag only templates use (#agenda-only) showed with nothing but the template behind it. On a local vault the bundled MCP server's search_text and the desktop's `zn search` also still returned template notes, where the app, the server and zn skip them. The rule is the one zn follows (ZenNotes/tui#25): a note in the template folder contributes no tags, and a tag search does not return it. shared-domain's `outsideTemplateFolder` names it, and every tag surface applies it: the sidebar's list (now `countListedTags` in tags.ts), the Tags view, the counts behind `#` and frontmatter `tags:` completion and the New note form (`noteTagsForCount`, `countVaultTags`), the tag presence the public shell API reports, a database field's Options from tag (the tags the prompt suggests and the notes the field then offers), MCP list_tags, search_by_tag and list_notes with a tag, and `zn tag list`, `zn tag find` and `zn list --tag`. A `#tag` Search Notes query already left templates out unless it starts with the folder's path, and still does. The CLI and MCP backends gain `templateFolder()`, read off vault.json or a server vault's settings, so the rule holds on a local and a server vault alike. Text search on a local vault (MCP search_text, `zn search`) skips the folder inside its walk, before the limit, so the matches it returns are real notes, as the server's already were. Deliberately unchanged: a template's index entry keeps its tags, so renaming or deleting a tag still rewrites it inside templates and they stay in step (zn does the same); title search (MCP search_by_title, `zn search-title`, Search Notes by name) and plain listings still reach templates, which is how one is found to edit; a field sourced from the template folder by path still offers its notes; the workflow engine and its tag suggestions still see template tags. Desktop main and the Go server need nothing: neither counts tags (the renderer does, for every host), and the server's one text search already skips the folder, so znserver feat/template-folder is unchanged. UI copy: Settings, Templates, Template folder now reads "Notes in this folder become templates. They stay out of Tasks, Tags and search." The manual's settings table and its template-folder entry say the same; the website's docs mirror still owes that sentence. Tests, red first: `outsideTemplateFolder`; `noteTagsForCount`, `countVaultTags` and `countListedTags`, an open template's live buffer included; `#` completion offering no tag only a template carries; a tag source's options and suggested tags; MCP list_tags, search_by_tag, list_notes with and without a tag, search_text and search_by_title on a real local vault; the vault-ops text search with and without a folder and at limits 1 and 2; RemoteBackend reading the folder off a server's settings; and `zn search`, `zn tag list`, `zn tag find`, `zn list --tag` and `zn search-title`. A `#tag` Search Notes test passed before too: it pins the rule. Driven over CDP on a built app with both stores isolated (a root-mode vault, `Templates` as the folder, Templates/Meeting.md tagged #meeting #agenda-only #template-only, Notes.md tagged #meeting): before, the sidebar read #agenda-only 1, #meeting 2, #template-only 1; after, #meeting 1, the Tags view for #meeting listed Notes alone, and `#agen` in the editor offered nothing while `#mee` offered meeting. The built CLI bundle on the same vault: `zn search agenda` found nothing, `zn tag list`, `zn tag find meeting` and `zn list --tag meeting` left the template out, and `zn search-title meeting` found it. How to test locally: build (`cd apps/desktop && npx electron-vite build`) and launch. In a vault with a note tagged #meeting, write Templates/Meeting.md holding `#meeting #agenda-only`, then pick Templates under Settings, Templates, Template folder. Before: the sidebar's tags show #agenda-only 1 and #meeting 2. After: #meeting 1 alone, and Space t still lists Meeting in the template picker. --- apps/desktop/src/cli/backend.ts | 8 ++ apps/desktop/src/cli/commands/notes.ts | 4 +- apps/desktop/src/cli/commands/tags.ts | 8 +- .../src/cli/commands/template-folder.test.ts | 92 +++++++++++++++++++ apps/desktop/src/cli/remote/backend.test.ts | 12 ++- apps/desktop/src/cli/remote/client.ts | 4 +- apps/desktop/src/mcp/scoped-backend.ts | 1 + apps/desktop/src/mcp/server.test.ts | 57 ++++++++++++ apps/desktop/src/mcp/server.ts | 24 +++-- apps/desktop/src/mcp/vault-ops.test.ts | 48 ++++++++++ apps/desktop/src/mcp/vault-ops.ts | 22 ++++- .../src/components/DatabaseNoteSuggest.tsx | 3 +- .../src/components/DatabaseTableView.tsx | 6 +- .../src/components/SearchCreateForm.tsx | 6 +- .../app-core/src/components/SettingsModal.tsx | 4 +- packages/app-core/src/components/Sidebar.tsx | 20 ++-- packages/app-core/src/components/TagView.tsx | 11 ++- .../src/lib/cm-hashtag-complete.test.ts | 10 +- .../app-core/src/lib/cm-hashtag-complete.ts | 4 +- packages/app-core/src/lib/help.ts | 4 +- .../app-core/src/lib/link-candidates.test.ts | 38 +++++++- packages/app-core/src/lib/link-candidates.ts | 24 ++++- packages/app-core/src/lib/note-search.test.ts | 11 +++ packages/app-core/src/lib/tags.test.ts | 59 +++++++++++- packages/app-core/src/lib/tags.ts | 45 +++++++-- packages/app-core/src/shell.ts | 13 ++- .../shared-domain/src/template-folder.test.ts | 22 +++++ packages/shared-domain/src/template-folder.ts | 21 ++++- 28 files changed, 522 insertions(+), 59 deletions(-) create mode 100644 apps/desktop/src/cli/commands/template-folder.test.ts diff --git a/apps/desktop/src/cli/backend.ts b/apps/desktop/src/cli/backend.ts index 915f08fe3..28ab3a72f 100644 --- a/apps/desktop/src/cli/backend.ts +++ b/apps/desktop/src/cli/backend.ts @@ -42,6 +42,7 @@ import { readNote, readNoteComments, readPrimaryNotesLocation, + readTemplateFolder, readVaultFileTextOrNull, renameFolder, renameNote, @@ -74,6 +75,7 @@ import { } from '@shared/database-ops' import { createAbsenceAwareReader } from '@shared/remote-absence' import { normalizeSystemFolderPaths, type SystemFolderPaths } from '@shared/system-folder-paths' +import { resolveTemplateFolder } from '@shared/template-folder' import { setCell } from '@shared/database-records' import { csvPathForFormDir, @@ -126,6 +128,9 @@ export interface VaultBackend { * what classifying a vault-relative path needs, for a scoped MCP client * (ZenNotes/tui#21). */ vaultLayout(): Promise<VaultLayout> + /** The vault's template folder (#1024) as its settings name it, or null: + * the tag tools and commands leave its notes out, as the app does. */ + templateFolder(): Promise<string | null> listNotes(): Promise<NoteMeta[]> listAssets(): Promise<VaultAssetMeta[]> listFolders(): Promise<{ folder: NoteFolder; subpath: string }[]> @@ -244,6 +249,7 @@ class LocalBackend implements VaultBackend { systemFolderPaths: layout.systemFolderPaths ?? {} } } + templateFolder = (): Promise<string | null> => readTemplateFolder(this.root) listNotes = (): Promise<NoteMeta[]> => listNotes(this.root) listAssets = (): Promise<VaultAssetMeta[]> => listAssets(this.root) listFolders = (): Promise<{ folder: NoteFolder; subpath: string }[]> => listFolders(this.root) @@ -354,6 +360,8 @@ class RemoteBackend implements VaultBackend { systemFolderPaths: normalizeSystemFolderPaths(settings.systemFolderPaths) } } + templateFolder = async (): Promise<string | null> => + resolveTemplateFolder(await this.client.getVaultSettings()) listNotes = (): Promise<NoteMeta[]> => this.client.listNotes() listAssets = async (): Promise<VaultAssetMeta[]> => (await this.client.listAssets()).map((asset) => ({ diff --git a/apps/desktop/src/cli/commands/notes.ts b/apps/desktop/src/cli/commands/notes.ts index 7050186b5..6eb74fb22 100644 --- a/apps/desktop/src/cli/commands/notes.ts +++ b/apps/desktop/src/cli/commands/notes.ts @@ -4,6 +4,7 @@ * round-trip, works whether the desktop app is open or closed. */ +import { outsideTemplateFolder } from '@shared/template-folder' import type { NoteFolder, NoteMeta } from '../../mcp/vault-ops.js' import type { VaultBackend } from '../backend.js' import { @@ -37,7 +38,8 @@ export async function cmdList(vault: VaultBackend, args: ParsedArgs): Promise<vo ? notes.filter((n) => n.folder === folder) : notes.filter((n) => n.folder !== 'trash') if (tag) { - notes = notes.filter((n) => + // A tag finds no template (#1024): its tags are the note it will make. + notes = outsideTemplateFolder(notes, await vault.templateFolder()).filter((n) => n.tags.map((t) => t.toLowerCase()).includes(tag.replace(/^#/, '')) ) } diff --git a/apps/desktop/src/cli/commands/tags.ts b/apps/desktop/src/cli/commands/tags.ts index af1310d49..613cff55c 100644 --- a/apps/desktop/src/cli/commands/tags.ts +++ b/apps/desktop/src/cli/commands/tags.ts @@ -1,14 +1,16 @@ /** * `zn tag list` and `zn tag find` — enumerate and discover #tags - * across live notes (excluding trash). + * across live notes (excluding trash and the vault's template folder, whose + * tags are the notes its templates will make, #1024). */ +import { outsideTemplateFolder } from '@shared/template-folder' import type { VaultBackend } from '../backend.js' import { getBool, getNumber, getString, type ParsedArgs } from '../args.js' import { emitJson, emitLine, pad } from '../format.js' export async function cmdTagList(vault: VaultBackend, args: ParsedArgs): Promise<void> { - const all = await vault.listNotes() + const all = outsideTemplateFolder(await vault.listNotes(), await vault.templateFolder()) const counts = new Map<string, number>() for (const n of all) { if (n.folder === 'trash') continue @@ -38,7 +40,7 @@ export async function cmdTagFind(vault: VaultBackend, args: ParsedArgs): Promise const tag = (getString(args, 'tag') ?? args.positionals[0])?.replace(/^#/, '').toLowerCase() if (!tag) throw new Error('zn tag find requires a tag name (e.g. `zn tag find idea`).') const limit = getNumber(args, 'limit') ?? 200 - const all = await vault.listNotes() + const all = outsideTemplateFolder(await vault.listNotes(), await vault.templateFolder()) const matches = all .filter( (n) => n.folder !== 'trash' && n.tags.map((x) => x.toLowerCase()).includes(tag) diff --git a/apps/desktop/src/cli/commands/template-folder.test.ts b/apps/desktop/src/cli/commands/template-folder.test.ts new file mode 100644 index 000000000..484168833 --- /dev/null +++ b/apps/desktop/src/cli/commands/template-folder.test.ts @@ -0,0 +1,92 @@ +import { promises as fsp } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createBackend } from '../backend' +import type { ParsedArgs } from '../args' +import { cmdList } from './notes' +import { cmdSearch, cmdSearchTitle } from './search' +import { cmdTagFind, cmdTagList } from './tags' + +// #1024: a vault's template folder holds placeholders for the notes it makes. +// `zn search` and the tag commands leave its notes out, as the app, the MCP +// server and the Go server do; `zn search-title` and a plain `zn list` still +// reach them, which is how a template is found to edit. + +function makeArgs(flags: Array<[string, string]> = [], positionals: string[] = []): ParsedArgs { + const map = new Map<string, string[]>() + for (const [k, v] of flags) map.set(k, [...(map.get(k) ?? []), v]) + return { positionals, flags: map } +} + +let tmpDir: string +let root: string +let out: string[] + +beforeAll(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-template-cli-')) + root = path.join(tmpDir, 'vault') + await fsp.mkdir(path.join(root, 'Templates', 'Work'), { recursive: true }) + await fsp.mkdir(path.join(root, '.zennotes'), { recursive: true }) + await fsp.writeFile( + path.join(root, '.zennotes', 'vault.json'), + JSON.stringify({ primaryNotesLocation: 'root', templates: { folder: 'Templates' } }) + ) + await fsp.writeFile(path.join(root, 'Standup.md'), '# Standup\n\nagenda #meeting\n') + await fsp.writeFile( + path.join(root, 'Templates', 'Meeting.md'), + '# Meeting\n\nagenda #meeting #agenda-only\n' + ) + await fsp.writeFile(path.join(root, 'Templates', 'Work', 'Retro.md'), '# Retro\n\nagenda #retro\n') +}) + +afterAll(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }) +}) + +beforeEach(() => { + out = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out.push(String(chunk)) + return true + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const backend = (): ReturnType<typeof createBackend> => createBackend({ kind: 'local', root }) +const json = (): unknown => JSON.parse(out.join('')) +const paths = (): string[] => (json() as Array<{ path: string }>).map((row) => row.path).sort() + +describe('the template folder on the command line (#1024)', () => { + it('zn search finds no template, and spends --limit on real notes', async () => { + await cmdSearch(backend(), makeArgs([['json', 'true']], ['agenda'])) + expect(paths()).toEqual(['Standup.md']) + out = [] + await cmdSearch(backend(), makeArgs([['json', 'true'], ['limit', '1']], ['agenda'])) + expect(paths()).toEqual(['Standup.md']) + }) + + it('zn tag list counts no tag a template carries', async () => { + await cmdTagList(backend(), makeArgs([['json', 'true']])) + expect(json()).toEqual([{ tag: 'meeting', count: 1 }]) + }) + + it('zn tag find and zn list --tag return no template', async () => { + await cmdTagFind(backend(), makeArgs([['json', 'true']], ['meeting'])) + expect(paths()).toEqual(['Standup.md']) + out = [] + await cmdList(backend(), makeArgs([['json', 'true'], ['tag', 'meeting']])) + expect(paths()).toEqual(['Standup.md']) + }) + + it('zn list and zn search-title still reach the templates', async () => { + await cmdList(backend(), makeArgs([['json', 'true']])) + expect(paths()).toEqual(['Standup.md', 'Templates/Meeting.md', 'Templates/Work/Retro.md']) + out = [] + await cmdSearchTitle(backend(), makeArgs([['json', 'true']], ['retro'])) + expect(paths()).toEqual(['Templates/Work/Retro.md']) + }) +}) diff --git a/apps/desktop/src/cli/remote/backend.test.ts b/apps/desktop/src/cli/remote/backend.test.ts index 527422182..c054df3df 100644 --- a/apps/desktop/src/cli/remote/backend.test.ts +++ b/apps/desktop/src/cli/remote/backend.test.ts @@ -29,6 +29,8 @@ interface FakeVault { requests: { method: string; url: string; auth: string | null }[] /** Set to a status code to make the next request fail. */ failWith: number | null + /** What `/api/vault/settings` answers; a fixed root-mode vault when unset. */ + settings?: Record<string, unknown> } let server: Server @@ -192,7 +194,7 @@ beforeAll(async () => { case 'GET /api/vault': return send({ root: '/srv/notes', name: 'notes' }) case 'GET /api/vault/settings': - return send({ primaryNotesLocation: 'root', systemFolderPaths: null }) + return send(vault.settings ?? { primaryNotesLocation: 'root', systemFolderPaths: null }) case 'GET /api/assets': return send([ { path: 'assets/pic.png', name: 'pic.png', kind: 'image', siblingOrder: 0, size: 4, updatedAt: 9 } @@ -432,6 +434,14 @@ describe('RemoteBackend: what the MCP needs beyond the CLI (#688)', () => { expect(await remote(null).describe()).toMatchObject({ authConfigured: false }) }) + it('reads the template folder from the server vault settings (#1024)', async () => { + expect(await remote().templateFolder()).toBeNull() + vault.settings = { primaryNotesLocation: 'root', templates: { folder: ' Templates/ ' } } + expect(await remote().templateFolder()).toBe('Templates') + vault.settings = { primaryNotesLocation: 'root', templates: { folder: '.zennotes' } } + expect(await remote().templateFolder()).toBeNull() + }) + it('lists assets in the local shape', async () => { expect(await remote().listAssets()).toEqual([ { path: 'assets/pic.png', name: 'pic.png', size: 4, updatedAt: 9 } diff --git a/apps/desktop/src/cli/remote/client.ts b/apps/desktop/src/cli/remote/client.ts index 5f7006b02..1f79fc025 100644 --- a/apps/desktop/src/cli/remote/client.ts +++ b/apps/desktop/src/cli/remote/client.ts @@ -62,10 +62,12 @@ export class CliRemoteClient { return this.get<RemoteVaultInfo | null>('/api/vault') } - /** The layout facts database path composition needs (#556). */ + /** The layout facts database path composition needs (#556), and the + * template folder the tag tools leave out (#1024). */ getVaultSettings(): Promise<{ primaryNotesLocation?: string systemFolderPaths?: Record<string, string> | null + templates?: unknown }> { return this.get('/api/vault/settings') } diff --git a/apps/desktop/src/mcp/scoped-backend.ts b/apps/desktop/src/mcp/scoped-backend.ts index 27d193a16..fda37133b 100644 --- a/apps/desktop/src/mcp/scoped-backend.ts +++ b/apps/desktop/src/mcp/scoped-backend.ts @@ -413,6 +413,7 @@ export class ScopedBackend implements VaultBackend { describe = (): Promise<VaultDescription> => this.inner.describe() vaultLayout = (): Promise<VaultLayout> => this.inner.vaultLayout() + templateFolder = (): Promise<string | null> => this.inner.templateFolder() listNotes = async (): Promise<NoteMeta[]> => [...(await this.load()).notes] diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index 1946a374a..a0fb3a72f 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -178,6 +178,63 @@ describe('list_tasks', () => { }) }) +// #1024: a template folder's notes are placeholders. The tag tools and text +// search leave them out as the app's tag list and search do; title search and +// a plain listing still reach them, which is how a template is found to edit. +describe('the template folder in the tag and search tools (#1024)', () => { + let dir: string + let vault: VaultBackend + + beforeEach(async () => { + dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-templates-')) + await fsp.mkdir(path.join(dir, 'Templates', 'Work'), { recursive: true }) + await fsp.mkdir(path.join(dir, '.zennotes'), { recursive: true }) + await fsp.writeFile( + path.join(dir, '.zennotes', 'vault.json'), + JSON.stringify({ primaryNotesLocation: 'root', templates: { folder: 'Templates' } }) + ) + await fsp.writeFile(path.join(dir, 'Standup.md'), '# Standup\n\nagenda #meeting\n') + await fsp.writeFile( + path.join(dir, 'Templates', 'Meeting.md'), + '# Meeting\n\nagenda #meeting #agenda-only\n' + ) + await fsp.writeFile(path.join(dir, 'Templates', 'Work', 'Retro.md'), '# Retro\n\n#retro\n') + vault = createBackend({ kind: 'local', root: dir }) + }) + + afterEach(async () => { + await fsp.rm(dir, { recursive: true, force: true }) + }) + + const paths = (rows: unknown): string[] => + (rows as Array<{ path: string }>).map((row) => row.path).sort() + + it('list_tags counts no tag a template carries', async () => { + expect(await callTool('list_tags', {}, vault)).toEqual([{ tag: 'meeting', count: 1 }]) + }) + + it('search_by_tag and a tag-filtered list_notes return no template', async () => { + expect(paths(await callTool('search_by_tag', { tag: '#meeting' }, vault))).toEqual(['Standup.md']) + expect(paths(await callTool('search_by_tag', { tag: 'agenda-only' }, vault))).toEqual([]) + expect(paths(await callTool('list_notes', { tag: 'meeting' }, vault))).toEqual(['Standup.md']) + }) + + it('search_text returns no template', async () => { + expect(paths(await callTool('search_text', { query: 'agenda' }, vault))).toEqual(['Standup.md']) + }) + + it('a plain list_notes and search_by_title still reach the templates', async () => { + expect(paths(await callTool('list_notes', {}, vault))).toEqual([ + 'Standup.md', + 'Templates/Meeting.md', + 'Templates/Work/Retro.md' + ]) + expect(paths(await callTool('search_by_title', { query: 'meeting' }, vault))).toEqual([ + 'Templates/Meeting.md' + ]) + }) +}) + // The ZenNotes/tui#21 probe cases, through the real tools on a root-mode // vault, where the inbox is the vault root and the internal dir, the // attachments dir and the other buckets are all one subpath away. Mirrors diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index c3b28d3b6..499e7b5fa 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -39,6 +39,7 @@ import { mcpToolAnnotations, type McpScope } from '@shared/mcp-scope' +import { outsideTemplateFolder } from '@shared/template-folder' import { ScopedBackend, scopeBackend, scrubVaultRoot } from './scoped-backend.js' interface ToolContext { @@ -240,7 +241,11 @@ const TOOLS: ToolDef[] = [ description: 'POSIX subpath under the folder (e.g. "Work/Meetings"). Requires folder to be set.' }, - tag: { type: 'string', description: 'Only return notes that contain this #tag.' }, + tag: { + type: 'string', + description: + 'Only return notes that contain this #tag. Notes in the vault’s template folder are left out.' + }, wikilinkTo: { type: 'string', description: 'Only return notes that link to a note with this title.' @@ -270,7 +275,12 @@ const TOOLS: ToolDef[] = [ const prefix = `${folder ?? ''}/${sub.replace(/^\/+|\/+$/g, '')}/` notes = notes.filter((n) => n.path.startsWith(prefix)) } - if (tag) notes = notes.filter((n) => n.tags.map((t) => t.toLowerCase()).includes(tag)) + if (tag) { + // A tag finds no template (#1024): its tags are the note it will make. + notes = outsideTemplateFolder(notes, await backend.templateFolder()).filter((n) => + n.tags.map((t) => t.toLowerCase()).includes(tag) + ) + } if (wikilinkTo) { notes = notes.filter((n) => n.wikilinks.some((w) => w.toLowerCase() === wikilinkTo) @@ -598,7 +608,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'search_text', description: - 'Full-text search across live notes (inbox/quick/archive). Returns line-level matches with path, line number, and preview. Cheap first step when the user says "find" or "where did I write".', + 'Full-text search across live notes (inbox/quick/archive), leaving out the vault’s template folder. Returns line-level matches with path, line number, and preview. Cheap first step when the user says "find" or "where did I write".', inputSchema: { type: 'object', properties: { @@ -642,7 +652,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'search_by_tag', description: - 'Find notes carrying a specific inline #tag. Tags are case-insensitive; pass the name with or without the leading #.', + 'Find notes carrying a specific inline #tag. Tags are case-insensitive; pass the name with or without the leading #. Notes in the vault’s template folder are left out.', inputSchema: { type: 'object', properties: { @@ -655,7 +665,7 @@ const TOOLS: ToolDef[] = [ handler: async (args, backend) => { const raw = requireString(args, 'tag').replace(/^#/, '').toLowerCase() const limit = optionalNumber(args, 'limit') ?? 200 - const all = await backend.listNotes() + const all = outsideTemplateFolder(await backend.listNotes(), await backend.templateFolder()) return all .filter( (n) => n.folder !== 'trash' && n.tags.map((t) => t.toLowerCase()).includes(raw) @@ -668,11 +678,11 @@ const TOOLS: ToolDef[] = [ schema: { name: 'list_tags', description: - 'Enumerate every #tag used in the vault (excluding trash) with the count of notes carrying it.', + 'Enumerate every #tag used in the vault (excluding trash and the template folder) with the count of notes carrying it.', inputSchema: { type: 'object', properties: {} } }, handler: async (_args, backend) => { - const all = await backend.listNotes() + const all = outsideTemplateFolder(await backend.listNotes(), await backend.templateFolder()) const counts = new Map<string, number>() for (const n of all) { if (n.folder === 'trash') continue diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index 9865db9c4..8397925d7 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -8,6 +8,7 @@ import { insertAtLineInBody, listNotes, readPrimaryNotesLocation, + readTemplateFolder, renameNote, replaceInBody, scanAllTasks, @@ -423,3 +424,50 @@ describe('mcp tasks leave the template folder out (#1024)', () => { ) }) }) + +// #1024: text search on a local vault (MCP search_text, `zn search`) leaves +// template notes out as the app's Search Text in Vault and the server do, and +// before the limit, so the matches it returns are real notes. +describe('mcp text search leaves the template folder out (#1024)', () => { + async function seed(settings: Record<string, unknown> | null): Promise<void> { + for (const dir of ['Templates', 'Templates/Work', 'Templates Old']) { + await mkdir(path.join(root, dir), { recursive: true }) + } + await writeFile(path.join(root, 'Templates', 'Meeting.md'), '# Meeting\n\nagenda for {{title}}\n') + await writeFile(path.join(root, 'Templates', 'Work', 'Retro.md'), '# Retro\n\nagenda: what went well\n') + await writeFile(path.join(root, 'Templates Old', 'Kept.md'), '# Kept\n\nan old agenda\n') + await writeFile(path.join(root, 'Standup.md'), '# Standup\n\ntoday’s agenda\n') + await rm(path.join(root, '.zennotes'), { recursive: true, force: true }) + if (settings) { + await mkdir(path.join(root, '.zennotes'), { recursive: true }) + await writeFile(path.join(root, '.zennotes', 'vault.json'), JSON.stringify(settings)) + } + } + const paths = (matches: { path: string }[]): string[] => matches.map((m) => m.path).sort() + + it('finds no template, subfolders included', async () => { + await seed({ templates: { folder: 'Templates' } }) + expect(paths(await searchText(root, 'agenda'))).toEqual(['Standup.md', 'Templates Old/Kept.md']) + expect(await readTemplateFolder(root)).toBe('Templates') + }) + + it('spends the limit on real notes', async () => { + await seed({ templates: { folder: 'Templates' } }) + for (const limit of [1, 2]) { + const found = paths(await searchText(root, 'agenda', limit)) + expect(found).toHaveLength(limit) + expect(found.every((p) => !p.startsWith('Templates/'))).toBe(true) + } + }) + + it('finds them all when the vault has no template folder', async () => { + await seed(null) + expect(await readTemplateFolder(root)).toBeNull() + expect(paths(await searchText(root, 'agenda'))).toEqual([ + 'Standup.md', + 'Templates Old/Kept.md', + 'Templates/Meeting.md', + 'Templates/Work/Retro.md' + ]) + }) +}) diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index fa38a53c7..ed2a456d6 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -31,7 +31,9 @@ import { tasksFolderListsForSettings } from '@shared/tasks-excluded-folders' import { + isPathInTemplateFolder, normalizeTemplateFolderSettings, + resolveTemplateFolder, rewriteTemplateFolderForRename } from '@shared/template-folder' import { @@ -1471,6 +1473,9 @@ export async function searchText( if (!trimmed) return [] const needle = trimmed.toLowerCase() const hiddenRootNames = hiddenRootNamesWith(await readSystemFolderPaths(root)) + // Template notes (#1024) are placeholders nobody is searching for: skipped + // in the walk, before the limit, as the app and the server skip them. + const templateFolder = await readTemplateFolder(root) const out: VaultTextSearchMatch[] = [] const walk = async ( folder: NoteFolder, @@ -1493,17 +1498,19 @@ export async function searchText( if (isPrimaryRoot && dirAbs === topAbs && hiddenRootNames.has(entry.name)) { continue } + if (isPathInTemplateFolder(toPosix(path.relative(root, full)), templateFolder)) continue await walk(folder, full, topAbs, isPrimaryRoot) continue } if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue + const rel = toPosix(path.relative(root, full)) + if (isPathInTemplateFolder(rel, templateFolder)) continue let body = '' try { body = await fs.readFile(full, 'utf8') } catch { continue } - const rel = toPosix(path.relative(root, full)) const title = path.basename(full, path.extname(full)) const lines = body.split('\n') for (let i = 0; i < lines.length && out.length < limit; i++) { @@ -1893,6 +1900,19 @@ async function readTypstPreambleFolder(root: string): Promise<string> { return resolveTypstPreambleFolder((preambles as { folder?: unknown }).folder) } +/** The vault's template folder (#1024), read straight off vault.json like the + * readers around it, or null when it has none. Its notes are placeholders: + * text search and the tag tools leave them out, as the app does. */ +export async function readTemplateFolder(root: string): Promise<string | null> { + const settingsPath = path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE) + try { + const raw: unknown = JSON.parse(await fs.readFile(settingsPath, 'utf8')) + return resolveTemplateFolder(isRecord(raw) ? raw : null) + } catch { + return null + } +} + /** The vault's Tasks folder lists, `tasks.excludedFolders` (#458) and the * Task folders in `tasks.includedFolders` (#962), with the template folder * (#1024) left out as well, read straight off vault.json like diff --git a/packages/app-core/src/components/DatabaseNoteSuggest.tsx b/packages/app-core/src/components/DatabaseNoteSuggest.tsx index cf5f0a512..80ab47d72 100644 --- a/packages/app-core/src/components/DatabaseNoteSuggest.tsx +++ b/packages/app-core/src/components/DatabaseNoteSuggest.tsx @@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef } from 'react' import type { VaultSettings } from '@shared/ipc' import type { SelectOptionsSource } from '@shared/databases' +import { resolveTemplateFolder } from '@shared/template-folder' import { useStore } from '../store' import { linkCandidates, @@ -29,7 +30,7 @@ export function useNoteSuggestions( return useMemo(() => { if (!opts.enabled) return [] return linkCandidates(query, { - notes: notesMatchingSource(notes, opts.source), + notes: notesMatchingSource(notes, opts.source, resolveTemplateFolder(vaultSettings)), vaultSettings: vaultSettings as VaultSettings | null, activePath, limit: opts.limit ?? 8 diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index e1b887048..dd87d53e0 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -10,7 +10,9 @@ import { import { createPortal } from 'react-dom' import type { DatabaseDoc, DbField, DbRow, DbView, FieldType, SelectOptionsSource } from '@shared/databases' import { viewRows } from '@shared/database-transforms' +import { resolveTemplateFolder } from '@shared/template-folder' import { useStore } from '../store' +import { tagSourceSuggestions } from '../lib/link-candidates' import { addRowInView, setCell, @@ -106,8 +108,8 @@ export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns ) return [...dirs].sort().map((value) => ({ value })) } - const tags = new Set(notes.flatMap((n) => n.tags.map((t) => t.toLowerCase()))) - return [...tags].sort().map((value) => ({ value })) + const templateFolder = resolveTemplateFolder(useStore.getState().vaultSettings) + return tagSourceSuggestions(notes, templateFolder).map((value) => ({ value })) }, [sourcePrompt]) const [selected, setSelected] = useState<Set<string>>(new Set()) // Edit picks: every row shows, the picked ones selected, and the selection diff --git a/packages/app-core/src/components/SearchCreateForm.tsx b/packages/app-core/src/components/SearchCreateForm.tsx index 78b7ca0c6..54a4eb410 100644 --- a/packages/app-core/src/components/SearchCreateForm.tsx +++ b/packages/app-core/src/components/SearchCreateForm.tsx @@ -8,6 +8,7 @@ import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPrimaryNotesAtRoot, noteFolderSubpath } from '../lib/vault-layout' import { resolveTypstPreambleFolder } from '../lib/typst-preamble' import { countVaultTags } from '../lib/tags' +import { resolveTemplateFolder } from '@shared/template-folder' import { addTag, buildDestinationChoices, @@ -105,9 +106,10 @@ export function SearchCreateForm({ countVaultTags( notes, activeNote ? { path: activeNote.path, body: activeNote.body } : null, - resolveTypstPreambleFolder(vaultSettings?.typstPreambles?.folder) + resolveTypstPreambleFolder(vaultSettings?.typstPreambles?.folder), + resolveTemplateFolder(vaultSettings) ), - [notes, activeNote, vaultSettings?.typstPreambles?.folder] + [notes, activeNote, vaultSettings] ) const nameCheck = checkNoteName(name) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index dda7f091a..e52a91968 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -8185,8 +8185,8 @@ function TemplateFolderRow({ settingId }: { settingId?: string }): JSX.Element { <div className="min-w-0 flex-1 basis-56"> <div className="text-sm font-medium text-ink-900">Template folder</div> <div className="mt-1 text-xs leading-5 text-ink-500"> - Notes in this folder become templates. They stay out of Tasks and - search. + Notes in this folder become templates. They stay out of Tasks, Tags + and search. </div> {current && ( <div className="mt-1 text-xs leading-5 text-ink-500" data-template-folder-count={count}> diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 85beb4ded..bf8c1d71b 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -26,7 +26,7 @@ import { import { Button } from "./ui/Button"; import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from "../lib/move-note"; import { assetMoveTargetDir, buildMoveAssetPrompt } from "../lib/move-asset"; -import { buildTagTree, extractTags, flattenTagTree } from "../lib/tags"; +import { buildTagTree, countListedTags, extractTags, flattenTagTree } from "../lib/tags"; import { isTypstPreamblePath, resolveTypstPreambleFolder } from "../lib/typst-preamble"; import { focusEditorNormalMode } from "../lib/editor-focus"; import type { AssetMeta, FolderColorId, FolderEntry, FolderIconId, NoteFolder, NoteMeta } from "@shared/ipc"; @@ -1655,24 +1655,20 @@ export function Sidebar(): JSX.Element { // Aggregate hashtags across non-trash notes. The active note's live // body is parsed only while it has unsaved edits; clean notes use the // indexed tags from note metadata and never reparse large bodies on open. + // Notes in the template folder count for nothing (#1024). + const templateFolder = useMemo( + () => resolveTemplateFolder(vaultSettings), + [vaultSettings], + ); const tags = useMemo(() => { const startedAt = performance.now(); - const counter = new Map<string, number>(); - const liveActivePath = activeBodyTagSnapshot?.path ?? null; - const liveActiveTags = activeBodyTagSnapshot?.tags ?? null; - for (const n of notes) { - if (n.folder === "trash") continue; - const list = - liveActivePath === n.path && liveActiveTags ? liveActiveTags : n.tags; - for (const t of list) counter.set(t, (counter.get(t) ?? 0) + 1); - } - const next = [...counter.entries()].sort((a, b) => a[0].localeCompare(b[0])); + const next = countListedTags(notes, activeBodyTagSnapshot, templateFolder); recordRendererPerf("sidebar.tags", performance.now() - startedAt, { notes: notes.length, tags: next.length, }); return next; - }, [activeBodyTagSnapshot, notes]); + }, [activeBodyTagSnapshot, notes, templateFolder]); // Nested-tag tree (#439): group the flat `[tag, count]` list on `/` and flatten // back to the rows visible given the collapsed set. Falls back to nothing when diff --git a/packages/app-core/src/components/TagView.tsx b/packages/app-core/src/components/TagView.tsx index 0578f84c0..979fd80e8 100644 --- a/packages/app-core/src/components/TagView.tsx +++ b/packages/app-core/src/components/TagView.tsx @@ -11,6 +11,7 @@ import { type TagViewRow } from '../lib/tag-view-rows' import { resolveTypstPreambleFolder } from '../lib/typst-preamble' +import { resolveTemplateFolder } from '@shared/template-folder' import { TagIcon, CloseIcon, DocumentIcon } from './icons' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { TaggedBlockText } from './TaggedBlockText' @@ -64,6 +65,8 @@ export function TagView(): JSX.Element { const preambleFolder = useStore((s) => resolveTypstPreambleFolder(s.vaultSettings?.typstPreambles?.folder) ) + // Template notes give the Tags view nothing (#1024), as they give search. + const templateFolder = useStore((s) => resolveTemplateFolder(s.vaultSettings)) const selectedTags = useStore((s) => s.selectedTags) const tagMatchMode = useStore((s) => s.tagMatchMode) const setTagMatchMode = useStore((s) => s.setTagMatchMode) @@ -112,12 +115,12 @@ export function TagView(): JSX.Element { const counter = new Map<string, number>() for (const note of notes) { if (note.folder === 'trash') continue - for (const t of noteTagsForCount(note, activeNote, preambleFolder)) { + for (const t of noteTagsForCount(note, activeNote, preambleFolder, templateFolder)) { counter.set(t, (counter.get(t) ?? 0) + 1) } } return [...counter.entries()].sort((a, b) => a[0].localeCompare(b[0])) - }, [notes, activeNote, preambleFolder]) + }, [notes, activeNote, preambleFolder, templateFolder]) // Nested-tag tree (#439). Shares the collapsed-node set with the sidebar so // expand/collapse stays in sync across both surfaces. @@ -137,11 +140,11 @@ export function TagView(): JSX.Element { return notes .filter((n) => { if (n.folder === 'trash') return false - const tags = noteTagsForCount(n, activeNote, preambleFolder) + const tags = noteTagsForCount(n, activeNote, preambleFolder, templateFolder) return matchesSelectedTags(tags, selectedTags, tagMatchMode) }) .sort((a, b) => b.updatedAt - a.updatedAt) - }, [notes, activeNote, selectedTags, tagMatchMode, preambleFolder]) + }, [notes, activeNote, selectedTags, tagMatchMode, preambleFolder, templateFolder]) // Block-level results (#837): the blocks inside each matching note that // carry a selected tag, read on demand for the listed notes only. diff --git a/packages/app-core/src/lib/cm-hashtag-complete.test.ts b/packages/app-core/src/lib/cm-hashtag-complete.test.ts index 9dad87b90..89f7e7a04 100644 --- a/packages/app-core/src/lib/cm-hashtag-complete.test.ts +++ b/packages/app-core/src/lib/cm-hashtag-complete.test.ts @@ -38,8 +38,13 @@ const storeState = vi.hoisted(() => ({ storeState.notes = [ meta('inbox/A.md', 'inbox', ['project', 'idea', 'work/deep']), meta('inbox/B.md', 'inbox', ['project', 'projectplan', 'todo']), - meta('trash/Old.md', 'trash', ['project', 'projecttrash']) + meta('trash/Old.md', 'trash', ['project', 'projecttrash']), + // A template's tags are the note it will make, not the vault's (#1024). + meta('inbox/Templates/Plan.md', 'inbox', ['project', 'projecttemplate']) ] +;(storeState as { vaultSettings?: unknown }).vaultSettings = { + templates: { folder: 'inbox/Templates' } +} vi.mock('../store', () => { const useStore = Object.assign(() => null, { getState: () => storeState }) @@ -54,7 +59,8 @@ function result(doc: string) { describe('hashtagSource (#410 — hashtag autocomplete)', () => { it('suggests previously used tags when typing #<query>', () => { const r = result('#pro') - // project (count 2) ranks above projectplan (count 1); trash is excluded. + // project (count 2) ranks above projectplan (count 1); trash and the + // template folder are excluded. expect(r?.options.map((o) => o.label)).toEqual(['project', 'projectplan']) }) diff --git a/packages/app-core/src/lib/cm-hashtag-complete.ts b/packages/app-core/src/lib/cm-hashtag-complete.ts index 401cbd389..329f4a6b7 100644 --- a/packages/app-core/src/lib/cm-hashtag-complete.ts +++ b/packages/app-core/src/lib/cm-hashtag-complete.ts @@ -15,6 +15,7 @@ import type { EditorView } from '@codemirror/view' import { useStore } from '../store' import { countVaultTags, rankTagCompletions } from './tags' import { resolveTypstPreambleFolder } from './typst-preamble' +import { resolveTemplateFolder } from '@shared/template-folder' import { isTagSkippedContext } from './cm-hashtags' import { isInsideFrontmatter } from './cm-frontmatter' @@ -51,7 +52,8 @@ export function collectTagCounts(): Map<string, number> { state.vaultSettings?.typstPreambles?.folder ) const active = activePath && activeBody != null ? { path: activePath, body: activeBody } : null - return countVaultTags(state.notes, active, preambleFolder) + // A tag only a template carries is not offered (#1024). + return countVaultTags(state.notes, active, preambleFolder, resolveTemplateFolder(state.vaultSettings)) } export function hashtagSource(context: CompletionContext): CompletionResult | null { diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 703a3a0db..05ceb65d0 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -119,7 +119,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Keep templates in a folder of your vault', body: - 'Pick a folder under Settings → Templates → Template folder, or choose “Use as Template Folder” from a folder’s menu in the sidebar (`m` in Vim mode). Every note in it, subfolders included, is a template in the picker, marked with its folder. The whole note is the template, named after its file, and its own frontmatter is copied into each new note. A frontmatter with only custom-template keys (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) is read as the template’s settings instead. A custom template with the same name shows too, listed first. The folder’s checkboxes stay out of Tasks and its notes out of search; start a Search Notes query with the folder’s path (`Templates/`) to find one. Saved in the vault; a self-hosted server keeps it from 2.80.' + 'Pick a folder under Settings → Templates → Template folder, or choose “Use as Template Folder” from a folder’s menu in the sidebar (`m` in Vim mode). Every note in it, subfolders included, is a template in the picker, marked with its folder. The whole note is the template, named after its file, and its own frontmatter is copied into each new note. A frontmatter with only custom-template keys (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) is read as the template’s settings instead. A custom template with the same name shows too, listed first. The folder’s checkboxes stay out of Tasks, its tags out of the tag list, the Tags view and `#` completion, and its notes out of search, a `#tag` search included; start a Search Notes query with the folder’s path (`Templates/`) to find one. Saved in the vault; a self-hosted server keeps it from 2.80.' }, { title: 'Draw diagrams with Excalidraw', @@ -1392,7 +1392,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { title: 'Templates', items: [ - { label: 'Template folder', detail: 'Notes in this folder are templates, kept out of Tasks and search. Saved in the vault.' }, + { label: 'Template folder', detail: 'Notes in this folder are templates, kept out of Tasks, Tags and search. Saved in the vault.' }, { label: 'Template library', detail: 'Browse every template, built-in and custom. Built-ins cover engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do).' }, { label: 'Create a custom template', detail: 'Author a new template as markdown with optional frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and variables like `{{title}}`, `{{date}}`, `{{date:FORMAT}}`, `{{time}}`, `{{week}}`, `{{cursor}}`, and the live `{{modified_date}}` / `{{modified_time}}` / `{{modified_datetime}}` (rendered from the note’s last-saved time rather than substituted). It is saved as a `.md` file in `.zennotes/templates/`.' }, { label: 'Edit or reset built-ins', detail: 'Press Edit on a built-in to fork an editable copy that shadows the original everywhere; Reset removes the copy and restores the built-in. Custom templates can be edited or deleted directly.' }, diff --git a/packages/app-core/src/lib/link-candidates.test.ts b/packages/app-core/src/lib/link-candidates.test.ts index 837d04d62..7f502af4e 100644 --- a/packages/app-core/src/lib/link-candidates.test.ts +++ b/packages/app-core/src/lib/link-candidates.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect } from 'vitest' import type { FolderEntry, NoteFolder, NoteMeta } from '@shared/ipc' import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' -import { linkCandidates, noteLinkTarget, noteSubtitle, notesMatchingSource } from './link-candidates' +import { + linkCandidates, + noteLinkTarget, + noteSubtitle, + notesMatchingSource, + tagSourceSuggestions +} from './link-candidates' const makeNote = (path: string, title: string, folder: NoteFolder = 'inbox'): NoteMeta => ({ @@ -130,6 +136,36 @@ describe('notesMatchingSource (#500)', () => { }) }) +describe('a field whose options come from a tag leaves template notes out (#1024)', () => { + const notes = [ + makeNote('inbox/people/Jane.md', 'Jane'), + makeNote('inbox/Templates/Person.md', 'Person'), + makeNote('inbox/Templates/Only.md', 'Only') + ] + notes[0].tags.push('person') + notes[1].tags.push('person', 'Template-Only') + notes[2].tags.push('template-only') + + it('offers no template as an option for its tag', () => { + expect( + notesMatchingSource(notes, { kind: 'tag', tag: 'person' }, 'inbox/Templates').map((n) => n.title) + ).toEqual(['Jane']) + }) + + it('still offers the folder itself when the field names it', () => { + expect( + notesMatchingSource(notes, { kind: 'folder', path: 'inbox/Templates' }, 'inbox/Templates').map( + (n) => n.title + ) + ).toEqual(['Person', 'Only']) + }) + + it('suggests no tag that only template notes carry when the tag is picked', () => { + expect(tagSourceSuggestions(notes, 'inbox/Templates')).toEqual(['person']) + expect(tagSourceSuggestions(notes, null)).toEqual(['person', 'template-only']) + }) +}) + describe('noteSubtitle: the [[ picker shows the folder note search shows', () => { it('drops the main area directory and keeps every other area', () => { expect(noteSubtitle(makeNote('inbox/Projects/Atlas/Overview.md', 'Overview'), settings)).toBe( diff --git a/packages/app-core/src/lib/link-candidates.ts b/packages/app-core/src/lib/link-candidates.ts index 1271dbbcd..4275a2061 100644 --- a/packages/app-core/src/lib/link-candidates.ts +++ b/packages/app-core/src/lib/link-candidates.ts @@ -8,16 +8,20 @@ import type { AssetMeta, NoteMeta, VaultSettings } from '@shared/ipc' import type { FolderEntry } from '@shared/ipc' import type { SelectOptionsSource } from '@shared/databases' +import { isPathInTemplateFolder, outsideTemplateFolder } from '@shared/template-folder' import { isPrimaryNotesAtRoot, noteFolderLabel } from './vault-layout' import { listDatabaseLinkTargets, type DatabaseLinkTarget } from './database-links' /** * Scope the note pool by a select field's options source (#500): every note, * a folder subtree (vault-relative path prefix), or a #tag (case-insensitive). + * A tag never offers a note in the template folder (#1024): its tags are the + * note a template will make. A folder source naming that folder still does. */ export function notesMatchingSource( notes: NoteMeta[], - source: SelectOptionsSource | null | undefined + source: SelectOptionsSource | null | undefined, + templateFolder: string | null = null ): NoteMeta[] { if (!source || source.kind === 'notes') return notes if (source.kind === 'folder') { @@ -25,7 +29,23 @@ export function notesMatchingSource( return notes.filter((n) => n.path.startsWith(prefix)) } const tag = source.tag.toLowerCase() - return notes.filter((n) => n.tags.some((t) => t.toLowerCase() === tag)) + return notes.filter( + (n) => + !isPathInTemplateFolder(n.path, templateFolder) && + n.tags.some((t) => t.toLowerCase() === tag) + ) +} + +/** The tags Options from tag suggests, lowercased and sorted: every note's, + * except the template folder's (#1024). */ +export function tagSourceSuggestions( + notes: readonly NoteMeta[], + templateFolder: string | null +): string[] { + const tags = new Set( + outsideTemplateFolder(notes, templateFolder).flatMap((n) => n.tags.map((t) => t.toLowerCase())) + ) + return [...tags].sort() } function normalize(value: string): string { diff --git a/packages/app-core/src/lib/note-search.test.ts b/packages/app-core/src/lib/note-search.test.ts index 6d9c3f263..f35799ff6 100644 --- a/packages/app-core/src/lib/note-search.test.ts +++ b/packages/app-core/src/lib/note-search.test.ts @@ -55,6 +55,17 @@ describe('note search and the template folder', () => { it('shows everything when the vault has no template folder', () => { expect(titles('meeting', null)).toEqual(['Meeting', 'Meeting old', 'Meeting with Ana']) }) + + it('finds no template by tag, unless the query starts with the folder path', () => { + const tagged = buildNoteSearchIndex([ + note('Templates/Meeting.md', 'Meeting', { tags: ['meeting'] }), + note('Notes/Standup.md', 'Standup', { tags: ['meeting'] }) + ]) + const byTag = (query: string): string[] => + searchNoteIndex(tagged, query, { limit: 10, templateFolder: 'Templates' }).map((n) => n.title) + expect(byTag('#meeting')).toEqual(['Standup']) + expect(byTag('Templates/ #meeting')).toEqual(['Meeting']) + }) }) describe('note search', () => { diff --git a/packages/app-core/src/lib/tags.test.ts b/packages/app-core/src/lib/tags.test.ts index 5296bd00f..bfd070074 100644 --- a/packages/app-core/src/lib/tags.test.ts +++ b/packages/app-core/src/lib/tags.test.ts @@ -1,5 +1,62 @@ import { describe, expect, it } from 'vitest' -import { buildTagTree, extractTags, flattenTagTree, matchesSelectedTags } from './tags' +import { + buildTagTree, + countListedTags, + countVaultTags, + extractTags, + flattenTagTree, + matchesSelectedTags, + noteTagsForCount +} from './tags' + +describe('template notes give the tag surfaces nothing (#1024)', () => { + const notes = [ + { path: 'Notes.md', folder: 'inbox', tags: ['meeting'] }, + { path: 'Templates/Meeting.md', folder: 'inbox', tags: ['meeting', 'agenda'] }, + { path: 'Templates/Work/Retro.md', folder: 'inbox', tags: ['retro'] }, + { path: 'Templates Old/Kept.md', folder: 'inbox', tags: ['kept'] }, + { path: 'Gone.md', folder: 'trash', tags: ['gone'] } + ] + + it('counts no tag a note in the template folder carries', () => { + expect([...countVaultTags(notes, null, 'typst', 'Templates')]).toEqual([ + ['meeting', 1], + ['kept', 1] + ]) + }) + + it('reads an open template as nothing, even while it is being edited', () => { + const active = { path: 'Templates/Meeting.md', body: '#fresh' } + expect(noteTagsForCount(notes[1], active, 'typst', 'Templates')).toEqual([]) + expect(noteTagsForCount(notes[0], active, 'typst', 'Templates')).toEqual(['meeting']) + }) + + it('counts every live note when the vault has no template folder', () => { + expect([...countVaultTags(notes, null, 'typst', null)].map(([tag]) => tag)).toEqual([ + 'meeting', + 'agenda', + 'retro', + 'kept' + ]) + }) + + it('gives the sidebar list the same counts, with its live tags for the edited note', () => { + expect(countListedTags(notes, null, 'Templates')).toEqual([ + ['kept', 1], + ['meeting', 1] + ]) + const live = { path: 'Notes.md', tags: ['meeting', 'standup'] } + expect(countListedTags(notes, live, 'Templates')).toEqual([ + ['kept', 1], + ['meeting', 1], + ['standup', 1] + ]) + expect(countListedTags(notes, { path: 'Templates/Meeting.md', tags: ['fresh'] }, 'Templates')).toEqual([ + ['kept', 1], + ['meeting', 1] + ]) + }) +}) describe('extractTags — code fences are never scanned for tags (#293)', () => { it('ignores #tags inside a top-level fenced code block', () => { diff --git a/packages/app-core/src/lib/tags.ts b/packages/app-core/src/lib/tags.ts index 069bc22c1..a805f1469 100644 --- a/packages/app-core/src/lib/tags.ts +++ b/packages/app-core/src/lib/tags.ts @@ -5,6 +5,7 @@ * for the renderer's existing callers. */ import { extractTags } from '@shared/tag-scan' +import { isPathInTemplateFolder } from '@shared/template-folder' import { isTypstPreamblePath } from '@shared/typst-preamble-folder' export { extractTags } @@ -34,39 +35,69 @@ export function matchesSelectedTags( * `#let vec(x) = …` and the `#var` references in formulas are variables rather * than tags (#562). The indexers already drop them, and this keeps the live * path from putting them back the moment the user opens one. + * + * Notes in the template folder contribute nothing either (#1024): their tags + * are the note a template will make, not the vault's, so the tag list, the + * Tags view and tag completion leave them out, as search does. Their index + * entries keep the tags (renaming a tag still reaches inside templates). */ export function noteTagsForCount( note: { path: string; tags: readonly string[] }, active: { path: string; body: string } | null | undefined, - preambleFolder: string + preambleFolder: string, + templateFolder: string | null ): readonly string[] { if (isTypstPreamblePath(note.path, preambleFolder)) return EMPTY_TAGS + if (isPathInTemplateFolder(note.path, templateFolder)) return EMPTY_TAGS return active && active.path === note.path ? extractTags(active.body) : note.tags } const EMPTY_TAGS: readonly string[] = [] /** - * Unique tags across the vault (trash excluded), counted by how many notes use - * them. The active note is read live from its buffer so a tag just typed in the - * same note is offered too. One aggregation for the editor's `#` completion, - * the frontmatter `tags:` completion and the New note form in search. + * Unique tags across the vault (trash and the template folder excluded), + * counted by how many notes use them. The active note is read live from its + * buffer so a tag just typed in the same note is offered too. One aggregation + * for the editor's `#` completion, the frontmatter `tags:` completion and the + * New note form in search. */ export function countVaultTags( notes: readonly { path: string; folder: string; tags: readonly string[] }[], active: { path: string; body: string } | null | undefined, - preambleFolder: string + preambleFolder: string, + templateFolder: string | null ): Map<string, number> { const counter = new Map<string, number>() for (const note of notes) { if (note.folder === 'trash') continue - for (const t of noteTagsForCount(note, active, preambleFolder)) { + for (const t of noteTagsForCount(note, active, preambleFolder, templateFolder)) { counter.set(t, (counter.get(t) ?? 0) + 1) } } return counter } +/** + * The sidebar's tag list, sorted by tag: every live note's indexed tags, with + * `live` (the edited note's tags, parsed from its unsaved buffer) in place of + * that note's, and nothing from the template folder (#1024). Typst preambles + * need no check here: their index entries carry no tags, and the sidebar never + * parses a live preamble. + */ +export function countListedTags( + notes: readonly { path: string; folder: string; tags: readonly string[] }[], + live: { path: string; tags: readonly string[] } | null, + templateFolder: string | null +): [string, number][] { + const counter = new Map<string, number>() + for (const note of notes) { + if (note.folder === 'trash' || isPathInTemplateFolder(note.path, templateFolder)) continue + const list = live && live.path === note.path ? live.tags : note.tags + for (const t of list) counter.set(t, (counter.get(t) ?? 0) + 1) + } + return [...counter.entries()].sort((a, b) => a[0].localeCompare(b[0])) +} + export interface RankedTag { tag: string count: number diff --git a/packages/app-core/src/shell.ts b/packages/app-core/src/shell.ts index 9ed3c4d13..2c18e0828 100644 --- a/packages/app-core/src/shell.ts +++ b/packages/app-core/src/shell.ts @@ -1,5 +1,6 @@ import { noteTagsForCount } from './lib/tags' import { resolveTypstPreambleFolder } from './lib/typst-preamble' +import { resolveTemplateFolder } from '@shared/template-folder' import { useSyncExternalStore } from 'react' import type { NoteFolder, @@ -202,15 +203,17 @@ export interface TagPresenceSnapshot { readonly vaultRoot: string | null readonly hasTags: boolean } -let tagNotes: unknown, tagActive: unknown, tagFolder: string | undefined +let tagNotes: unknown, tagActive: unknown, tagFolder: string | undefined, tagTemplates: string | null | undefined let tagPresence: TagPresenceSnapshot | undefined -/** Tag presence includes the live editor and excludes Typst preambles. */ +/** Tag presence includes the live editor and excludes Typst preambles and the + * template folder (#1024). */ export function getTagPresenceSnapshot(): TagPresenceSnapshot { const state = useStore.getState() const folder = resolveTypstPreambleFolder(state.vaultSettings.typstPreambles?.folder) - if (tagNotes === state.notes && tagActive === state.activeNote && tagFolder === folder && tagPresence?.vaultRoot === (state.vault?.root ?? null)) return tagPresence! - tagNotes = state.notes; tagActive = state.activeNote; tagFolder = folder - const hasTags = state.notes.some(note => note.folder !== 'trash' && noteTagsForCount(note, state.activeNote, folder).length > 0) + const templates = resolveTemplateFolder(state.vaultSettings) + if (tagNotes === state.notes && tagActive === state.activeNote && tagFolder === folder && tagTemplates === templates && tagPresence?.vaultRoot === (state.vault?.root ?? null)) return tagPresence! + tagNotes = state.notes; tagActive = state.activeNote; tagFolder = folder; tagTemplates = templates + const hasTags = state.notes.some(note => note.folder !== 'trash' && noteTagsForCount(note, state.activeNote, folder, templates).length > 0) const vaultRoot = state.vault?.root ?? null if (!tagPresence || tagPresence.vaultRoot !== vaultRoot || tagPresence.hasTags !== hasTags) tagPresence = Object.freeze({ vaultRoot, hasTags }) diff --git a/packages/shared-domain/src/template-folder.test.ts b/packages/shared-domain/src/template-folder.test.ts index 834d83a8e..0ba4be965 100644 --- a/packages/shared-domain/src/template-folder.test.ts +++ b/packages/shared-domain/src/template-folder.test.ts @@ -3,6 +3,7 @@ import { isPathInTemplateFolder, normalizeTemplateFolder, normalizeTemplateFolderSettings, + outsideTemplateFolder, resolveTemplateFolder, rewriteTemplateFolderForRename, templateFolderEntryKey @@ -130,3 +131,24 @@ describe('rewriteTemplateFolderForRename', () => { expect(rewriteTemplateFolderForRename(settings, 'Templates', 'Templates')).toBe(settings) }) }) + +describe('outsideTemplateFolder: what the tag surfaces count (#1024)', () => { + const notes = [ + { path: 'Notes.md' }, + { path: 'Templates/Meeting.md' }, + { path: 'Templates/Work/Retro.md' }, + { path: 'Templates Old/Kept.md' } + ] + + it('leaves the template folder out, subfolders included, and nothing else', () => { + expect(outsideTemplateFolder(notes, 'Templates').map((n) => n.path)).toEqual([ + 'Notes.md', + 'Templates Old/Kept.md' + ]) + }) + + it('hands back the same list when the vault has no template folder', () => { + expect(outsideTemplateFolder(notes, null)).toBe(notes) + expect(outsideTemplateFolder(notes, undefined)).toBe(notes) + }) +}) diff --git a/packages/shared-domain/src/template-folder.ts b/packages/shared-domain/src/template-folder.ts index acdff5204..4c0946fad 100644 --- a/packages/shared-domain/src/template-folder.ts +++ b/packages/shared-domain/src/template-folder.ts @@ -11,7 +11,9 @@ // lines are not work and never feed Tasks (every scanner adds the folder // to its excluded list through `tasksFolderListsForSettings`); // - nobody searching their notes is looking for one, so search leaves them -// out (Search notes brings them back once the query names the folder); +// out (Search notes brings them back once the query names the folder), +// and their tags are not the vault's: the tag surfaces skip them too +// (`outsideTemplateFolder`); // - they stay ordinary notes otherwise: they sync, they show in the sidebar, // and they are edited in the editor like any other note. // @@ -80,6 +82,23 @@ export function isPathInTemplateFolder( return relPath === folder || relPath.startsWith(folder + '/') } +/** + * The notes outside the template folder: what the vault's tag surfaces count + * and search by tag (the tag list, the Tags view, tag completion, a database + * field's tag options, MCP `list_tags` and `search_by_tag`, `zn tag`). A + * template's tags are the note it will make, not the vault's, so they never + * show as the vault's own. Title search and plain listings keep the folder, + * and so does renaming a tag, which rewrites it inside templates too so they + * stay in step. The same list comes back when the vault has no folder. + */ +export function outsideTemplateFolder<T extends { path: string }>( + notes: readonly T[], + folder: string | null | undefined +): readonly T[] { + if (!folder) return notes + return notes.filter((note) => !isPathInTemplateFolder(note.path, folder)) +} + /** What names a note inside the template folder: its path below the folder * without the `.md` extension (`Work/Standup`). Stable when the folder itself * is renamed, so it is what a template-folder template's id is built from. From 94a527b23484e130943fdf0f5f2fbcc98b58fb78 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 14:19:36 -0500 Subject: [PATCH 50/53] Fix(tasks): the Calendar and Timeline panels and Home leave out empty tasks too (#1026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch 2.80.0 added for #1026 kept a daily-note template's bare `- [ ]` out of the Tasks list, calendar and board, but the surfaces beside them still drew every one. The Calendar panel listed "(empty task)" under each day of its week and marked the day as holding open work. The Timeline put yesterday's placeholder on top, in red, as "(empty task) 1d late", counted it as overdue and let one split the quiet days. Home listed it under Today as a raw `- [ ]` and counted it in the overdue badge. With the setting on by default the app said two things at once: no empty tasks in the Tasks view, a panel full of them right beside it. They now follow the same setting. With Hide empty tasks on, each drops the empty tasks with withoutEmptyTasks before it does anything else, so what it counts is what it shows: the Calendar panel's week agenda and its day counts, the open-task mark on a day or week cell (a day whose only tasks are empty has none and reads as a quiet day), the Timeline's open and overdue counts, its late days, its quiet-day folds, a milestone's "tasks before it" and the later and undated buttons, and Home's Today rows, overdue badge and "+N more". With the setting off they show as before. There is no new setting and nothing new on the panels, no "hidden" pill: one switch covers them all, and the Tasks view's "3 hidden" already says where the placeholders went. A cell's mark counts the open tasks in the note's body, measured once per edit. The measure now keeps the empty ones apart, so flipping the switch redraws every mark without reading a note again. The phones' home-screen widgets are built from the same data in core: the Calendar widget promises to show what the Calendar sheet shows, and getTodayTasks is Home's Today. Both follow the switch too, so a phone that adopts this core never puts on its home screen a placeholder its own Calendar sheet hides. Their shapes are unchanged; neither phone repo reads the new count. Copy: help.ts says the switch covers the Calendar and Timeline panels and the home view (the card, the `E` row and the Settings row), and so do the Settings line, the config.toml comment and the 2.80.0 What's New. Tests, red first (7 of 56 failed in the five files): the Calendar panel's agenda, counts and cell marks with the setting on, off and flipped live; the Timeline's rows, counts, late days, quiet days, milestone tally and undated count both ways; Home's rows, overdue badge, "+N more" and the all-clear; the Calendar widget's marks and agenda; getTodayTasks. Over CDP on the built app, both stores isolated, with yesterday's and today's daily notes each holding a bare `- [ ]` and one real task, and tomorrow's only the placeholder: Home lists the two real tasks with "1 overdue", the Calendar panel shows one task a day and no mark on tomorrow, and the Timeline reads "2 open · 1 overdue". `E` in the Tasks view brings them back everywhere: "2 overdue" on Home, "(empty task)" rows in the panel's week with counts of 2, 2 and 1, and "5 open · 2 overdue" on the Timeline with "(empty task) 1d late" on top. No console errors. How to test locally: build (`cd apps/desktop && npx electron-vite build`), launch with daily notes on, and give yesterday's and today's daily notes a bare `- [ ]` and one real task each. Before: the Calendar panel (`Space c`) and the Timeline (`Mod+7`) list "(empty task)", the Timeline shows one 1d late, and Home's Today says 2 overdue. After: only the real tasks, and Home says 1 overdue. Open Tasks (`:tasks`) and press `E` in Vim mode, or pick Hide empty tasks in the View menu: the placeholders come back in all three. --- apps/desktop/src/main/app-config.ts | 2 +- .../src/components/CalendarPanel.test.ts | 102 ++++++++++++++++++ .../app-core/src/components/CalendarPanel.tsx | 25 +++-- .../app-core/src/components/HomeView.test.ts | 93 ++++++++++++++++ packages/app-core/src/components/HomeView.tsx | 11 +- .../app-core/src/components/SettingsModal.tsx | 4 +- .../src/components/TimelinePanel.test.ts | 42 ++++++++ .../app-core/src/components/TimelinePanel.tsx | 16 +-- packages/app-core/src/lib/calendar-data.ts | 22 +++- .../src/lib/calendar-widget-data.test.ts | 24 +++++ .../app-core/src/lib/calendar-widget-data.ts | 22 ++-- packages/app-core/src/lib/commands.ts | 3 +- packages/app-core/src/lib/help.ts | 6 +- packages/app-core/src/lib/whats-new.ts | 2 +- packages/app-core/src/public-host-api.test.ts | 23 ++++ packages/app-core/src/store.ts | 7 +- packages/app-core/src/tasks.ts | 7 +- 17 files changed, 370 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 49dcd422f..5fa2f5069 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -449,7 +449,7 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = { hideEmptyTasks: { section: 'view', tomlKey: 'hide_empty_tasks', - comment: 'leave blank checkboxes out of the Tasks list, calendar and board' + comment: 'leave blank checkboxes out of the Tasks views, the Calendar and Timeline panels and the home view' }, tasksListOrder: { section: 'view', diff --git a/packages/app-core/src/components/CalendarPanel.test.ts b/packages/app-core/src/components/CalendarPanel.test.ts index dfabf735d..808209f43 100644 --- a/packages/app-core/src/components/CalendarPanel.test.ts +++ b/packages/app-core/src/components/CalendarPanel.test.ts @@ -4,6 +4,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NoteMeta } from '@shared/ipc' +import { parseTasksFromBody } from '@shared/tasks' import { useStore } from '../store' import { dailyNoteLocationForDate } from '../lib/vault-layout' import { CalendarPanel } from './CalendarPanel' @@ -160,3 +161,104 @@ describe('CalendarPanel: a day on a touch screen', () => { expect(document.activeElement).toBe(input) }) }) + +describe('CalendarPanel: empty tasks (#1026)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + const today = new Date() + // Another day of today's week (weeks start Monday here), so the agenda + // under the grid shows both. + const offset = (today.getDay() + 6) % 7 + const other = + offset === 0 + ? new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1) + : new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset) + + beforeEach(() => { + originalState = useStore.getState() + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) + const settings = originalState.vaultSettings + useStore.setState({ + vaultSettings: { ...settings, dailyNotes: { ...settings.dailyNotes, enabled: true } }, + calendarWeekStart: 'monday' + }) + // A daily-note template's placeholder beside real work today, and a day + // that holds only the placeholder. + const bodies = new Map([ + [dailyNote(today), '- [ ]\n- [ ] Call the bank\n'], + [dailyNote(other), '- [ ]\n'] + ]) + const notes = [...bodies.keys()] + useStore.setState({ + notes, + vaultTasks: notes.flatMap((n) => + parseTasksFromBody(bodies.get(n)!, { path: n.path, title: n.title, folder: n.folder }) + ), + noteContents: Object.fromEntries(notes.map((n) => [n.path, { ...n, body: bodies.get(n)! }])), + tasksLoading: false, + showArchivedTasks: false + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + async function render(): Promise<void> { + act(() => root.render(createElement(CalendarPanel, { note: null }))) + // The grid's open-task marks come from the notes' bodies, read async. + await act(async () => {}) + } + const agendaRows = (date: Date): number => + host.querySelectorAll(`[data-cal-agenda-day="${isoDate(date)}"] [data-cal-task]`).length + const agendaCount = (date: Date): string | null => + host.querySelector(`[data-cal-agenda-day="${isoDate(date)}"] [data-cal-agenda-count]`)?.textContent ?? + null + const openMark = (date: Date): string | null => + host + .querySelector(`[data-cal-day="${isoDate(date)}"] span[title*="open task"]`) + ?.getAttribute('title') ?? null + + it('lists and counts only real tasks, and a day of placeholders goes quiet', async () => { + useStore.setState({ hideEmptyTasks: true }) + await render() + expect(host.textContent).not.toContain('(empty task)') + expect(agendaRows(today)).toBe(1) + expect(agendaCount(today)).toBe('1') + expect(openMark(today)).toBe('1 open task') + expect(agendaRows(other)).toBe(0) + expect(agendaCount(other)).toBeNull() + expect(openMark(other)).toBeNull() + }) + + it('shows them again, counted, with the setting off', async () => { + useStore.setState({ hideEmptyTasks: false }) + await render() + expect(host.textContent).toContain('(empty task)') + expect(agendaRows(today)).toBe(2) + expect(agendaCount(today)).toBe('2') + expect(openMark(today)).toBe('2 open tasks') + expect(agendaRows(other)).toBe(1) + expect(agendaCount(other)).toBe('1') + expect(openMark(other)).toBe('1 open task') + }) + + it('follows the switch without reading the notes again', async () => { + useStore.setState({ hideEmptyTasks: false }) + await render() + expect(openMark(other)).toBe('1 open task') + act(() => useStore.setState({ hideEmptyTasks: true })) + expect(openMark(other)).toBeNull() + expect(agendaRows(other)).toBe(0) + expect(openMark(today)).toBe('1 open task') + }) +}) diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index 712d70117..41c164fa1 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -38,8 +38,10 @@ import { isoWeekStr, measureNote, parseIsoDate, + shownOpenTasks, type NoteStats, } from '../lib/calendar-data' +import { withoutEmptyTasks } from '../lib/empty-tasks' import { InlineMarkdown } from '../lib/inline-markdown' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { resolveWeekStartDay } from '../lib/week-start' @@ -92,6 +94,7 @@ export function CalendarPanel({ const storeOpenWeeklyNote = useStore((s) => s.openWeeklyNoteForDate) const vaultTasks = useStore((s) => s.vaultTasks) const showArchivedTasks = useStore((s) => s.showArchivedTasks) + const hideEmptyTasks = useStore((s) => s.hideEmptyTasks) const tasksLoading = useStore((s) => s.tasksLoading) const refreshTasks = useStore((s) => s.refreshTasks) const addTaskForDate = useStore((s) => s.addTaskForDate) @@ -200,14 +203,16 @@ export function CalendarPanel({ // Tasks scheduled for the selected day. Tasks written in a daily note inherit // that note's date (implicit due) just like the full Tasks calendar, so we run // the same inference over the vault-wide index. Load it lazily the first time. + // Empty tasks stay out on the Tasks views' switch (#1026), so a day whose + // only tasks are a template's bare `- [ ]` reads as a quiet day. const dueByPath = useMemo( () => buildDailyNoteDateByPath(notes, vaultSettings), [notes, vaultSettings] ) - const inferredTasks = useMemo( - () => inferDailyTaskDueDates(filterTasksForDisplay(vaultTasks, showArchivedTasks), dueByPath), - [vaultTasks, showArchivedTasks, dueByPath] - ) + const inferredTasks = useMemo(() => { + const shown = filterTasksForDisplay(vaultTasks, showArchivedTasks) + return inferDailyTaskDueDates(hideEmptyTasks ? withoutEmptyTasks(shown).shown : shown, dueByPath) + }, [vaultTasks, showArchivedTasks, hideEmptyTasks, dueByPath]) // The 7 days of the selected day's week (respecting the week-start setting), // shown as an agenda under the calendar. const weekDays = useMemo(() => { @@ -841,6 +846,7 @@ export function CalendarPanel({ <div key={task.id} ref={opts?.rowRef ?? undefined} + data-cal-task={task.id} role="button" tabIndex={0} draggable @@ -968,7 +974,7 @@ export function CalendarPanel({ const isActiveWeek = weekIso === activeWeekIso const weekMeta = weeklyByWeek.get(weekIso) const weekDots = dotsFor(weekMeta ? stats.get(weekMeta.path) : undefined) - const weekTasks = weekMeta ? stats.get(weekMeta.path)?.openTasks ?? 0 : 0 + const weekTasks = weekMeta ? shownOpenTasks(stats.get(weekMeta.path), hideEmptyTasks) : 0 const weekCell = !showWeekNumbers ? null : weeklyEnabled ? ( <button @@ -1020,7 +1026,7 @@ export function CalendarPanel({ const dayMeta = dailyByDate.get(iso) const dayStats = dayMeta ? stats.get(dayMeta.path) : undefined const dots = dotsFor(dayStats) - const openTasks = dayStats?.openTasks ?? 0 + const openTasks = shownOpenTasks(dayStats, hideEmptyTasks) return ( <button @@ -1176,6 +1182,7 @@ export function CalendarPanel({ return ( <div key={iso} + data-cal-agenda-day={iso} onDragOver={(e) => { if (!dragTaskRef.current) return e.preventDefault() @@ -1213,7 +1220,11 @@ export function CalendarPanel({ </span> )} </span> - {dayTasks.length > 0 && <span className="text-ink-400">{dayTasks.length}</span>} + {dayTasks.length > 0 && ( + <span data-cal-agenda-count className="text-ink-400"> + {dayTasks.length} + </span> + )} </button> {dayTasks.length > 0 && ( <div className="mt-0.5 space-y-0.5"> diff --git a/packages/app-core/src/components/HomeView.test.ts b/packages/app-core/src/components/HomeView.test.ts index ccc4bbdc7..d0f6fc05d 100644 --- a/packages/app-core/src/components/HomeView.test.ts +++ b/packages/app-core/src/components/HomeView.test.ts @@ -4,6 +4,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NoteMeta } from '@shared/ipc' +import type { VaultTask } from '@shared/tasks' import { useStore } from '../store' import { HomeView } from './HomeView' @@ -100,3 +101,95 @@ describe('HomeView: opening a note hands the keyboard to the editor (#863)', () expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) }) }) + +describe('HomeView: Today leaves empty tasks out (#1026)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + + function isoOffset(days: number): string { + const now = new Date() + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + } + + function task(id: string, content: string, due: string): VaultTask { + return { + id, + sourcePath: `inbox/${id}.md`, + noteTitle: `Note ${id}`, + noteFolder: 'inbox', + lineNumber: 0, + taskIndex: 0, + rawText: `- [ ] ${content}`.trimEnd(), + content, + checked: false, + forwarded: false, + cancelled: false, + inProgress: false, + waiting: false, + tags: [], + due + } + } + + // Five real tasks, one of them late, and a daily-note template's two + // placeholders: yesterday's (now overdue) and today's. + const TASKS = [ + task('a-late', 'Renew the passport', isoOffset(-2)), + task('b', 'Call the bank', isoOffset(0)), + task('c', 'Water the plants', isoOffset(0)), + task('d', 'Book the dentist', isoOffset(0)), + task('e', 'Send the invoice', isoOffset(0)), + task('blank-late', '', isoOffset(-1)), + task('blank-today', '', isoOffset(0)) + ] + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + useStore.setState({ + notes: [], + folders: [], + vaultTasks: TASKS, + tasksLoading: false, + showArchivedTasks: false, + refreshTasks: vi.fn(async () => {}) + }) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const section = (): Element => host.querySelector('[data-home-section="today"]')! + const rows = (): string[] => + [...section().querySelectorAll('li')].map((li) => li.textContent ?? '') + + it('lists only real tasks, and the overdue badge and "+N more" count only them', () => { + useStore.setState({ hideEmptyTasks: true }) + act(() => root.render(createElement(HomeView, { sidebarOpen: true, onShowSidebar: () => {} }))) + expect(rows()).toHaveLength(5) + expect(rows().some((text) => text.includes('- [ ]'))).toBe(false) + expect(section().textContent).toContain('1 overdue') + expect(section().textContent).not.toMatch(/more tasks?/) + + // Off, they show again and count again. + act(() => useStore.setState({ hideEmptyTasks: false })) + expect(rows()).toHaveLength(6) + expect(section().textContent).toContain('2 overdue') + expect(section().textContent).toContain('+1 more task') + }) + + it('says all clear when the day holds only placeholders', () => { + useStore.setState({ hideEmptyTasks: true, vaultTasks: TASKS.filter((t) => t.content === '') }) + act(() => root.render(createElement(HomeView, { sidebarOpen: true, onShowSidebar: () => {} }))) + expect(rows()).toHaveLength(0) + expect(section().textContent).toContain('No open tasks. All clear.') + expect(section().textContent).not.toContain('overdue') + }) +}) diff --git a/packages/app-core/src/components/HomeView.tsx b/packages/app-core/src/components/HomeView.tsx index fb88f981f..de160b47d 100644 --- a/packages/app-core/src/components/HomeView.tsx +++ b/packages/app-core/src/components/HomeView.tsx @@ -3,6 +3,7 @@ import type { NoteMeta } from '@shared/ipc' import { filterTasksForDisplay, inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' import { useStore } from '../store' import { computeTasksRender } from '../lib/tasks-filter' +import { withoutEmptyTasks } from '../lib/empty-tasks' import { focusEditorNormalMode } from '../lib/editor-focus' import { InlineMarkdown } from '../lib/inline-markdown' import { getSystemFolderLabel } from '../lib/system-folder-labels' @@ -75,6 +76,7 @@ export function HomeView({ const folders = useStore((s) => s.folders) const vaultTasks = useStore((s) => s.vaultTasks) const showArchivedTasks = useStore((s) => s.showArchivedTasks) + const hideEmptyTasks = useStore((s) => s.hideEmptyTasks) const tasksLoading = useStore((s) => s.tasksLoading) const vimMode = useStore((s) => s.vimMode) const selectNote = useStore((s) => s.selectNote) @@ -224,17 +226,20 @@ export function HomeView({ // Today is what is due today or overdue, as in the Tasks list: a task in a // daily note takes the note's date there, and an undated one waits under No - // due date instead (#990), so Home applies the same daily-note dates. + // due date instead (#990), so Home applies the same daily-note dates. Empty + // tasks stay out on the Tasks views' switch (#1026), from the rows, the + // overdue badge and the "+N more" alike. const { today, overdueCount } = useMemo(() => { const dueByPath = buildDailyNoteDateByPath(notes, vaultSettings) + const shown = filterTasksForDisplay(vaultTasks, showArchivedTasks) const render = computeTasksRender( - inferDailyTaskDueDates(filterTasksForDisplay(vaultTasks, showArchivedTasks), dueByPath), + inferDailyTaskDueDates(hideEmptyTasks ? withoutEmptyTasks(shown).shown : shown, dueByPath), '', new Date(now), NO_COLLAPSE ) return { today: render.groups.today, overdueCount: render.groups.overdueCount } - }, [vaultTasks, showArchivedTasks, now, notes, vaultSettings]) + }, [vaultTasks, showArchivedTasks, hideEmptyTasks, now, notes, vaultSettings]) const visibleTasks = today.slice(0, MAX_TASKS) const hiddenTaskCount = today.length - visibleTasks.length diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index dda7f091a..1c787870d 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -3724,7 +3724,7 @@ export function SettingsModal(): JSX.Element { { id: "hide-empty-tasks", title: "Hide empty tasks", - description: "Leave blank checkboxes out of the Tasks views.", + description: "Leave blank checkboxes out of the Tasks views, Calendar, Timeline and home view.", keywords: [ "empty", "empty task", @@ -3853,7 +3853,7 @@ export function SettingsModal(): JSX.Element { <Section title="Empty tasks"> <ToggleRow label="Hide empty tasks" - description="Leave blank checkboxes out of the Tasks views." + description="Leave blank checkboxes out of the Tasks views, Calendar, Timeline and home view." value={hideEmptyTasks} settingId="hide-empty-tasks" onChange={setHideEmptyTasks} diff --git a/packages/app-core/src/components/TimelinePanel.test.ts b/packages/app-core/src/components/TimelinePanel.test.ts index 6231dc403..7d7ed1ab5 100644 --- a/packages/app-core/src/components/TimelinePanel.test.ts +++ b/packages/app-core/src/components/TimelinePanel.test.ts @@ -174,6 +174,48 @@ describe('TimelinePanel', () => { expect(host.querySelector('[data-timeline-count]')?.textContent).toBe('3 open · 1 overdue') }) + // A daily-note template's bare `- [ ]` lines (#1026): yesterday's sat on + // top as "(empty task) 1d late", today's under Today, and one two days out + // split the quiet days and padded the milestone's count. + it('leaves empty tasks off the rail and out of every count while Hide empty tasks is on', () => { + const empty = (id: string, due?: string): VaultTask => + task(id, { rawText: '- [ ]', content: '', ...(due ? { due } : {}) }) + useStore.setState({ + hideEmptyTasks: true, + vaultTasks: [ + ...VAULT, + empty('blank-late', isoOffset(-1)), + empty('blank-today', isoOffset(0)), + empty('blank-soon', isoOffset(2)), + empty('blank-undated') + ] + }) + const panel = render() + expect(rowIds()).toEqual(['late', 'doing', 'today', 'launch']) + expect(panel.textContent).not.toContain('(empty task)') + expect(panel.querySelector('[data-timeline-count]')?.textContent).toBe('4 open · 1 overdue') + expect(panel.querySelectorAll('[data-timeline-late]')).toHaveLength(1) + expect(panel.querySelector(`[data-timeline-day="${isoOffset(2)}"]`)).toBeNull() + expect([...panel.querySelectorAll('[data-timeline-quiet]')].map((el) => el.textContent)).toEqual([ + '4 quiet days' + ]) + expect(panel.querySelector('[data-timeline-before]')?.textContent).toBe('· 3 tasks before it') + expect(panel.querySelector('[data-timeline-undated]')?.textContent).toBe('1 undated in Tasks') + + // Off, they come back, counted. + act(() => useStore.setState({ hideEmptyTasks: false })) + expect(rowIds()).toEqual(['late', 'blank-late', 'blank-today', 'doing', 'today', 'blank-soon', 'launch']) + expect(panel.querySelector('[data-timeline-count]')?.textContent).toBe('7 open · 2 overdue') + expect(panel.querySelector('[data-timeline-task="blank-late"]')?.textContent).toContain('(empty task)') + expect(panel.querySelector('[data-timeline-task="blank-late"]')?.textContent).toContain('1d late') + expect([...panel.querySelectorAll('[data-timeline-quiet]')].map((el) => el.textContent)).toEqual([ + '1 quiet day', + '2 quiet days' + ]) + expect(panel.querySelector('[data-timeline-before]')?.textContent).toBe('· 6 tasks before it') + expect(panel.querySelector('[data-timeline-undated]')?.textContent).toBe('2 undated in Tasks') + }) + it('leaves the letters alone with Vim off; the arrows, Enter and Escape still work', () => { useStore.setState({ vimMode: false }) const panel = render() diff --git a/packages/app-core/src/components/TimelinePanel.tsx b/packages/app-core/src/components/TimelinePanel.tsx index 36503e1a5..d0f78809b 100644 --- a/packages/app-core/src/components/TimelinePanel.tsx +++ b/packages/app-core/src/components/TimelinePanel.tsx @@ -24,6 +24,7 @@ import { filterTasksForDisplay, inferDailyTaskDueDates, type VaultTask } from '@ import { useStore } from '../store' import { buildDailyNoteDateByPath } from '../lib/vault-layout' import { filterTasks, noteTagsByPath } from '../lib/tasks-filter' +import { withoutEmptyTasks } from '../lib/empty-tasks' import { buildTimelineRail, timelineDayLabel, @@ -116,6 +117,7 @@ export function TimelinePanel({ const vaultTasks = useStore((s) => s.vaultTasks) const tasksLoading = useStore((s) => s.tasksLoading) const showArchivedTasks = useStore((s) => s.showArchivedTasks) + const hideEmptyTasks = useStore((s) => s.hideEmptyTasks) const vimMode = useStore((s) => s.vimMode) const keymapOverrides = useStore((s) => s.keymapOverrides) const focusedPanel = useStore((s) => s.focusedPanel) @@ -134,16 +136,18 @@ export function TimelinePanel({ const today = useToday() // The same task list every Tasks surface starts from: archived notes out - // unless asked for, and a daily note's undated tasks due on its date, - // exactly as the calendar panel infers them. + // unless asked for, empty tasks out while Hide empty tasks is on (#1026), + // and a daily note's undated tasks due on its date, exactly as the + // calendar panel infers them. Dropped before the rail is built, so every + // count, late day, quiet stretch and milestone tally leaves them out too. const dueByPath = useMemo( () => buildDailyNoteDateByPath(notes, vaultSettings), [notes, vaultSettings] ) - const tasks = useMemo( - () => inferDailyTaskDueDates(filterTasksForDisplay(vaultTasks, showArchivedTasks), dueByPath), - [vaultTasks, showArchivedTasks, dueByPath] - ) + const tasks = useMemo(() => { + const shown = filterTasksForDisplay(vaultTasks, showArchivedTasks) + return inferDailyTaskDueDates(hideEmptyTasks ? withoutEmptyTasks(shown).shown : shown, dueByPath) + }, [vaultTasks, showArchivedTasks, hideEmptyTasks, dueByPath]) // A file task also matches the tags written in its note's body (#1008). const noteTags = useMemo(() => noteTagsByPath(notes), [notes]) const filtered = useMemo(() => filterTasks(tasks, filter, noteTags), [tasks, filter, noteTags]) diff --git a/packages/app-core/src/lib/calendar-data.ts b/packages/app-core/src/lib/calendar-data.ts index d4ed4d910..6a1a9a898 100644 --- a/packages/app-core/src/lib/calendar-data.ts +++ b/packages/app-core/src/lib/calendar-data.ts @@ -9,6 +9,7 @@ import type { NoteMeta } from '@shared/ipc' import { isTaskOpen, parseTasksFromBody } from '@shared/tasks' import { getISOWeek, getISOWeekYear } from './template-render' import { countWords } from './word-count' +import { withoutEmptyTasks } from './empty-tasks' export const WORDS_PER_DOT = 80 export const MAX_DOTS = 3 @@ -18,6 +19,9 @@ export interface NoteStats { sig: string words: number openTasks: number + /** How many of `openTasks` are empty, a template's bare `- [ ]` (#1026). + * Counted apart so the Hide empty tasks switch never needs a re-read. */ + emptyOpenTasks: number } export function addMonths(d: Date, n: number): Date { @@ -64,10 +68,22 @@ export function measureNote( note: Pick<NoteMeta, 'path' | 'title' | 'folder' | 'updatedAt' | 'size'>, body: string ): NoteStats { - const openTasks = parseTasksFromBody(body, { + const open = parseTasksFromBody(body, { path: note.path, title: note.title, folder: note.folder, - }).filter(isTaskOpen).length - return { sig: `${note.updatedAt}:${note.size}`, words: countWords(body), openTasks } + }).filter(isTaskOpen) + return { + sig: `${note.updatedAt}:${note.size}`, + words: countWords(body), + openTasks: open.length, + emptyOpenTasks: withoutEmptyTasks(open).hidden, + } +} + +/** The open tasks a day or week marks, less the empty ones while Hide empty + * tasks is on: a day holding only placeholders has no mark. (#1026) */ +export function shownOpenTasks(stats: NoteStats | undefined, hideEmpty: boolean): number { + if (!stats) return 0 + return hideEmpty ? stats.openTasks - stats.emptyOpenTasks : stats.openTasks } diff --git a/packages/app-core/src/lib/calendar-widget-data.test.ts b/packages/app-core/src/lib/calendar-widget-data.test.ts index 9688d5ac7..ffa79b402 100644 --- a/packages/app-core/src/lib/calendar-widget-data.test.ts +++ b/packages/app-core/src/lib/calendar-widget-data.test.ts @@ -119,6 +119,30 @@ describe('getCalendarWidgetData', () => { expect(numbered).toMatchObject({ line: 3, toggled: '1. [x] Numbered due:2026-10-28' }) }) + // The widget draws what the Calendar sheet draws, so a daily-note + // template's bare `- [ ]` stays out of it on the same switch (#1026). + it('leaves empty tasks out of the marks and the agenda while Hide empty tasks is on', async () => { + const today = dailyPath(NOW) + const yesterday = dailyPath(new Date(2026, 9, 26)) + files.set(today, '- [ ]\n- [ ] Call the bank\n') + files.set(yesterday, '- [ ]\n') + load() + useStore.setState({ hideEmptyTasks: true }) + let data = await getCalendarWidgetData(NOW) + expect(data.days['2026-10-27'].openTasks).toBe(1) + expect(data.days['2026-10-26'].openTasks).toBe(0) + expect(data.agenda['2026-10-27'].total).toBe(1) + expect(data.agenda['2026-10-27'].tasks.map((t) => t.content)).toEqual(['Call the bank']) + expect(data.agenda['2026-10-26']).toBeUndefined() + + useStore.setState({ hideEmptyTasks: false }) + data = await getCalendarWidgetData(NOW) + expect(data.days['2026-10-27'].openTasks).toBe(2) + expect(data.days['2026-10-26'].openTasks).toBe(1) + expect(data.agenda['2026-10-27'].total).toBe(2) + expect(data.agenda['2026-10-26'].total).toBe(1) + }) + it('caps a busy day but keeps its total', async () => { files.set('inbox/Lots.md', Array.from({ length: 5 }, (_, i) => `- [ ] T${i} due:2026-10-29`).join('\n')) load() diff --git a/packages/app-core/src/lib/calendar-widget-data.ts b/packages/app-core/src/lib/calendar-widget-data.ts index c22ca89f4..95f2cf685 100644 --- a/packages/app-core/src/lib/calendar-widget-data.ts +++ b/packages/app-core/src/lib/calendar-widget-data.ts @@ -33,8 +33,10 @@ import { isoDateStr, isoWeekStr, measureNote, + shownOpenTasks, type NoteStats, } from './calendar-data' +import { withoutEmptyTasks } from './empty-tasks' /** A grid mark: the day's (or week's) note and how much is in it. */ export interface CalendarWidgetMark { @@ -113,9 +115,9 @@ async function noteStats(note: NoteMeta): Promise<NoteStats | undefined> { return measured } -function markFor(measured: NoteStats | undefined): CalendarWidgetMark { +function markFor(measured: NoteStats | undefined, hideEmpty: boolean): CalendarWidgetMark { const { count, faint } = dotsFor(measured) - return { dots: count, faint, openTasks: measured?.openTasks ?? 0 } + return { dots: count, faint, openTasks: shownOpenTasks(measured, hideEmpty) } } function widgetTask(task: VaultTask, dailyPath: string | undefined): CalendarWidgetTask { @@ -161,20 +163,22 @@ export async function getCalendarWidgetData( const iso = isoDateStr(d) dayIsos.push(iso) const daily = settings.dailyNotes.enabled ? dailyByDate.get(iso) : undefined - if (daily) days[iso] = markFor(await noteStats(daily)) + if (daily) days[iso] = markFor(await noteStats(daily), state.hideEmptyTasks) const week = isoWeekStr(d) const weekly = settings.weeklyNotes.enabled ? weeklyByWeek.get(week) : undefined - if (weekly && !weeks[week]) weeks[week] = markFor(await noteStats(weekly)) + if (weekly && !weeks[week]) weeks[week] = markFor(await noteStats(weekly), state.hideEmptyTasks) } // The agenda is the panel's: open tasks by due date, with daily-note tasks - // inheriting the note's date. Tasks of a note that left the index (trashed, - // deleted from the drawer) can linger until the next full scan; skip them. + // inheriting the note's date, and empty ones out on the same switch + // (#1026). Tasks of a note that left the index (trashed, deleted from the + // drawer) can linger until the next full scan; skip them. const live = new Set(state.notes.filter((n) => n.folder !== 'trash').map((n) => n.path)) + const listed = filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks).filter((t) => + live.has(t.sourcePath) + ) const inferred = inferDailyTaskDueDates( - filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks).filter((t) => - live.has(t.sourcePath) - ), + state.hideEmptyTasks ? withoutEmptyTasks(listed).shown : listed, buildDailyNoteDateByPath(state.notes, state.vaultSettings) ) const byDay = new Map<string, VaultTask[]>() diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 3905860dd..51c5aa2fb 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1914,7 +1914,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }) } - // Empty tasks (#1026), from any note: the list, calendar and board follow it. + // Empty tasks (#1026), from any note: the Tasks views, the Calendar and + // Timeline panels and Home all follow it. cmds.push({ id: 'view.hide-empty-tasks', bindTitle: 'Tasks: Toggle Empty Tasks', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 703a3a0db..19bcb9f20 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -392,7 +392,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Empty tasks stay out of the Tasks views', body: - 'A bare `- [ ]`, like the ones a daily-note template leaves, is an empty task, and the Tasks list, calendar, and board leave it out. A quiet “3 hidden” beside the **View** button says how many; click it to show them. A task with any text always shows, even just `due:2026-11-02`. Turn it off in the View menu (`v`, then Hide empty tasks), with `E` in Vim mode, or in Settings → Tasks. Config key: `hide_empty_tasks` under `[view]`.' + 'A bare `- [ ]`, like the ones a daily-note template leaves, is an empty task, and the Tasks views, the Calendar and Timeline panels, and the home view leave it out. A quiet “3 hidden” beside the **View** button says how many; click it to show them. A task with any text always shows, even just `due:2026-11-02`. Turn it off in the View menu (`v`, then Hide empty tasks), with `E` in Vim mode, or in Settings → Tasks. Config key: `hide_empty_tasks` under `[view]`.' }, { title: 'WIP limits on Kanban columns', @@ -822,7 +822,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'x / d', action: 'Delete forever', detail: 'Trash view only: permanently delete the selected trashed note after confirmation.' }, { keys: '/', action: 'Filter the view', detail: 'Focus the local filter box for tasks, tag matches, or trashed notes. In Tags, a word from a tagged block narrows the list to that block.' }, { keys: 'F', action: 'Pick a saved Tasks filter', detail: 'Tasks view: open the list of saved filters and apply one. Save the current query with the Save filter… chip or `:savefilter <name>`.' }, - { keys: 'E', action: 'Show or hide empty tasks', detail: 'Tasks view: show or hide bare `- [ ]` tasks in the list, calendar, and board.' }, + { keys: 'E', action: 'Show or hide empty tasks', detail: 'Tasks view: show or hide bare `- [ ]` tasks in the Tasks views, the Calendar and Timeline panels, and the home view.' }, { keys: 'v', action: 'View menu', detail: 'Tasks view: open the View menu (Hide empty tasks, and the board\'s Lanes).' }, { keys: '?', action: 'Keys', detail: 'Tasks view: list the current view’s keys. Esc closes it.' }, { keys: 'W', action: 'Set a WIP limit', detail: 'Tasks board only: set the focused column’s WIP limit. Same as `:wip <limit>`.' }, @@ -1414,7 +1414,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ title: 'Tasks', items: [ { label: 'Show tasks from archived notes', detail: 'Off by default: archiving a note retires its tasks from the Tasks list, calendar, and Kanban. Turn this on to keep them visible everywhere, Archive column included. The raw key is `show_archived_tasks` in `config.toml`.' }, - { label: 'Hide empty tasks', detail: 'On by default: bare `- [ ]` tasks stay out of the Tasks views. Same as the View menu; config key `hide_empty_tasks`.' }, + { label: 'Hide empty tasks', detail: 'On by default: bare `- [ ]` tasks stay out of the Tasks views, the Calendar and Timeline panels, and the home view. Same as the View menu; config key `hide_empty_tasks`.' }, { label: 'Kanban lanes', detail: 'Splits the Kanban board into rows by Priority, Folder, or Tag; an `@field` picked on the board shows here too. Same as Lanes in the View menu; config key `kanban_lanes`.' }, { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' }, { label: 'Tasks list order', detail: 'Note order keeps each group of the Tasks list in its notes’ line order, which Shift+J/K and dragging rearrange; Priority puts high priority first, then medium, low, and none, ties by due date. The list’s Order menu sets the same thing, and the raw key is `tasks_list_order` (`note` or `priority`) under `[view]` in `config.toml`.' }, diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 1706bf414..546eaf707 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -56,7 +56,7 @@ export const WHATS_NEW: readonly WhatsNewEntry[] = [ }, { title: 'Quieter Tasks views', - body: 'Empty - [ ] placeholders stay out of the list, calendar and board. Options live in one View menu, and Keys lists the shortcuts.' + body: 'Empty - [ ] placeholders stay out of the Tasks views, the Calendar and Timeline panels and the home view. Options live in one View menu, and Keys lists the shortcuts.' }, { title: 'A template folder', diff --git a/packages/app-core/src/public-host-api.test.ts b/packages/app-core/src/public-host-api.test.ts index e6d3dc92a..d8395a7af 100644 --- a/packages/app-core/src/public-host-api.test.ts +++ b/packages/app-core/src/public-host-api.test.ts @@ -45,6 +45,29 @@ describe('public host APIs', () => { const today = new Date() expect(changes).toContainEqual({ kind: 'set-due', due: `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}` }) }) + it("leaves empty tasks out of Today, the phones' Today widget, on the Tasks views' switch (#1026)", async () => { + const s = await setup() + const iso = (offset: number): string => { + const d = new Date(); d.setDate(d.getDate() + offset) + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` + } + const at = (title: string, content: string, due: string): VaultTask => ({ ...task(), id: `inbox/${title}.md#0`, + sourcePath: `inbox/${title}.md`, noteTitle: title, rawText: `- [ ] ${content}`.trimEnd(), content, + inProgress: false, tags: [], fields: undefined, due }) + const vaultTasks = [at('Real', 'Call the bank', iso(0)), at('Late', 'Renew the passport', iso(-2)), + at('Blank', '', iso(0)), at('BlankLate', '', iso(-1))] + const notes = vaultTasks.map((t) => ({ path: t.sourcePath, title: t.noteTitle, folder: 'inbox' as const, + siblingOrder: 0, createdAt: 0, updatedAt: 0, size: 0, tags: [], wikilinks: [], hasAttachments: false, + assetEmbeds: [], excerpt: '' })) + s.useStore.setState({ notes, vaultTasks, hideEmptyTasks: true }) + let today = s.tasks.getTodayTasks() + expect(today.tasks.map((t) => t.noteTitle).sort()).toEqual(['Late', 'Real']) + expect(today.overdueCount).toBe(1) + s.useStore.setState({ hideEmptyTasks: false }) + today = s.tasks.getTodayTasks() + expect(today.tasks).toHaveLength(4) + expect(today.overdueCount).toBe(2) + }) it('exposes profile display fields without retaining credentials or mutable entries', async () => { const s = await setup() const profile = { id: 'one', name: 'Private server', baseUrl: 'https://example.test', vaultPath: null, diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 65ff09d8e..fed13ca3e 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -862,8 +862,8 @@ interface Prefs { * due date and leaves that arrangement untouched for later. (#889) */ kanbanCardSort: KanbanCardSort /** Leave tasks with no text (a template's bare `- [ ]`) off the Tasks - * list, calendar and Kanban board. On by default; portable as - * `hide_empty_tasks` under [view]. (#1026) */ + * list, calendar and Kanban board, the Calendar and Timeline panels and + * Home. On by default; portable as `hide_empty_tasks` under [view]. (#1026) */ hideEmptyTasks: boolean /** Kanban WIP limits: the most cards a column should hold, keyed by * `${groupBy}:${columnId}` like the column titles. Portable as the @@ -3616,7 +3616,8 @@ interface Store { kanbanGroupBy: KanbanGroupBy /** Cards in each Kanban column by hand-placed order or by due date. (#889) */ kanbanCardSort: KanbanCardSort - /** Tasks with no text stay off the Tasks list, calendar and board. (#1026) */ + /** Tasks with no text stay off the Tasks views, the Calendar and Timeline + * panels and Home. (#1026) */ hideEmptyTasks: boolean /** Per-column Kanban WIP limits, keyed `${groupBy}:${columnId}`. (#1025) */ kanbanWipLimits: Record<string, number> diff --git a/packages/app-core/src/tasks.ts b/packages/app-core/src/tasks.ts index 4229599fb..2da916733 100644 --- a/packages/app-core/src/tasks.ts +++ b/packages/app-core/src/tasks.ts @@ -7,6 +7,7 @@ import { TASK_LINE_RE } from '@shared/tasklists' import { computeTasksRender } from './lib/tasks-filter' import { buildDailyNoteDateByPath } from './lib/vault-layout' import { dropMutationsFor } from './lib/task-column-mutations' +import { withoutEmptyTasks } from './lib/empty-tasks' export type { KanbanGroupBy } from './store' export interface TaskActionHost { isCurrent(): boolean } @@ -152,12 +153,14 @@ export async function setTaskChecked(id: string, raw: string, checked: boolean): return now && lineShape(now.rawText) === shape && now.checked === checked ? 'applied' : 'retry' } -/** Today/overdue groups use core's filtering and file order, including file tasks. */ +/** Today/overdue groups use core's filtering and file order, including file + * tasks. Home's Today, so empty tasks stay out on the same switch (#1026). */ export function getTodayTasks(now = new Date()): { readonly tasks: readonly TaskSnapshot[]; readonly overdueCount: number } { const state = useStore.getState() const livePaths = new Set(state.notes.filter(note => note.folder !== 'trash').map(note => note.path)) const live = filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks).filter(task => livePaths.has(task.sourcePath)) - const dated = inferDailyTaskDueDates(live, buildDailyNoteDateByPath(state.notes, state.vaultSettings)) + const shown = state.hideEmptyTasks ? withoutEmptyTasks(live).shown : live + const dated = inferDailyTaskDueDates(shown, buildDailyNoteDateByPath(state.notes, state.vaultSettings)) const render = computeTasksRender(dated, '', now, { today: false, upcoming: false, undated: false, waiting: false, forwarded: false, done: false, cancelled: false }) const publicTasks = new Map(getTasksSnapshot().tasks.map(task => [task.id, task])) return Object.freeze({ tasks: Object.freeze(render.groups.today.map(task => publicTasks.get(task.id)!)), overdueCount: render.groups.overdueCount ?? 0 }) From 4fcf91962a82e7f8abe0f897461fa69e1f3e4da9 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 14:25:52 -0500 Subject: [PATCH 51/53] Fix(tasks): `a` on the calendar always adds to the selected day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With daily notes on, the Tasks calendar has a box under the month for adding a task to the selected day, and its key sheet names `a` for it ("add to day"). The Tasks view also answers `a`, with New task, in all three sub-views, and its button's tooltip said so ("New task (a)"). Which one a press reached depended on which window listener registered last: the calendar registers again on every day it moves to, so on the same day the same key opened New task after `l` then `h`, and the day's box after a fresh open of the Tasks tab. Driven over CDP on a built app before the change, seven presses on one day gave New task three times and the day's box four times. The calendar's own `a` wins, as the board's own keys already win over New task (#1027): on the calendar with daily notes on, the Tasks view leaves `a` to it, and the New task tooltip drops "(a)" there. Without daily notes there is no day to add to, so `a` stays New task on the calendar too, and the key sheet now lists "a add to day" only while daily notes are on. With Vim mode off `a` does nothing on either, as before. The manual's New task paragraph names the calendar's `a`. Tests, red first: TasksView-calendar-keys.test.ts presses `a` on the calendar with daily notes on, then `l`, `h`, `a`, and wants the day's box focused both times and New task never called; it failed before the change. With daily notes off `a` still opens New task. In tasks-keys.test.ts the calendar sheet names `a` only with daily notes on. Driven over CDP on the built app with both stores isolated, daily notes on: `a` focused "Add a task for today…" on all seven presses (right after switching to the calendar, again, after `l h`, after closing and reopening the Tasks tab, again, after `l h`, after List then Calendar), the tooltip read "New task" there and "New task (a)" on the list, where `a` still opened New task, and the sheet showed "a add to day". With daily notes off, `a` after `l h` opened New task and the sheet had no `a`; with Vim off, `a` did nothing. UI copy: the "+ New task" button's tooltip is "New task" (no key) on the calendar while daily notes are on; elsewhere it is unchanged. How to test locally: build (`cd apps/desktop && npx electron-vite build`), launch with Vim mode on and daily notes on (Settings, Daily notes), open Tasks and press 2 for the calendar. Press l, h, then a. Before: the New task prompt opens. After: the cursor lands in "Add a task for today…". --- .../app-core/src/components/TasksToolbar.tsx | 3 +- .../TasksView-calendar-keys.test.ts | 109 +++++++++++------- .../app-core/src/components/TasksView.tsx | 13 ++- packages/app-core/src/lib/help.ts | 2 +- packages/app-core/src/lib/tasks-keys.test.ts | 8 ++ packages/app-core/src/lib/tasks-keys.ts | 7 +- 6 files changed, 96 insertions(+), 46 deletions(-) diff --git a/packages/app-core/src/components/TasksToolbar.tsx b/packages/app-core/src/components/TasksToolbar.tsx index d73a2697a..5d72f36cf 100644 --- a/packages/app-core/src/components/TasksToolbar.tsx +++ b/packages/app-core/src/components/TasksToolbar.tsx @@ -188,6 +188,7 @@ export function TasksKeysButton({ }): JSX.Element { const vimMode = useStore((s) => s.vimMode) const listOrder = useStore((s) => s.tasksListOrder) + const dayAdd = useStore((s) => s.vaultSettings.dailyNotes.enabled) const buttonRef = useRef<HTMLButtonElement | null>(null) const panelRef = useRef<HTMLDivElement | null>(null) const previousFocusRef = useRef<HTMLElement | null>(null) @@ -233,7 +234,7 @@ export function TasksKeysButton({ } }, [anchor, close]) - const sheet = tasksKeySheet(view, { vimMode, dndEnabled, listOrder, subtasks, lanes, laneMoves }) + const sheet = tasksKeySheet(view, { vimMode, dndEnabled, listOrder, subtasks, lanes, laneMoves, dayAdd }) return ( <> diff --git a/packages/app-core/src/components/TasksView-calendar-keys.test.ts b/packages/app-core/src/components/TasksView-calendar-keys.test.ts index cc0866df7..2a7bcd6a7 100644 --- a/packages/app-core/src/components/TasksView-calendar-keys.test.ts +++ b/packages/app-core/src/components/TasksView-calendar-keys.test.ts @@ -23,48 +23,53 @@ function press(key: string): void { }) } -describe('Tasks calendar keys while a task is picked up', () => { - let host: HTMLDivElement - let root: Root - let originalState: ReturnType<typeof useStore.getState> - const newTaskFile = vi.fn(async () => null) - const setHideEmptyTasks = vi.fn() +let host: HTMLDivElement +let root: Root +let originalState: ReturnType<typeof useStore.getState> +const newTaskFile = vi.fn(async () => null) +const setHideEmptyTasks = vi.fn() - beforeEach(() => { - originalState = useStore.getState() - Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) - const leaf = makeLeaf([TASKS_TAB_PATH], TASKS_TAB_PATH) - const tasks = parseTasksFromBody(`- [ ] Pay rent due:${isoToday()}\n- [ ] Call the bank due:${isoToday()}\n`, { - path: 'Calendar.md', - title: 'Calendar', - folder: 'inbox' - }) - useStore.setState({ - vimMode: true, - paneLayout: leaf, - activePaneId: leaf.id, - focusedPanel: 'tasks', - tasksViewMode: 'calendar', - vaultTasks: tasks, - tasksLoading: false, - tasksFilter: '', - refreshTasks: vi.fn(async () => {}), - newTaskFile, - setHideEmptyTasks - }) - newTaskFile.mockClear() - setHideEmptyTasks.mockClear() - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) - act(() => root.render(createElement(TasksView))) +/** The Tasks view on the calendar, Vim on, two tasks due today. */ +function mount(dailyNotes: boolean): void { + originalState = useStore.getState() + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + const leaf = makeLeaf([TASKS_TAB_PATH], TASKS_TAB_PATH) + const tasks = parseTasksFromBody(`- [ ] Pay rent due:${isoToday()}\n- [ ] Call the bank due:${isoToday()}\n`, { + path: 'Calendar.md', + title: 'Calendar', + folder: 'inbox' }) - - afterEach(() => { - act(() => root.unmount()) - host.remove() - useStore.setState(originalState, true) + const settings = originalState.vaultSettings + useStore.setState({ + vimMode: true, + paneLayout: leaf, + activePaneId: leaf.id, + focusedPanel: 'tasks', + tasksViewMode: 'calendar', + vaultTasks: tasks, + tasksLoading: false, + tasksFilter: '', + vaultSettings: { ...settings, dailyNotes: { ...settings.dailyNotes, enabled: dailyNotes } }, + refreshTasks: vi.fn(async () => {}), + newTaskFile, + setHideEmptyTasks }) + newTaskFile.mockClear() + setHideEmptyTasks.mockClear() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + act(() => root.render(createElement(TasksView))) +} + +afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) +}) + +describe('Tasks calendar keys while a task is picked up', () => { + beforeEach(() => mount(false)) const moving = (): boolean => /Moving “Pay rent”/.test(host.textContent ?? '') @@ -90,3 +95,29 @@ describe('Tasks calendar keys while a task is picked up', () => { expect(useStore.getState().tasksFilter).toBe('') }) }) + +describe('the Tasks calendar’s `a`', () => { + const quickAddFocused = (): boolean => + /^Add a task for/.test((document.activeElement as HTMLElement | null)?.getAttribute('placeholder') ?? '') + + it('adds to the selected day with daily notes on, however the handlers registered', async () => { + mount(true) + press('a') + await vi.waitFor(() => expect(quickAddFocused()).toBe(true)) + act(() => (document.activeElement as HTMLElement).blur()) + // A day there and back registers the calendar's handler again, after the + // Tasks view's: the order that used to hand `a` to New task. + press('l') + press('h') + press('a') + await vi.waitFor(() => expect(quickAddFocused()).toBe(true)) + expect(newTaskFile).not.toHaveBeenCalled() + }) + + it('opens New task with daily notes off, where there is no day to add to', () => { + mount(false) + press('l') + press('a') + expect(newTaskFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 78e0c0b2d..9267da618 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -141,6 +141,9 @@ export function TasksView(): JSX.Element { const setTasksListOrder = useStore((s) => s.setTasksListOrder) const viewMode = useStore((s) => s.tasksViewMode) const setViewMode = useStore((s) => s.setTasksViewMode) + // With daily notes on, the calendar's `a` adds a task to the selected day + // (its key sheet's "add to day"), so New task leaves `a` to it there. + const calendarAddsToDay = viewMode === 'calendar' && vaultSettings.dailyNotes.enabled // Only the Tasks panel in the *active* pane should listen for j/k/etc. // Splits can show Tasks in multiple panes simultaneously; without this // gate every keypress would fire once per mounted panel. @@ -777,8 +780,11 @@ export function TasksView(): JSX.Element { // Quick-add a new task file. View-independent (works in list/calendar/ // kanban). A single-key shortcut, so it's gated on Vim mode like the rest; - // with Vim off, the header "+ New task" button is the way in. - if (vimMode && key === 'a') { + // with Vim off, the header "+ New task" button is the way in. Not on a + // calendar that adds to its day: this handler runs first whenever the + // calendar registered after it (every day it moves to), and took `a` + // for New task there, while a fresh open left it to the calendar. + if (vimMode && key === 'a' && !calendarAddsToDay) { consume() void newTaskFile() return @@ -941,6 +947,7 @@ export function TasksView(): JSX.Element { setFilter, viewMode, setViewMode, + calendarAddsToDay, newTaskFile, pickSavedFilter, listOrder, @@ -1027,7 +1034,7 @@ export function TasksView(): JSX.Element { type="button" onClick={() => void newTaskFile()} className="shrink-0 rounded-md border border-accent/45 bg-accent/10 px-2 py-1 text-xs font-medium text-accent hover:bg-accent/20" - title={vimMode ? 'New task (a)' : 'New task'} + title={vimMode && !calendarAddsToDay ? 'New task (a)' : 'New task'} > + New task </button> diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 05ceb65d0..4b1f37751 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -367,7 +367,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'A whole note can be a task (task files)', body: - 'Besides inline `- [ ]` checkboxes, a whole note can itself be a task: give its frontmatter a `task` tag (`tags: [task]`) and its metadata lives in frontmatter, including `status` (open / in-progress / done…), `priority` (high / normal / low), `due` and `scheduled` (`YYYY-MM-DD`), plus any `tags`, while the note body holds free-form detail or sub-checkboxes. These "task files" show up in the Tasks views right alongside inline tasks, so both styles live in one vault. This is the TaskNotes convention, so a vault stays interoperable with TaskForge and Obsidian. Quick-add one from the command palette (**New Task**, or **New Task in Folder…** to choose where it lands), the "+ New task" button in the Tasks header, the `a` key (Vim mode), or the `:newtask` / `:task` ex command; `:newtask Projects/Website` drops it straight into a folder so multiple projects stay organized. New task files default to your configured tasks location (Settings → Vault → Notes → **Default tasks location**, or search Settings for "tasks folder"). That is your primary notes area unless you pick the active note’s folder or a specific folder, and a specific folder lives inside the primary notes area: `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault, as the setting itself shows. The folder-picking options override it per task. Checking a task file off rewrites its frontmatter (`status: done` and a `completedDate`) rather than a checkbox character, and rescheduling from the calendar or changing its column on the Kanban board updates the matching frontmatter field. Delete on a task file trashes the whole note (after a confirm).' + 'Besides inline `- [ ]` checkboxes, a whole note can itself be a task: give its frontmatter a `task` tag (`tags: [task]`) and its metadata lives in frontmatter, including `status` (open / in-progress / done…), `priority` (high / normal / low), `due` and `scheduled` (`YYYY-MM-DD`), plus any `tags`, while the note body holds free-form detail or sub-checkboxes. These "task files" show up in the Tasks views right alongside inline tasks, so both styles live in one vault. This is the TaskNotes convention, so a vault stays interoperable with TaskForge and Obsidian. Quick-add one from the command palette (**New Task**, or **New Task in Folder…** to choose where it lands), the "+ New task" button in the Tasks header, the `a` key (Vim mode; on the Tasks calendar with daily notes on, `a` adds a task to the selected day instead), or the `:newtask` / `:task` ex command; `:newtask Projects/Website` drops it straight into a folder so multiple projects stay organized. New task files default to your configured tasks location (Settings → Vault → Notes → **Default tasks location**, or search Settings for "tasks folder"). That is your primary notes area unless you pick the active note’s folder or a specific folder, and a specific folder lives inside the primary notes area: `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault, as the setting itself shows. The folder-picking options override it per task. Checking a task file off rewrites its frontmatter (`status: done` and a `completedDate`) rather than a checkbox character, and rescheduling from the calendar or changing its column on the Kanban board updates the matching frontmatter field. Delete on a task file trashes the whole note (after a confirm).' }, { title: 'The Tasks calendar schedules and reschedules', diff --git a/packages/app-core/src/lib/tasks-keys.test.ts b/packages/app-core/src/lib/tasks-keys.test.ts index 5fd6e5aa6..8990cc82b 100644 --- a/packages/app-core/src/lib/tasks-keys.test.ts +++ b/packages/app-core/src/lib/tasks-keys.test.ts @@ -27,6 +27,14 @@ describe('the Tasks keys cheat sheet', () => { }) } + it('names the calendar’s `a` only where there is a day to add to (daily notes on)', () => { + const calendar = (dayAdd: boolean) => + keysOf(tasksKeySheet('calendar', { vimMode: true, dndEnabled: true, listOrder: 'note', dayAdd })) + expect(calendar(true)).toContain('a') + // Without daily notes `a` is New task, named by its button's tooltip. + expect(calendar(false)).not.toContain('a') + }) + it('lists the board keys with Vim mode on, the WIP limit among them', () => { const keys = keysOf(tasksKeySheet('kanban', { vimMode: true, dndEnabled: true, listOrder: 'note' })) for (const key of ['h / l', 'j / k', 'x', 'Enter', 'Shift+H / L', 'm', '< / >', 'W', 'g', 's', 'v', 'E', '?']) { diff --git a/packages/app-core/src/lib/tasks-keys.ts b/packages/app-core/src/lib/tasks-keys.ts index 124a9d676..256299683 100644 --- a/packages/app-core/src/lib/tasks-keys.ts +++ b/packages/app-core/src/lib/tasks-keys.ts @@ -43,6 +43,9 @@ export interface TasksKeySheetOptions { lanes?: boolean /** A card can move to another lane (every lane field but Folder). */ laneMoves?: boolean + /** The calendar adds a task to the selected day (daily notes on): its `a`. + * Without it `a` is New task, which its button's tooltip names. */ + dayAdd?: boolean } /** The board's Subtasks group, shown once `subtasks` is on. Tab and Esc are @@ -215,7 +218,7 @@ function listSheet({ vimMode, listOrder }: TasksKeySheetOptions): TasksKeySheet } } -function calendarSheet({ vimMode }: TasksKeySheetOptions): TasksKeySheet { +function calendarSheet({ vimMode, dayAdd }: TasksKeySheetOptions): TasksKeySheet { const mouse = 'Drag to another day · right-click for actions' if (!vimMode) { return { @@ -269,7 +272,7 @@ function calendarSheet({ vimMode }: TasksKeySheetOptions): TasksKeySheet { { keys: '< / >', label: 'due ±1 day' }, { keys: 'T', label: 'due today' }, { keys: '@', label: 'due date' }, - { keys: 'a', label: 'add to day' } + ...(dayAdd ? [{ keys: 'a', label: 'add to day' }] : []) ] }, { title: 'Calendar', rows: viewRows(null) } From 8c23f3df9bbda564897af1a91b8c0627bd14e544 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 14:36:40 -0500 Subject: [PATCH 52/53] Release: align desktop and shared packages at 2.80.0 --- 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 064bf8d7e..7c13d5545 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.79.0", + "version": "2.80.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 3af5c9bb7..3d355b546 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.79.0", + "version": "2.80.0", "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 a2a8f85fc..65451307b 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.79.0", + "version": "2.80.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 0eb655233..698011d0d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.79.0", + "version": "2.80.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 583ff18b1..124d59aec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.79.0", + "version": "2.80.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.79.0", + "version": "2.80.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.79.0", + "version": "2.80.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.79.0", + "version": "2.80.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.79.0", + "version": "2.80.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.79.0", + "version": "2.80.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16396,7 +16396,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.79.0", + "version": "2.80.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16485,14 +16485,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.79.0", + "version": "2.80.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.79.0", + "version": "2.80.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16506,7 +16506,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.79.0" + "version": "2.80.0" } } } diff --git a/package.json b/package.json index 0c185374f..13085d88b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.79.0", + "version": "2.80.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 3edb68a98..7a9ff060c 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.79.0", + "version": "2.80.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index dbd6a5460..74f50ccb0 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.79.0", + "version": "2.80.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 57ceb83bd..749db4b7c 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.79.0", + "version": "2.80.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 41b4617aa..4e1b22d2d 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.79.0", + "version": "2.80.0", "type": "module", "exports": { ".": "./src/index.ts" From 31520814a09c7cd02538d63993196a39bd3986f8 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Fri, 30 Oct 2026 14:41:53 -0500 Subject: [PATCH 53/53] Release(cli): bundle the verified 0.13.1 terminal release The bundled CLI is the floor a desktop build guarantees, and 0.13.1 is the newest release (ZenNotes/tui#25). It leaves the vault's template folder out of zn's Tasks, text search and tags, the same rules this app follows since 8b42b542 and 38a20451: a template's checkboxes are not work, its notes are not search results, and its tags are not the vault's. Title search and tag renames still reach templates. The pin is the release's terminal-release.json copied byte for byte after it verified against the zn-release-1 key this app trusts (cli-update-core.ts), naming source commit 2df43e93 (the v0.13.1 tag), integration protocol 1, and four archives that match both the manifest and checksums.txt. Staged through `npm run terminal:stage`; the native darwin-arm64 binary reports zn v0.13.1. The in-app manual's CLI card and the 2.80.0 What's New say 0.13.1. --- apps/desktop/terminal-release.json | 20 ++++++++++---------- packages/app-core/src/lib/help.ts | 4 ++-- packages/app-core/src/lib/whats-new.ts | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index c1e46fbfc..5bbf9ede0 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.13.0", - "commit": "cae76aff84741cd6208c58a3a94fea90df4ac771", + "version": "0.13.1", + "commit": "2df43e938cd5567ba3a759cc5f81c08c9eca496d", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_darwin_arm64.tar.gz", - "sha256": "7f36d799066abba575d58cd1821a9cfbdd997003203de28aa884d92692453c50" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_darwin_arm64.tar.gz", + "sha256": "f3c9f9ec75e93a7ce975e8acb099cec5c4889f38ab2e6a39a2159c7132e62d37" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_darwin_amd64.tar.gz", - "sha256": "28ff881df056a7911674162e0f9739ce1b0405623b694746adf0619229bb9ff8" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_darwin_amd64.tar.gz", + "sha256": "3aac2fb65558760a861a10a9caa8eab892d34cbdcf52230e83aa80ba6737ef04" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_linux_arm64.tar.gz", - "sha256": "848163c70d7aa8228936e8ee876e858af4fba67d62e4d1909a985e9f8e5da730" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_linux_arm64.tar.gz", + "sha256": "90c3aa7463a1de2d106b31b20474ac753d4c4747a52539a753c2973b492cf448" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.0/zn_0.13.0_linux_amd64.tar.gz", - "sha256": "01ec73fdd4637490bd9d053f103bc49fe18e6657ac1945737b80ed0504032b74" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.13.1/zn_0.13.1_linux_amd64.tar.gz", + "sha256": "3ea08b4728c36384e8ccb008e29daa8b7ebfeb4c5264497966d41039fb326aa1" } } } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 5cd4d6b1f..87cbda780 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1465,9 +1465,9 @@ export const HELP_CLI: HelpCard[] = [ 'Builds with the Go terminal tool include `zn tui`. An existing desktop-managed CLI keeps itself up to date (see Updates arrive without a ZenNotes update); keep using the same `zn` commands. Settings shows the installed terminal version and offers Repair if an upgrade needs attention. Desktop-installed commands keep following the desktop vault, while the TUI remembers its own selection. Explicit `--vault` and `--server` flags still win. Set `ZENNOTES_WORKSPACE_SOURCE=terminal` to use the terminal default for a command, or `ZENNOTES_CLI_ENGINE=legacy` to run the previous CLI during the transition. Homebrew and manual installations stay managed by their own installer. A shortcut left behind by a moved Mac app or an old AppImage can be repaired from Settings: review the old target, replacement and backup path before choosing Repair shortcut. Note saves preserve creation dates in small files under `.zennotes/note-metadata` without changing Markdown; keep the `.zennotes` folder with vault backups. Explicit legacy rollback needs the original app resources to remain available.' }, { - title: 'CLI 0.13.0 in desktop 2.80.0', + title: 'CLI 0.13.1 in desktop 2.80.0', body: - 'On macOS and Linux, desktop 2.80.0 bundles CLI 0.13.0. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. 0.9.1 keeps a wikilink of more than one word from widening the TUI preview’s row and scrolling the whole screen. 0.9.2 lets a second `$` on a wrapped line reach the end of the line and makes three backticks a code fence, 0.10.0 adds `zn base pages`, which gives every database record its record page so each can be linked, and 0.11.0 gives a task without a due date its own No due date group and Status board column, as this app does (#990), and orders the task list by priority with `O` or `:order priority` (#989). 0.12.0 can draw display math and TikZ in the TUI preview with `[terminal] math = "latex"`, using TeX installed on your computer (zn does not include TeX; `zn doctor` prints the install commands), 0.12.1 fits pictures in the preview to the pane as it grows or shrinks, 0.12.2 says which TeX tool is missing and how to install it, and 0.13.0 lets `zn mcp` share only part of a vault (`--allow-path`, `--deny-path`, `--read-only`, `--access`; see Share part of a vault with an assistant), keeps assistants out of hidden and system folders, and puts a row `zn base add` makes into the picked rows of the view the database opens on. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' + 'On macOS and Linux, desktop 2.80.0 bundles CLI 0.13.1. Run `zn` in an interactive terminal or `zn tui` to open the editor. It includes editable settings, restored splits and sessions, comments, attachments, templates, bulk actions, and cross-note yank and paste. Use `:version` in the TUI for a persistent report and press `c` or `y` to copy it. `zn status`, `zn doctor`, `zn config`, and `zn completion` help with setup and scripts; `zn server` manages native local servers, also available through `:servers`. Since 0.6.1, `zn vault list` marks each entry `app` (saved by this desktop app) or `terminal` (saved by zn itself) and says which of them `zn use`, `zn disconnect` and `zn vault remove` act on; every command starts without waiting on terminals that do not answer a background-color query; and the help output lines up. 0.6.2 is the first release with a signed manifest, which is how this app keeps its managed zn current, 0.6.3 reads a line over a lone `-` as a paragraph, as the app does (#898), and 0.7.0’s `zn mcp` records an assistant’s edits for review in this app and no longer lets an assistant write into `.zennotes`. 0.7.1 leaves cancelled tasks out of the open ones in `zn task list` and in `zn mcp`’s `list_tasks` (#929), and when a server needs a token, its message points to Settings → MCP → Server token. 0.7.2 reads tasks in numbered lists and quotes the way the app does, in `zn task` and in `zn mcp`, and 0.8.0 adds `zn task watch`, which prints each task status change as it happens, and gives each task’s status in `zn task list --json` and in `list_tasks`. 0.8.1 keeps a finished task note in its Custom status column and gives `zn mcp` this app’s assistant instructions again, and 0.9.0 follows the vault’s Task folders in `zn task list`, `zn task watch` and `zn mcp`, and the TUI’s Settings saves both Tasks folder lists where every app reads them. 0.9.1 keeps a wikilink of more than one word from widening the TUI preview’s row and scrolling the whole screen. 0.9.2 lets a second `$` on a wrapped line reach the end of the line and makes three backticks a code fence, 0.10.0 adds `zn base pages`, which gives every database record its record page so each can be linked, and 0.11.0 gives a task without a due date its own No due date group and Status board column, as this app does (#990), and orders the task list by priority with `O` or `:order priority` (#989). 0.12.0 can draw display math and TikZ in the TUI preview with `[terminal] math = "latex"`, using TeX installed on your computer (zn does not include TeX; `zn doctor` prints the install commands), 0.12.1 fits pictures in the preview to the pane as it grows or shrinks, 0.12.2 says which TeX tool is missing and how to install it, and 0.13.0 lets `zn mcp` share only part of a vault (`--allow-path`, `--deny-path`, `--read-only`, `--access`; see Share part of a vault with an assistant), keeps assistants out of hidden and system folders, and puts a row `zn base add` makes into the picked rows of the view the database opens on; 0.13.1 leaves the template folder out of tasks, search and tags, as the app does. Run `zn update --check` to see the latest CLI release; for a desktop-managed CLI, ZenNotes installs it. The full command reference is at https://zennotes.org/tui/docs.' }, { title: 'Updates arrive without a ZenNotes update', diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 546eaf707..fdbdbf44f 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -64,7 +64,7 @@ export const WHATS_NEW: readonly WhatsNewEntry[] = [ }, { title: 'Share part of a vault with an assistant', - body: 'Launch MCP with --allow-path, --deny-path or --read-only and it sees only what you share. The bundled zn is 0.13.0.' + body: 'Launch MCP with --allow-path, --deny-path or --read-only and it sees only what you share. The bundled zn is 0.13.1.' }, { title: 'Cloud keeps syncing past a file it refuses',