diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 71838b3f..6b8f49c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.69.1", + "version": "2.70.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 75cac527..3a81e651 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -671,7 +671,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'list_tasks', description: - 'Return every task (markdown checkbox) in live notes with parsed metadata: due date, priority, @waiting, tags. Filter by status, priority, date range, tag, or folder.', + 'Return every task (markdown checkbox) in live notes with parsed metadata: due date, priority, @waiting, tags, and status, the column a Kanban board grouped by status shows it in (a task note’s `status:`, a line’s `@status:`, or the frontmatter `status:` of its note). Filter by state, priority, date range, tag, or folder.', inputSchema: { type: 'object', properties: { @@ -679,7 +679,7 @@ const TOOLS: ToolDef[] = [ type: 'string', enum: ['open', 'done', 'waiting', 'all'], description: - 'open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything.' + 'The task’s state, not its status column. open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything.' }, priority: { type: 'string', diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index c168efca..a6e4f18f 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -15,6 +15,7 @@ import { toggleTaskInBody } from './vault-ops' import { TASK_LINE_RE as SHARED_TASK_LINE_RE } from '@shared/tasklists' +import { parseTasksFromBody as parseAppTasks } from '@shared/tasks' // Every note-shaped MCP result carries `link`, the zennotes:// deep link a // model renders as a markdown link so the user can click from chat straight @@ -113,6 +114,27 @@ describe('mcp task states', () => { expect(tasks.find((t) => t.content === 'deeply quoted')?.inProgress).toBe(true) }) + // An assistant sees which column of a status board a task is in, as the app + // and `zn mcp` report it, while this grammar keeps the token in the content + // (ZenNotes/zennotes#941). + it('reports each task’s status column the way the app reads it', async () => { + await writeFile(path.join(root, 'inbox', 'Ship it.md'), '---\ntags: [task]\nstatus: Ready\n---\nBuild it.\n') + await writeFile(path.join(root, 'inbox', 'Bare task.md'), '---\ntags: [task]\n---\nNo status yet.\n') + const sprint = '---\nstatus: backlog\n---\n- [ ] inherits\n- [ ] overrides @status:Review @status:later\n- [x] done here\n' + await writeFile(path.join(root, 'inbox', 'Sprint.md'), sprint) + const tasks = await scanAllTasks(root) + const statusOf = (content: string): string | undefined => tasks.find((t) => t.content === content)?.status + expect(statusOf('Ship it')).toBe('ready') + expect(statusOf('Bare task')).toBe('open') + expect(statusOf('inherits')).toBe('backlog') + expect(statusOf('overrides @status:Review @status:later')).toBe('review') + expect(statusOf('done here')).toBe('backlog') + expect(statusOf('actually do it')).toBeUndefined() + expect(JSON.parse(JSON.stringify(tasks.find((t) => t.content === 'actually do it')))).not.toHaveProperty('status') + const app = parseAppTasks(sprint, { path: 'inbox/Sprint.md', title: 'Sprint', folder: 'inbox' }) + expect(tasks.filter((t) => t.sourcePath === 'inbox/Sprint.md').map((t) => t.status)).toEqual(app.map((t) => t.status)) + }) + it('toggles an ordered or quoted task in place', () => { const body = '1. [ ] first\n> - [ ] quoted\n' expect(toggleTaskInBody(body, 0)).toBe('1. [x] first\n> - [ ] quoted\n') diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 31373780..e7e3092f 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -21,7 +21,7 @@ import { retitleLeadingHeading } from '@shared/note-heading-sync' import { normalizeNoteComments } from '@shared/note-comments' import type { NoteComment, NoteCommentInput } from '@shared/ipc' export type { NoteComment, NoteCommentInput } -import { noteTasksMode, type NoteTasksMode } from '@shared/tasks' +import { inlineTaskField, noteTasksMode, type NoteTasksMode } from '@shared/tasks' import { isPathExcludedFromTasks, normalizeTasksExcludedFolders @@ -283,6 +283,13 @@ export interface VaultTask { due?: string priority?: 'high' | 'med' | 'low' waiting: boolean + /** The column the task sits in on a Kanban board grouped by status, read as + * the app reads it: a task note's frontmatter `status:` (`open` when it has + * none), a checkbox line's `@status:`, or else the frontmatter `status:` of + * the note the line is in. Lower-cased; absent when a line has none. The + * `@status:` token stays in `content`, as every field does in this grammar + * (ZenNotes/zennotes#941). */ + status?: string tags: string[] /** How this task is stored. `'file'` is a whole-note task (TaskNotes-style: a * `.md` file tagged `task`, metadata in frontmatter); `'inline'` (the default @@ -1356,11 +1363,17 @@ function normalizeDueDate(raw: string | undefined): string | undefined { function parseNoteDefaults(body: string): { due?: string priority?: 'high' | 'med' | 'low' + status?: string tasksMode: NoteTasksMode } { const m = body.match(FRONTMATTER_RE) if (!m) return { tasksMode: 'all' } - const out: { due?: string; priority?: 'high' | 'med' | 'low'; tasksMode: NoteTasksMode } = { + const out: { + due?: string + priority?: 'high' | 'med' | 'low' + status?: string + tasksMode: NoteTasksMode + } = { tasksMode: 'all' } for (const rawLine of m[1].split('\n')) { @@ -1377,6 +1390,8 @@ function parseNoteDefaults(body: string): { else if (key === 'priority') { const p = normalizePriority(value) if (p) out.priority = p + } else if (key === 'status') { + if (value) out.status = value.toLowerCase() } else if (key === 'tasks') out.tasksMode = noteTasksMode(value) } return out @@ -1478,6 +1493,7 @@ function parseTasksFromBody( due: due ?? defaults.due, priority: priority ?? defaults.priority, waiting, + status: inlineTaskField(tail, 'status') ?? defaults.status, tags }) taskIndex += 1 @@ -1603,6 +1619,7 @@ function parseTaskFile( due: normalizeDueDate(firstScalar(fm.due)), priority: normalizePriority(firstScalar(fm.priority)), waiting: status === 'waiting', + status, tags: tags.filter((t) => t !== TASK_FILE_TAG), kind: 'file', scheduled: normalizeDueDate(firstScalar(fm.scheduled)), diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index e79d4864..a106126a 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.7.1", - "commit": "91bb33b42a3d156c2e577762b1f7ce1feb02d586", + "version": "0.8.0", + "commit": "58b0e5683bdc35049e7e592998a3f236093e7557", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_arm64.tar.gz", - "sha256": "cb0c90410fcd86972b4e62a24840bd0f5d2d82d403cfc556399e3c735b43651c" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_darwin_arm64.tar.gz", + "sha256": "29ebd83447c45a8fd5f8196f2c6680521bc1a2bbbec6afa609fadec480006042" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_darwin_amd64.tar.gz", - "sha256": "c6df5efe8d18dfa598e1ce8698fdd1a2eccf76892d94533479838cdd9a3e5d37" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_darwin_amd64.tar.gz", + "sha256": "7d6e91766664d72bb938e20644ab2501d8691aba4ae82168f7d18741850ef22e" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_arm64.tar.gz", - "sha256": "1151c6daed55825e881014873802c32c056dff68797e64ed83451b9fdfe3f0b9" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_linux_arm64.tar.gz", + "sha256": "b73d9356b65a597364ba09bcb086be1b37b24c7c4cd2e806e990b00e8a997f5f" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.7.1/zn_0.7.1_linux_amd64.tar.gz", - "sha256": "b61d145931c32b417eaf5f8bce379e7f94e327e3b5527f7868dcba3a3a178ac2" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.8.0/zn_0.8.0_linux_amd64.tar.gz", + "sha256": "5f5484434dda2c4745529f4c54c955c678219ce05880e6ff3c25a5af8845b2b1" } } } diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index e50e0dfb..63ec424a 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.69.1", + "version": "2.70.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 a41bf1cb..de1295db 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.69.1", + "version": "2.70.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 8debf4cc..cf7a473f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.69.1", + "version": "2.70.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 fa4f5c5b..6e55e90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.69.1", + "version": "2.70.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.69.1", + "version": "2.70.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.69.1", + "version": "2.70.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16390,7 +16390,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16478,14 +16478,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.69.1", + "version": "2.70.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.69.1", + "version": "2.70.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16499,7 +16499,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.69.1" + "version": "2.70.0" } } } diff --git a/package.json b/package.json index 191ae6aa..5ca3b4b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.69.1", + "version": "2.70.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 18f3b29f..11990a27 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.69.1", + "version": "2.70.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 742fa087..d9ff4f32 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -1029,20 +1029,12 @@ function App(): JSX.Element { !!document.querySelector('[data-prompt-modal]') || !!document.querySelector('[data-confirm-modal]') || !!document.querySelector('[data-cloud-conflict-dialog]') - // Search Notes is a toggle: its own shortcut closes the palette it - // opened (#510 moved it here from the bubble handler, which had no - // overlay guard at all). A confirm on top of the palette, such as the - // Ctrl+D trash confirmation, keeps the key to itself. - if ( - state.searchOpen && - !modalOrMenuOpen && - matchesShortcut(e, overrides, 'global.searchNotes') - ) { - e.preventDefault() - e.stopImmediatePropagation() - setSearchOpen(false) - return - } + // `global.searchNotes` is deliberately not a toggle. `Mod+P` resolves to + // `Ctrl+P` on Linux and Windows, which is also the Emacs previous-item + // chord every palette handles via `isPalettePreviousKey()`. A toggle here + // ran capture-phase and called `stopImmediatePropagation()`, making that + // branch unreachable while the palette was open (#938). Escape dismisses. + // Do not reinstate a toggle above the overlay guard. if ( state.settingsOpen || state.whatsNewOpen || diff --git a/packages/app-core/src/components/QuickCaptureApp.test.ts b/packages/app-core/src/components/QuickCaptureApp.test.ts index ed18c4a3..42e3c78b 100644 --- a/packages/app-core/src/components/QuickCaptureApp.test.ts +++ b/packages/app-core/src/components/QuickCaptureApp.test.ts @@ -7,6 +7,7 @@ import { completionStatus, startCompletion } from '@codemirror/autocomplete' import { getCM } from '@replit/codemirror-vim' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QuickCaptureApp } from './QuickCaptureApp' +import { getKeymapDisplay } from '../lib/keymaps' describe('Quick Capture Escape handling (#765)', () => { let host: HTMLDivElement @@ -28,6 +29,10 @@ describe('Quick Capture Escape handling (#765)', () => { configurable: true, value: () => new DOMRect() }) + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) @@ -225,6 +230,10 @@ describe('Quick Capture close shortcut (#893)', () => { configurable: true, value: () => new DOMRect() }) + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) @@ -293,6 +302,272 @@ describe('Quick Capture close shortcut (#893)', () => { expect(host.querySelector('input')).not.toBeNull() }) + // The chords below are claimed in a capture-phase listener on window, because + // codemirror-vim consumes Mod+N / Mod+P / Mod+W as normal-mode chords and stops + // propagation, so those chords only reach the bubble listener in insert mode. + // The fix moved them into a capture-phase listener on window. Dispatching on + // document is what makes these tests exercise that phase: the capture path + // still starts at window, so the listener runs, while the editor never sees + // the key and Vim cannot stand in for it. + const onWindow = (init: KeyboardEventInit): Promise => press(document, init) + const modP: KeyboardEventInit = { key: 'p', code: 'KeyP', keyCode: 80, metaKey: true } + const modN: KeyboardEventInit = { key: 'n', code: 'KeyN', keyCode: 78, metaKey: true } + const modShiftP: KeyboardEventInit = { + key: 'p', code: 'KeyP', keyCode: 80, metaKey: true, shiftKey: true + } + // Navigation inside an overlay is Ctrl-only. isPalettePreviousKey requires + // ctrlKey and rejects metaKey outright, on every platform, which is why the + // footer reads Ctrl and not Mod. Mod+N/P only open the overlays. + const ctrlP: KeyboardEventInit = { key: 'p', code: 'KeyP', keyCode: 80, ctrlKey: true } + const ctrlN: KeyboardEventInit = { key: 'n', code: 'KeyN', keyCode: 78, ctrlKey: true } + + const overlayInput = (): HTMLInputElement | null => + host.querySelector('input') + const footerHints = (): string => + (host.querySelector('footer')?.textContent ?? '').replace(/\s+/g, ' ').trim() + const rows = (): HTMLButtonElement[] => + [...host.querySelectorAll('[data-qc-idx]')] + // Exact class token, not a substring match: the inactive row carries + // `hover:bg-paper-200/60`, which contains "bg-paper-200" as a substring. + const activeRow = (): HTMLButtonElement => + rows().find((r) => r.classList.contains('bg-paper-200')) ?? rows()[0] + + /** `window.zen` is installed by defineProperty without `writable`, so seeding + * notes has to go through the same door rather than by assignment. */ + function seedNotes(notes: { path: string; title: string; folder: string }[]): void { + const zen = (window as unknown as { zen: Record }).zen + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + ...zen, + listNotes: vi.fn(async () => + notes.map((n, i) => ({ + ...n, + siblingOrder: i, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + })) + ) + } + }) + } + + /** CodeMirror starts in insert mode here, so one Escape puts Vim in normal + * mode, which is the state where the chords used to be swallowed. */ + async function toNormalMode(view: EditorView): Promise { + await press(view.contentDOM, { key: 'Escape', keyCode: 27 }) + } + + it('opens the note picker on Mod+P in normal mode, where Vim would take it', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(modP) + + expect(overlayInput()?.placeholder).toContain('Search notes') + // Vim's cursor-up never ran, and nothing was saved or closed on the way. + expect(windowClose).not.toHaveBeenCalled() + expect(writeNote).not.toHaveBeenCalled() + }) + + it('starts a new note on Mod+N in normal mode', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(modN) + + expect(createNote).toHaveBeenCalled() + expect(overlayInput()).toBeNull() + }) + + it('hides the window on Mod+W in normal mode, keeping the draft', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(cmdW) + + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('opens the command palette on Mod+Shift+P in normal mode', async () => { + const view = await mount() + await toNormalMode(view) + + await onWindow(modShiftP) + + expect(overlayInput()?.placeholder).toContain('Run a command') + }) + + it('moves down rather than toggling once the picker has focus', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + await mount() + await onWindow(modP) + expect(rows()).toHaveLength(2) + expect(activeRow().textContent).toContain('Alpha') + + // The chord targets the focused input, which is where a real keystroke + // lands. The window capture listener stands aside while an overlay is + // open, so the overlay's own handler navigates rather than the chord + // re-entering the capture handler and closing the picker. + await press(overlayInput()!, ctrlN) + + expect(overlayInput()?.placeholder).toContain('Search notes') + expect(rows()).toHaveLength(2) + expect(activeRow().textContent).toContain('Beta') + }) + + it('holds at the top row on Ctrl+P instead of closing the picker', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + await mount() + await onWindow(modP) + const input = overlayInput()! + await press(input, ctrlN) // move off the top row first + + // Ctrl+P is previous. From the top row it clamps rather than toggling the + // overlay shut, which is the specific failure being fixed. + await press(input, ctrlP) + expect(overlayInput()?.placeholder).toContain('Search notes') + expect(activeRow().textContent).toContain('Alpha') + + // And it does go up when there is somewhere to go. + await press(input, ctrlN) + await press(input, ctrlP) + expect(activeRow().textContent).toContain('Alpha') + }) + + it('stops moving at the last row instead of wrapping or closing', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + await mount() + await onWindow(modP) + const input = overlayInput()! + await press(input, ctrlN) // onto Beta, the last row + await press(input, ctrlN) // one past the end + + expect(rows()).toHaveLength(2) + expect(activeRow().textContent).toContain('Beta') + }) + + it('swaps the footer hints while an overlay is open', async () => { + await mount() + const idle = footerHints() + expect(idle).toContain('notes') + expect(idle).toContain('close') + + await onWindow(modP) + const open = footerHints() + expect(open).toContain('move') + expect(open).toContain('esc close') + // Ctrl, not Mod. Cmd+N/P do not navigate in any palette, so advertising + // them here would be the same lie Bug B was about, reintroduced. + expect(open).toContain('Ctrl+N/P') + expect(open).not.toContain('⌘N/P') + // The chords that no longer apply inside an overlay are not offered. + expect(open).not.toContain('new') + expect(open).not.toContain('cmd') + }) + + it('names the close key the way Close active tab is bound', async () => { + localStorage.setItem( + 'zen:prefs:v2', + JSON.stringify({ keymapOverrides: { 'global.closeActiveTab': 'Shift+Mod+W' } }) + ) + await mount() + const rebound = getKeymapDisplay( + { 'global.closeActiveTab': 'Shift+Mod+W' }, + 'global.closeActiveTab' + ) + expect(footerHints()).toContain(`${rebound} close`) + }) + + it('does not advertise a close key once Close active tab is unbound', async () => { + localStorage.setItem( + 'zen:prefs:v2', + JSON.stringify({ keymapOverrides: { 'global.closeActiveTab': '' } }) + ) + await mount() + expect(footerHints()).toContain('notes') + expect(footerHints()).not.toContain('close') + }) + + it('drops the two command hints that no longer run inside the command palette', async () => { + await mount() + await onWindow(modShiftP) + + const listed = rows().map((r) => r.textContent?.replace(/\s+/g, ' ').trim() ?? '') + expect(listed.some((t) => t.startsWith('Save and start a new note'))).toBe(true) + expect(listed.some((t) => t.startsWith('Open another note'))).toBe(true) + // Mod+Enter is not a navigation chord, so its hint stays. + expect(rows()[0].textContent).toContain('↩') + }) + + it('scrolls the active row into view in both overlays', async () => { + seedNotes([ + { path: 'inbox/Alpha.md', title: 'Alpha', folder: 'inbox' }, + { path: 'inbox/Beta.md', title: 'Beta', folder: 'inbox' } + ]) + const scrolled = Element.prototype.scrollIntoView as unknown as ReturnType + + await mount() + + await onWindow(modP) + scrolled.mockClear() + await press(overlayInput()!, ctrlN) + expect(activeRow().textContent).toContain('Beta') + expect(scrolled).toHaveBeenCalledWith({ block: 'nearest' }) + + // Dismiss, then the same check for the command overlay. Its effect is + // keyed on the same `active`, but it is a separate component. + await press(overlayInput()!, { key: 'Escape', keyCode: 27 }) + await onWindow(modShiftP) + scrolled.mockClear() + await press(overlayInput()!, ctrlN) + expect(scrolled).toHaveBeenCalledWith({ block: 'nearest' }) + }) + + it('shows only the subpath beside the filename', async () => { + seedNotes([ + { + path: 'inbox/ZenNotes/Enhancements/Calendar Panel Tasks Placement.md', + title: 'Calendar Panel Tasks Placement', + folder: 'inbox' + } + ]) + await mount() + await onWindow(modP) + + const bidiControls = /[\u202A-\u202E]/g + const cells = [...rows()[0].querySelectorAll('span')].map( + (s) => s.textContent?.replace(bidiControls, '').trim() ?? '' + ) + expect(cells).toContain('Calendar Panel Tasks Placement') + expect(cells).toContain('ZenNotes/Enhancements') + // Neither the folder nor the filename is repeated in the path column. + expect(cells.filter((c) => c === 'inbox')).toHaveLength(1) + expect(cells.some((c) => c.endsWith('.md'))).toBe(false) + // The column is right to left so its ellipsis lands on the left, and the + // path inside it is embedded left to right, as in Open Buffers: without + // that, a subpath that starts with digits, such as 2026/Planning, displays + // as Planning/2026. + expect(rows()[0].querySelector('[dir="rtl"]')?.textContent).toBe( + '\u202AZenNotes/Enhancements\u202C' + ) + }) + it('follows a rebind of Close active tab', async () => { localStorage.setItem( 'zen:prefs:v2', diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 95b082e3..79e08242 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -88,7 +88,7 @@ import { PinIcon } from './icons' import { headingFolding } from '../lib/cm-heading-fold' import { editorTabSize, normalizeEditorTabSize } from '../lib/editor-tab-size' import { storedCursorDrawSelection } from '../lib/cm-cursor-blink' -import { useCloseWindowShortcut } from '../lib/close-shortcut' +import { useCloseShortcutDisplay, useCloseWindowShortcut } from '../lib/close-shortcut' const PREFS_KEY = 'zen:prefs:v2' @@ -576,7 +576,8 @@ export function QuickCaptureApp(): JSX.Element { // save, the draft stays in this renderer for the next show. The picker and // the command palette keep it, as palettes do in the main window. const overlayOpen = useCallback(() => overlayRef.current !== 'none', []) - useCloseWindowShortcut(overlayOpen) + useCloseWindowShortcut(overlayOpen, true) + const closeKey = useCloseShortcutDisplay() const submitAndCloseRef = useRef(submitAndClose) useEffect(() => { submitAndCloseRef.current = submitAndClose @@ -594,21 +595,8 @@ export function QuickCaptureApp(): JSX.Element { void submitAndCloseRef.current() return } - if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'n') { - e.preventDefault() - void newNoteRef.current() - return - } - if (mod && e.shiftKey && e.key.toLowerCase() === 'p') { - e.preventDefault() - setOverlay((current) => (current === 'command' ? 'none' : 'command')) - return - } - if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'p') { - e.preventDefault() - setOverlay((current) => (current === 'search' ? 'none' : 'search')) - return - } + // Mod+N, Mod+P and Mod+Shift+P are handled in the capture-phase listener + // below, which stands aside while an overlay is open. if (e.key === 'Escape') { // CodeMirror can consume Esc (e.g. collapsing a visual selection) // without stopping propagation. Do not also save and hide. (#765) @@ -631,6 +619,40 @@ export function QuickCaptureApp(): JSX.Element { return () => window.removeEventListener('keydown', handler) }, []) + // codemirror-vim consumes Ctrl+P, Ctrl+N and Ctrl+W as normal-mode chords and + // stops propagation, so the bubble handler above never sees them and the + // footer advertises shortcuts that only work in insert mode. Capture phase, + // the same treatment App.tsx gives its own editor chords (#510, #579). + // + // Gated on no overlay being open: with one open its input has focus and its + // own isPalettePreviousKey branch moves the selection, so standing aside here + // is what keeps Ctrl+P navigating instead of closing. + // + // Escape deliberately stays in the bubble handler, whose branch reads + // e.defaultPrevented on the grounds that CodeMirror has already run (#765). + useEffect(() => { + const overlayChords = (e: KeyboardEvent): void => { + if (overlayRef.current !== 'none') return + if (!(e.metaKey || e.ctrlKey) || e.altKey) return + const key = e.key.toLowerCase() + if (e.shiftKey && key === 'p') { + e.preventDefault() + e.stopImmediatePropagation() + setOverlay((current) => (current === 'command' ? 'none' : 'command')) + } else if (!e.shiftKey && key === 'n') { + e.preventDefault() + e.stopImmediatePropagation() + void newNoteRef.current() + } else if (!e.shiftKey && key === 'p') { + e.preventDefault() + e.stopImmediatePropagation() + setOverlay((current) => (current === 'search' ? 'none' : 'search')) + } + } + window.addEventListener('keydown', overlayChords, true) + return () => window.removeEventListener('keydown', overlayChords, true) + }, []) + const isMacPlatform = useMemo(() => { try { return window.zen.platformSync() === 'darwin' @@ -769,15 +791,40 @@ export function QuickCaptureApp(): JSX.Element { {modKey}↩ save - - {modKey}N new - - - {modKey}P notes - - - {modKey}⇧P cmd - + {overlay === 'none' ? ( + <> + + {modKey}N new + + + {modKey}P notes + + + {modKey}⇧P cmd + + {/* Close active tab follows a rebind here, and an unbound key + is not advertised at all. */} + {closeKey && ( + + {closeKey} close + + )} + + ) : ( + <> + + {/* Ctrl, not Mod: isPaletteNextKey/isPalettePreviousKey + accept Ctrl+N/P and Ctrl+J/K only, with no Cmd branch. + Same literal string the other palettes use, so the footer + stays true on macOS where Mod+N/P does not navigate. */} + ↑↓{' '} + Ctrl+N/P move + + + esc close + + + )} @@ -802,6 +849,17 @@ interface NotePickerOverlayProps { onCancel: () => void } +/** Where a note sits inside its folder, with neither the folder nor the + * filename repeated. The row already shows the folder on the left and the + * title next to it, so a full path would say the same thing twice and leave + * the title nothing but a truncated stub. */ +function noteSubpath(note: Pick): string { + const within = note.path.startsWith(`${note.folder}/`) + ? note.path.slice(note.folder.length + 1) + : note.path + return within.replace(/[^/]+$/, '').replace(/\/+$/, '') +} + function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): JSX.Element { const [query, setQuery] = useState('') const [active, setActive] = useState(0) @@ -811,6 +869,12 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): inputRef.current?.focus() }, []) + const listRef = useRef(null) + useEffect(() => { + const el = listRef.current?.querySelector(`[data-qc-idx="${active}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [active]) + const searchIndex = useMemo(() => buildNoteSearchIndex(notes), [notes]) const results = useMemo(() => { @@ -858,7 +922,7 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): className="w-full bg-transparent text-sm outline-none placeholder:text-ink-400" /> -
+
{results.length === 0 ? (
No notes match. Esc to dismiss. @@ -869,6 +933,7 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): return ( ) }) @@ -912,6 +990,12 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv inputRef.current?.focus() }, []) + const listRef = useRef(null) + useEffect(() => { + const el = listRef.current?.querySelector(`[data-qc-idx="${active}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [active]) + const all = useMemo( () => [ { @@ -927,15 +1011,18 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv keywords: 'save write keep open' }, { + // This overlay only mounts while it is open, and while it is open those + // two chords move the selection rather than running the command. Mod+Enter + // is not a navigation chord, so its hint stays. id: 'new' as CommandAction, label: 'Save and start a new note', - hint: `${modKey}N`, + hint: '', keywords: 'new fresh next another note save' }, { id: 'open' as CommandAction, label: 'Open another note…', - hint: `${modKey}P`, + hint: '', keywords: 'open switch picker find search note' } ], @@ -986,7 +1073,7 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv className="w-full bg-transparent text-sm outline-none placeholder:text-ink-400" />
-
+
{results.length === 0 ? (
No commands match.
) : ( @@ -995,6 +1082,7 @@ function CommandOverlay({ modKey, mode, vimMode, onAction, onCancel }: CommandOv return (