diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1e1e3d71..ddae5d7f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.64.0", + "version": "2.65.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/login-shell-path.test.ts b/apps/desktop/src/main/login-shell-path.test.ts index 2900e6b9..3d523385 100644 --- a/apps/desktop/src/main/login-shell-path.test.ts +++ b/apps/desktop/src/main/login-shell-path.test.ts @@ -1,12 +1,15 @@ +import { execFileSync } from 'node:child_process' import { accessSync, constants as fsConstants } from 'node:fs' -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { probeNvmInstall, resetLoginShellResolutionForTests, - resolveCommandViaLoginShell + resolveCommandViaLoginShell, + resolveLoginShellPathDirs, + setLoginShellTimeoutForTests } from './login-shell-path' const onPosix = process.platform !== 'win32' @@ -98,6 +101,110 @@ describe('rc-file-only PATH entries (#634)', () => { ) }) +// Julie (Discord, 2026-10-09) launched ZenNotes from a terminal; opening +// Settings > CLI spawned interactive shells that took her terminal and never +// exited, and the page sat on "Checking install status..." for good. +describe('shells started from a terminal', () => { + const savedEnv: Record = {} + let tempDir: string | null = null + const marker = (dirs: string): string => `printf '\\n__ZENNOTES_LOGIN_SHELL__%s' "${dirs}"` + + afterEach(async () => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + resetLoginShellResolutionForTests() + if (tempDir) await rm(tempDir, { recursive: true, force: true }) + tempDir = null + }) + + function overrideEnv(overrides: Record): void { + for (const [key, value] of Object.entries(overrides)) { + if (!(key in savedEnv)) savedEnv[key] = process.env[key] + process.env[key] = value + } + } + + // A killed process lingers as a zombie until its parent reaps it; that + // counts as gone. + function running(pid: number): boolean { + try { + return !execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' }).trim().startsWith('Z') + } catch { + return false + } + } + + async function gone(pid: number): Promise { + for (let i = 0; i < 40; i++) { + if (!running(pid)) return true + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return false + } + + // Stands in for an interactive shell stuck on an rc prompt: it ignores + // SIGTERM, as interactive shells do, and never prints the marker. + it.skipIf(!onPosix)('gives up on a shell that ignores SIGTERM and leaves none of it running', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'zen-stuck-shell-')) + const pids = path.join(tempDir, 'pids') + const stuck = path.join(tempDir, 'stuck-shell') + await makeExecutable(stuck, `#!/bin/sh\ntrap '' TERM\necho $$ > "${pids}"\nsleep 30 &\necho $! >> "${pids}"\nwait\n`) + overrideEnv({ SHELL: stuck, HOME: tempDir }) + resetLoginShellResolutionForTests() + setLoginShellTimeoutForTests(1000) + + const started = Date.now() + const dirs = await resolveLoginShellPathDirs() + // The standard shells tried after it still answer. + expect(dirs.length).toBeGreaterThan(0) + expect(Date.now() - started).toBeLessThan(4000) + const [shellPid, sleepPid] = (await readFile(pids, 'utf8')).trim().split('\n').map(Number) + expect(await gone(shellPid)).toBe(true) + expect(await gone(sleepPid)).toBe(true) + }) + + it.skipIf(!onPosix)('runs the shell in a session of its own, without a terminal', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'zen-tty-shell-')) + const report = path.join(tempDir, 'report') + const shell = path.join(tempDir, 'tty-shell') + await makeExecutable( + shell, + `#!/bin/sh\nif (exec 3/dev/null; then tty=open; else tty=none; fi\necho "$tty $$ $(ps -o pgid= -p $$)" > "${report}"\n${marker('/usr/bin:/bin')}\n` + ) + overrideEnv({ SHELL: shell, HOME: tempDir }) + resetLoginShellResolutionForTests() + + expect(await resolveLoginShellPathDirs()).toEqual(['/usr/bin', '/bin']) + const [tty, pid, group] = (await readFile(report, 'utf8')).trim().split(/\s+/) + // /dev/tty only tells the probes apart when the tests run in a terminal. + // The new session also makes the shell lead its own process group, which + // shows anywhere and is what lets a timeout kill the whole group. + expect(group).toBe(pid) + expect(tty).toBe('none') + }) + + it.skipIf(!onPosix)('shares one shell between callers that ask at the same time', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'zen-shared-shell-')) + const runs = path.join(tempDir, 'runs') + const shell = path.join(tempDir, 'counting-shell') + await makeExecutable(shell, `#!/bin/sh\necho run >> "${runs}"\nsleep 0.3\n${marker('/usr/bin:/bin')}\n`) + overrideEnv({ SHELL: shell, HOME: tempDir }) + resetLoginShellResolutionForTests() + + const [dirs, sh, ls] = await Promise.all([ + resolveLoginShellPathDirs(), + resolveCommandViaLoginShell('sh'), + resolveCommandViaLoginShell('ls') + ]) + expect(dirs).toEqual(['/usr/bin', '/bin']) + expect(sh).toBeTruthy() + expect(ls).toBeTruthy() + expect((await readFile(runs, 'utf8')).trim().split('\n')).toHaveLength(1) + }) +}) + describe('probeNvmInstall', () => { const savedNvmDir = process.env.NVM_DIR let nvmDir: string | null = null diff --git a/apps/desktop/src/main/login-shell-path.ts b/apps/desktop/src/main/login-shell-path.ts index 882fb14f..a662bbda 100644 --- a/apps/desktop/src/main/login-shell-path.ts +++ b/apps/desktop/src/main/login-shell-path.ts @@ -1,14 +1,13 @@ -import { execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { promises as fsp, constants as fsConstants } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { promisify } from 'node:util' - -const execFileAsync = promisify(execFile) // A login shell can be slow if the user's profile is heavy; cap it so a stuck // shell never blocks search. Resolution is memoized, so this runs rarely. const LOGIN_SHELL_TIMEOUT_MS = 5_000 +let loginShellTimeoutMs = LOGIN_SHELL_TIMEOUT_MS +const LOGIN_SHELL_MAX_OUTPUT = 1024 * 1024 // Memoize resolved locations so we don't spawn a shell on every capability // check / search. Short enough to self-heal after a tool is installed mid-run. const RESOLUTION_TTL_MS = 60_000 @@ -32,6 +31,9 @@ const SHELL_MODES = ['-lic', '-lc'] as const const cache = new Map() let pathDirsCache: { at: number; value: string[] } | null = null +// One probe at a time: the CLI page asks for the PATH, node and npm at once, +// and each caller used to start its own shell while the first was running. +let pathDirsInFlight: Promise | null = null /** * Resolve a command to an absolute path using the user's login shell. @@ -88,15 +90,30 @@ export async function resolveLoginShellPathDirs(): Promise { const cached = pathDirsCache if (cached && Date.now() - cached.at < RESOLUTION_TTL_MS) return cached.value - const value = await queryLoginShellPathDirs() - pathDirsCache = { at: Date.now(), value } - return value + if (!pathDirsInFlight) { + pathDirsInFlight = queryLoginShellPathDirs() + .then((value) => { + pathDirsCache = { at: Date.now(), value } + return value + }) + .finally(() => { + pathDirsInFlight = null + }) + } + return pathDirsInFlight } /** Resolution is memoized; tests that vary HOME/SHELL/NVM_DIR reset it between cases. */ export function resetLoginShellResolutionForTests(): void { cache.clear() pathDirsCache = null + pathDirsInFlight = null + loginShellTimeoutMs = LOGIN_SHELL_TIMEOUT_MS +} + +/** Shortens the per-shell timeout so a test of a stuck shell does not take five seconds. */ +export function setLoginShellTimeoutForTests(ms: number): void { + loginShellTimeoutMs = ms } async function scanLoginShellPath(command: string): Promise { @@ -120,17 +137,8 @@ async function queryLoginShellPathDirs(): Promise { await fsp.access(shellPath, fsConstants.X_OK) // printf interprets the leading \n itself, forcing our marker onto its // own line no matter what the rc files printed before it. - const { stdout } = await execFileAsync( - shellPath, - [mode, `printf '\\n${OUTPUT_MARKER}%s' "$PATH"`], - { - encoding: 'utf8', - timeout: LOGIN_SHELL_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - windowsHide: true - } - ) - const dirs = (extractMarkedLine(String(stdout)) ?? '') + const stdout = await runLoginShell(shellPath, mode, `printf '\\n${OUTPUT_MARKER}%s' "$PATH"`) + const dirs = (extractMarkedLine(stdout) ?? '') .split(path.delimiter) .map((entry) => entry.trim()) .filter(Boolean) @@ -147,6 +155,59 @@ async function queryLoginShellPathDirs(): Promise { return [] } +/** + * Run one probe in the user's shell, cut off from any terminal. + * + * Launched from a terminal, ZenNotes owns that terminal, and an interactive + * shell started beneath it took it over: job control moved the terminal's + * foreground to the shell, and an rc file that waits on the terminal (an + * update prompt, a setup wizard, `exec tmux`) blocked there for good. + * Interactive shells ignore SIGTERM, so the timeout never ended them, the CLI + * settings page waited on them forever, and the user's terminal was left to + * the stuck shells. In a new session (`detached`) the shell has no + * controlling terminal, so `/dev/tty` fails at once instead of blocking, its + * stdin is empty, and a timeout kills its whole process group with SIGKILL. + */ +function runLoginShell(shellPath: string, mode: string, command: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(shellPath, [mode, command], { + detached: true, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true + }) + let stdout = '' + let settled = false + const killGroup = (): void => { + try { + if (child.pid) process.kill(-child.pid, 'SIGKILL') + } catch { + /* the group is already gone */ + } + } + const finish = (error: Error | null): void => { + if (settled) return + settled = true + clearTimeout(timer) + if (error) reject(error) + else resolve(stdout) + } + const timer = setTimeout(() => { + killGroup() + finish(new Error(`${shellPath} ${mode} did not answer within ${loginShellTimeoutMs} ms`)) + }, loginShellTimeoutMs) + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout += chunk + if (stdout.length > LOGIN_SHELL_MAX_OUTPUT) { + killGroup() + finish(new Error(`${shellPath} ${mode} printed more than ${LOGIN_SHELL_MAX_OUTPUT} bytes`)) + } + }) + child.on('error', (error) => finish(error)) + child.on('close', () => finish(null)) + }) +} + async function anyExistingDirectory(dirs: string[]): Promise { for (const dir of dirs) { if (!path.isAbsolute(dir)) continue diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 187d49c3..261747ca 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.64.0", + "version": "2.65.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 3f7f2655..16a5ad02 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.64.0", + "version": "2.65.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 0209d115..0588e4ab 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.64.0", + "version": "2.65.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 daaadcf1..d2ebb47d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.64.0", + "version": "2.65.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.64.0", + "version": "2.65.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.64.0", + "version": "2.65.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.64.0", + "version": "2.65.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.64.0", + "version": "2.65.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.64.0", + "version": "2.65.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16360,7 +16360,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.64.0", + "version": "2.65.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16447,14 +16447,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.64.0", + "version": "2.65.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.64.0", + "version": "2.65.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16466,7 +16466,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.64.0" + "version": "2.65.0" } } } diff --git a/package.json b/package.json index 0d271b0b..8af523d8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.64.0", + "version": "2.65.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 57273aff..ed9b823e 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.64.0", + "version": "2.65.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 1fabbe96..30b38be1 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -158,6 +158,7 @@ function resolveShortcutKeys( function resolveVimCommandLabel(command: string, overrides: KeymapOverrides): string { if (command === 'gd') return shortcut(overrides, 'vim.goToDefinition') if (command === 'gD') return shortcut(overrides, 'vim.createNoteFromLink') + if (command === 'Mod+L') return shortcut(overrides, 'editor.toggleCheckbox') if (command === ' l f') { return chord( leaderShortcut(overrides, 'vim.leaderNoteActions'), diff --git a/packages/app-core/src/components/TagView.tsx b/packages/app-core/src/components/TagView.tsx index f315fd8d..da5e2f63 100644 --- a/packages/app-core/src/components/TagView.tsx +++ b/packages/app-core/src/components/TagView.tsx @@ -354,6 +354,12 @@ export function TagView(): JSX.Element {
useStore.getState().setFocusedPanel('tags')} + onFocusCapture={() => useStore.getState().setFocusedPanel('tags')} >
diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index ca7705a0..9db24244 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -807,7 +807,8 @@ export function VimNav(): JSX.Element | null { noteListOpen: state.noteListOpen, unifiedSidebar: state.unifiedSidebar, tasksViewOpen: isTasksViewActive(state), - atlasViewOpen: isAtlasViewActive(state) + atlasViewOpen: isAtlasViewActive(state), + tagsViewOpen: isTagsViewActive(state) }) const direction = matchesSequenceToken(e, overrides, 'vim.paneFocusLeft') || diff --git a/packages/app-core/src/components/WorkflowsView.tsx b/packages/app-core/src/components/WorkflowsView.tsx index 54a45970..34829c47 100644 --- a/packages/app-core/src/components/WorkflowsView.tsx +++ b/packages/app-core/src/components/WorkflowsView.tsx @@ -41,7 +41,7 @@ import { setStatementName, setStepArg } from '@shared/workflows/edit' -import { serializeWorkflow } from '@shared/workflows/serialize' +import { serializeWorkflow, tagListText } from '@shared/workflows/serialize' // The recipes the gallery offers and the completions the editor pops up. Both // are data derived from `NODE_DEFS`, which is why neither lives here: this file // renders them, it does not decide what the language contains. @@ -282,6 +282,7 @@ function formatArg(spec: ParamSpec, value: ArgValue | undefined): string { // The parser strips the `#`; put it back so the node reads like the line the // author wrote. if (spec.type === 'tag') return `#${text}` + if (spec.type === 'tags') return tagListText(text) if (QUOTED_TYPES.has(spec.type) && /\s/.test(text)) return `"${text}"` return text } diff --git a/packages/app-core/src/components/ui/Toast.tsx b/packages/app-core/src/components/ui/Toast.tsx index 19213863..44f9bef6 100644 --- a/packages/app-core/src/components/ui/Toast.tsx +++ b/packages/app-core/src/components/ui/Toast.tsx @@ -27,7 +27,12 @@ export function ToastHost(): JSX.Element | null { if (toasts.length === 0) return null return ( -
+ // `data-toast-host` is the hook the phone shells reposition toasts by: the + // corner it pins them to is where their floating button lives. +
{toasts.map((t) => (
piece.trim()).filter(Boolean)) + .split(',') + .filter(Boolean) + .join(', ') +} + const INPUT_CLASS = 'w-full rounded-md border border-paper-300 bg-paper-50 px-2 py-1 text-xs text-ink-900 outline-none focus-visible:ring-1 focus-visible:ring-accent/50' @@ -276,13 +285,22 @@ function ParamControl({ disabled: boolean onChange: (value: ArgValue) => void }): JSX.Element { - const stored = displayValue(value) + const stored = spec.type === 'tags' ? tagListDisplay(displayValue(value)) : displayValue(value) const [text, setText] = useState(stored) + const echo = useRef(null) + const seededFor = useRef(nodeKey) // Re-seed from the file whenever the step changes or the stored value moves // underneath us (a text edit, an undo). A value we just pushed comes back // identical, so this is a no-op for our own echo and the caret stays put. + // A tag list is the exception: `book, ` comes back as `book`, so its echo is + // recognized by value instead, or the re-seed would eat the separator the + // author is about to type after. useEffect(() => { + const sameStep = seededFor.current === nodeKey + seededFor.current = nodeKey + if (sameStep && echo.current === stored) return + echo.current = null setText(stored) }, [nodeKey, stored]) @@ -299,6 +317,23 @@ function ParamControl({ onChange(clean) return } + case 'tags': { + // Each comma-separated entry is cleaned like a single tag; the commas, + // and the space after one, stay so the next tag can be typed. + const clean = next + .split(',') + .map((piece, i) => { + const name = sanitizeTagChars(piece.trim()) + if (i === 0) return name + // An entry still empty keeps the author's spacing, so Backspace works. + return name ? ` ${name}` : piece.startsWith(' ') ? ' ' : '' + }) + .join(',') + setText(clean) + echo.current = tagListDisplay(clean) + onChange(clean) + return + } case 'number': { setText(next) // An empty box is a number being retyped, not a number being deleted. @@ -386,6 +421,8 @@ function ParamControl({ return select(vocabulary.wires) case 'tag': return combobox(vocabulary.tags, 'book') + case 'tags': + return plain('book, article') case 'field': return combobox([...BUILTIN_FIELDS, ...vocabulary.fields], 'updated') case 'path': @@ -432,6 +469,12 @@ function ParamControl({ Written to the file with a #. Nested tags match, so book keeps book/scifi. )} + {spec.type === 'tags' && ( + + One tag, or several separated by commas: book, article. Written to the file with #s, + and nested tags match. + + )} {spec.type === 'rest' && ( Free text. {'{{date}}'} and any field expand per note. diff --git a/packages/app-core/src/lib/cm-table-menu.ts b/packages/app-core/src/lib/cm-table-menu.ts index 5e9342fb..6a67cd00 100644 --- a/packages/app-core/src/lib/cm-table-menu.ts +++ b/packages/app-core/src/lib/cm-table-menu.ts @@ -16,9 +16,11 @@ import { setColumnAlign, sortByColumn, columnCount, + serializeTable, type ColumnAlign, type MarkdownTable } from './markdown-table' +import { writeClipboardText } from './clipboard-text' export interface TableMenuRequest { x: number @@ -32,6 +34,8 @@ export interface TableMenuRequest { * place (#832). Unlike `apply`, this leaves the table model alone: the * converter reads the committed document itself. Omitted = no such item. */ convertToDatabase?: () => void + /** Insert a copy of the whole table below it. Omitted = no such item. */ + duplicateTable?: () => void } type MenuItem = @@ -47,7 +51,7 @@ export function closeTableContextMenu(): void { export function openTableContextMenu(req: TableMenuRequest): void { closeTableContextMenu() - const { row, col, model, apply, convertToDatabase } = req + const { row, col, model, apply, convertToDatabase, duplicateTable } = req // Restore focus to whatever opened the menu (e.g. a table cell) on close, // unless an action ran — that focuses its own target cell. const previouslyFocused = document.activeElement as HTMLElement | null @@ -147,6 +151,19 @@ export function openTableContextMenu(req: TableMenuRequest): void { label: 'Sort column (Z → A)', run: () => apply(sortByColumn(model, col, 'desc')) }, + { kind: 'sep' }, + { + kind: 'item', + label: 'Copy table as Markdown', + run: () => { + writeClipboardText(serializeTable(model)) + // Nothing changed, so the cell that opened the menu keeps the caret. + previouslyFocused?.focus?.() + } + }, + ...(duplicateTable + ? [{ kind: 'item', label: 'Duplicate table', run: duplicateTable } as const] + : []), ...(convertToDatabase ? [ { kind: 'sep' } as const, diff --git a/packages/app-core/src/lib/cm-table.test.ts b/packages/app-core/src/lib/cm-table.test.ts index 0adbd6e7..48707b3e 100644 --- a/packages/app-core/src/lib/cm-table.test.ts +++ b/packages/app-core/src/lib/cm-table.test.ts @@ -243,6 +243,43 @@ describe('tablePlugin', () => { view.destroy() }) + const menuItem = (label: string): HTMLButtonElement => + Array.from(document.querySelectorAll('.cm-table-menu .cm-table-menu-item')).find( + (button) => button.textContent === label + )! + + it('the table menu copies the whole table as Markdown and keeps the caret', () => { + const bridge = window as unknown as { zen?: Record } + const previous = bridge.zen + const clipboardWriteText = vi.fn() + bridge.zen = { ...(previous ?? {}), clipboardWriteText } + try { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector('.cm-table-widget [data-row="0"][data-col="0"]')! + cell.focus() + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', bubbles: true, cancelable: true })) + menuItem('Copy table as Markdown').click() + expect(clipboardWriteText).toHaveBeenCalledWith( + '| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |' + ) + expect(document.activeElement).toBe(cell) + expect(view.state.doc.toString()).toBe(TABLE_DOC) + view.destroy() + } finally { + bridge.zen = previous + } + }) + + it('the table menu duplicates the table right below itself', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector('.cm-table-widget [data-row="1"][data-col="1"]')! + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', bubbles: true, cancelable: true })) + menuItem('Duplicate table').click() + const table = '| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |' + expect(view.state.doc.toString()).toBe(`Intro text.\n\n${table}\n\n${table}\n\nOutro text.`) + view.destroy() + }) + it('supports x / dd / D editing operators in a cell', () => { const view = mount(TABLE_DOC) const cell = view.dom.querySelector( @@ -503,6 +540,105 @@ describe('tablePlugin', () => { view.destroy() }) + // Tornado300 (Discord, 2026-10-09): copying whole rows gave tab-separated cell + // text, and the platform copy shortcut ran as Vim's `c` and emptied the rows. + const typeKeys = (...keys: string[]): void => { + for (const key of keys) { + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + } + } + const chord = (key: string): void => { + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key, ...modKey, bubbles: true, cancelable: true }) + ) + } + const cellTexts = (view: EditorView): string[] => + Array.from(view.dom.querySelectorAll('.cm-table-widget .cm-table-cell')).map( + (cell) => cell.dataset.raw ?? '' + ) + const WHOLE_TABLE = '| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |' + + it('a visual-line yank from the header takes the rows as a Markdown table, linewise', () => { + const view = mount(TABLE_DOC) + unnamedRegister().setText('') + view.dom.querySelector('.cm-table-widget [data-row="-1"][data-col="0"]')!.focus() + typeKeys('V', 'j', 'j', 'y') + expect(unnamedRegister().toString()).toBe(`${WHOLE_TABLE}\n`) + expect((unnamedRegister() as unknown as { linewise: boolean }).linewise).toBe(true) + expect(cellTexts(view)).toEqual(['Name', 'Age', 'Alice', '30', 'Bob', '25']) + view.destroy() + }) + + it('a visual-line yank of body rows takes just those rows', () => { + const view = mount(TABLE_DOC) + unnamedRegister().setText('') + view.dom.querySelector('.cm-table-widget [data-row="0"][data-col="1"]')!.focus() + typeKeys('V', 'j', 'y') + expect(unnamedRegister().toString()).toBe('| Alice | 30 |\n| Bob | 25 |\n') + view.destroy() + }) + + it('the platform copy shortcut copies a visual selection and keeps the cells', () => { + const bridge = window as unknown as { zen?: Record } + const previous = bridge.zen + const clipboardWriteText = vi.fn() + bridge.zen = { ...(previous ?? {}), clipboardWriteText } + try { + const view = mount(TABLE_DOC) + view.dom.querySelector('.cm-table-widget [data-row="-1"][data-col="0"]')!.focus() + typeKeys('V', 'j', 'j') + chord('c') + expect(clipboardWriteText).toHaveBeenCalledWith(`${WHOLE_TABLE}\n`) + expect(cellTexts(view)).toEqual(['Name', 'Age', 'Alice', '30', 'Bob', '25']) + expect(view.dom.querySelectorAll('.cm-table-cell.is-vim-visual')).toHaveLength(0) + // Within a row, a charwise selection still copies the cells' text. + view.dom.querySelector('.cm-table-widget [data-row="0"][data-col="0"]')!.focus() + typeKeys('v', 'l') + chord('c') + expect(clipboardWriteText).toHaveBeenLastCalledWith('Al') + view.destroy() + } finally { + bridge.zen = previous + } + }) + + it('the platform cut shortcut copies a visual selection, then empties it', () => { + const bridge = window as unknown as { zen?: Record } + const previous = bridge.zen + const clipboardWriteText = vi.fn() + bridge.zen = { ...(previous ?? {}), clipboardWriteText } + try { + const view = mount(TABLE_DOC) + view.dom.querySelector('.cm-table-widget [data-row="0"][data-col="0"]')!.focus() + typeKeys('V') + chord('x') + expect(clipboardWriteText).toHaveBeenCalledWith('| Alice | 30 |\n') + expect(cellTexts(view)).toEqual(['Name', 'Age', '', '', 'Bob', '25']) + view.destroy() + } finally { + bridge.zen = previous + } + }) + + it('other Cmd or Ctrl chords are left to the app instead of running as Vim letters', () => { + const view = mount(TABLE_DOC) + const alice = view.dom.querySelector('.cm-table-widget [data-row="0"][data-col="0"]')! + alice.focus() + // Save in NORMAL mode used to run `s`; with a visual selection, `s` changed it. + chord('s') + typeKeys('V') + chord('s') + chord('d') + expect(cellTexts(view)).toEqual(['Name', 'Age', 'Alice', '30', 'Bob', '25']) + expect(view.dom.querySelectorAll('.cm-table-cell.is-vim-visual')).toHaveLength(2) + typeKeys('Escape') + // Copy in NORMAL mode used to arm `c`, so the next motion changed text. + chord('c') + typeKeys('w') + expect(cellTexts(view)).toEqual(['Name', 'Age', 'Alice', '30', 'Bob', '25']) + view.destroy() + }) + it('visual p replaces the selection and swaps it into the register (#706)', () => { const view = mount(TABLE_DOC) const cell = view.dom.querySelector( diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index 8d4a278f..b727d94a 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -47,7 +47,8 @@ import { renderMarkdown } from './markdown' import { getCM, Vim } from '@replit/codemirror-vim' import { undo, redo } from '@codemirror/commands' import { useStore } from '../store' -import { matchesSequenceToken, matchesShortcutBinding } from './keymaps' +import { isMacPlatform, matchesSequenceToken, matchesShortcutBinding, type KeymapId } from './keymaps' +import { writeClipboardText } from './clipboard-text' import { followLinkTarget } from './follow-link' import { linkRangeAtCursor, type LineLinkKind } from './internal-links' @@ -105,6 +106,16 @@ function saveVimRegister(operator: 'yank' | 'delete' | 'change', text: string): vimRegisters()?.pushText('"', operator, text, false, false) } +/** Whole table rows go in as lines, so `p` in the note puts them on lines of their + * own, the way a linewise yank does, instead of splicing them into a line. */ +function saveVimRegisterLines(text: string): void { + if (!text) return + vimRegisters()?.pushText('"', 'yank', text, true, false) +} + +/** Navigation a user may have remapped onto a Ctrl chord (#213). */ +const NAV_KEYMAP_IDS: KeymapId[] = ['nav.moveLeft', 'nav.moveRight', 'nav.moveDown', 'nav.moveUp'] + /** The unnamed register's content flattened to fit a single-line cell: a * linewise yank sheds its trailing newline, and interior line/cell breaks * (a multi-line yank from the editor, a multi-cell table yank) become @@ -720,7 +731,8 @@ class TableWidget extends WidgetType { col, model: this.model, apply: (next, focus) => this.applyMenuAction(next, focus, anchor), - convertToDatabase: () => this.convertToDatabase(anchor) + convertToDatabase: () => this.convertToDatabase(anchor), + duplicateTable: () => this.duplicateTable(anchor) }) }) cell.append(editable) @@ -913,6 +925,17 @@ class TableWidget extends WidgetType { if (!event.metaKey && !event.ctrlKey && !event.altKey) { event.stopPropagation() } + // A chord is an app or system shortcut, never a Vim command. Read as plain + // letters, the platform copy (Cmd-C, Ctrl-C off macOS) ran `c` and emptied a + // visual selection, and Cmd-S ran `s`. Copy and cut act on the selection; + // everything else is left to the app. + if (this.isAppChord(event)) { + if (this.visualMode && (this.isPlatformChord(event, 'c') || this.isPlatformChord(event, 'x'))) { + event.preventDefault() + this.copyVisualSelection(this.isPlatformChord(event, 'x')) + } + return + } const cellText = editable.dataset.raw ?? '' if (this.visualMode) { event.preventDefault() @@ -1793,10 +1816,59 @@ class TableWidget extends WidgetType { // the text can be pasted anywhere with `p` — another cell or the note body. // The system clipboard now follows the yank-to-clipboard setting (the // register patch mirrors it) instead of being written unconditionally. - saveVimRegister('yank', this.visualSelectionText(ranges)) + const rows = this.visualSelectionMarkdown() + if (rows !== null) saveVimRegisterLines(rows) + else saveVimRegister('yank', this.visualSelectionText(ranges)) this.finishVisualAtStart(first, false) } + /** The platform copy or cut of a visual selection: the same text a yank takes, + * written to the system clipboard. Cut then empties the cells, as `d` does. */ + private copyVisualSelection(cut: boolean): void { + const ranges = this.visualSelectionRanges() + const first = ranges[0] + if (!first) return + const rows = this.visualSelectionMarkdown() + writeClipboardText(rows !== null ? `${rows}\n` : this.visualSelectionText(ranges)) + if (cut) this.deleteVisualSelection(false) + else this.finishVisualAtStart(first, false) + } + + /** A visual-line selection as Markdown table rows, laid out with the whole + * table's column widths. The header brings its delimiter row, so a selection + * from the header down pastes as a complete table. Null outside line mode. */ + private visualSelectionMarkdown(): string | null { + if (this.visualMode !== 'line' || !this.visualAnchor || !this.visualHead) return null + this.syncFromDom() + const firstRow = Math.min(this.visualAnchor.row, this.visualHead.row) + const lastRow = Math.max(this.visualAnchor.row, this.visualHead.row) + // Header, delimiter, then one line per body row. + const lines = serializeTable({ ...this.model, colWidths: undefined }).split('\n') + const picked = firstRow < 0 ? [lines[0], lines[1]] : [] + for (let row = Math.max(0, firstRow); row <= lastRow; row++) { + const line = lines[row + 2] + if (line !== undefined) picked.push(line) + } + return picked.join('\n') + } + + /** A modified key the widget leaves to the app: anything with Cmd, and Ctrl + * chords other than Vim's Ctrl-r redo or a navigation key remapped onto one. + * Ctrl+Alt is AltGr, which types characters (`@` on a German layout). */ + private isAppChord(event: KeyboardEvent): boolean { + if (event.metaKey) return true + if (!event.ctrlKey || event.altKey) return false + if (event.key === 'r') return false + const overrides = useStore.getState().keymapOverrides + return !NAV_KEYMAP_IDS.some((id) => matchesSequenceToken(event, overrides, id)) + } + + /** Cmd (Ctrl off macOS) plus `key`: the platform clipboard shortcut. */ + private isPlatformChord(event: KeyboardEvent, key: string): boolean { + const primary = isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey + return primary && !event.altKey && event.key.toLowerCase() === key + } + /** Visual `p`/`P`: the register replaces the selection; the replaced text * swaps into the unnamed register, per Vim. (#706) */ private pasteVisualSelection(): void { @@ -2017,7 +2089,8 @@ class TableWidget extends WidgetType { col, model: this.model, apply: (next, focus) => this.applyMenuAction(next, focus, anchor), - convertToDatabase: () => this.convertToDatabase(anchor) + convertToDatabase: () => this.convertToDatabase(anchor), + duplicateTable: () => this.duplicateTable(anchor) }) } @@ -2031,6 +2104,20 @@ class TableWidget extends WidgetType { void convertTableToDatabase(this.view, pos) } + /** A copy of the table right below it, a blank line apart, with its column + * widths: the table menu's Duplicate table. */ + private duplicateTable(anchor?: number): void { + this.commitIfDirty() + const pos = anchor ?? this.captureTableAnchor() + if (pos == null) return + const range = tableBlockAt(this.view.state.doc, pos) + if (!range) return + this.view.dispatch({ + changes: { from: range.to, insert: `\n\n${this.view.state.sliceDoc(range.from, range.to)}` } + }) + requestAnimationFrame(() => this.view.focus()) + } + ignoreEvent(): boolean { return true } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 5c41aba9..1e8c4957 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -280,9 +280,9 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ 'A reading list is a checklist, not a to-do. Add `tasks: false` (or `off`) to a note’s frontmatter and its checkboxes stay checkboxes: they render and toggle exactly as before, but stop feeding the Tasks list, the boards, the calendars, `zn task list`, and the MCP tools. On a #task note, `tasks: note` keeps the note itself on the board while silencing its internal checklist, so a project note can be one card instead of twenty. To retire a whole folder (a media backlog, a reference library), right-click it in the sidebar and choose “Exclude from Tasks”, or manage the list under Settings → Tasks; the exclusion is stored in the vault’s own settings, so desktop, web, mobile, and the CLI all agree. When you do want to see past it, `zn task list --include-excluded` and the MCP `list_tasks` tool’s `includeExcluded` flag list everything.' }, { - title: 'Any line becomes a checkbox with ⌘L', + title: 'Check off a task with ⌘L', body: - 'Press `⌘L` (`Ctrl+L` on Windows/Linux) in the editor to turn the current line into a checkbox and toggle it on repeat: plain text becomes `- [ ] text`, an existing bullet or numbered item keeps its marker (`* note` becomes `* [ ] note`), and pressing again flips `[ ]` to `[x]` and back. An in-progress `[/]` checks off to `[x]`; forwarded `[>]` and cancelled `[-]` lines are left alone, since those states have their own commands. It applies to every line of a multi-line selection, works with Vim mode on or off, is remappable as `editor.toggleCheckbox` under `[keymaps]` in `config.toml`, and is also in the command palette as “Toggle Checkbox”.' + 'Press `⌘L` (`Ctrl+L` on Windows/Linux) in the editor to mark the task under the cursor done, or to turn the current line into a checkbox and toggle it on repeat: plain text becomes `- [ ] text`, an existing bullet or numbered item keeps its marker (`* note` becomes `* [ ] note`), and pressing again flips `[ ]` to `[x]` and back. An in-progress `[/]` checks off to `[x]`; forwarded `[>]` and cancelled `[-]` lines are left alone, since those states have their own commands. It applies to every line of a multi-line selection, works with Vim mode on or off (in Vim it works from normal, insert, and visual mode and leaves you in the mode you were in, so `Vjj` then `⌘L` checks off three tasks and one `u` undoes them), is remappable as `editor.toggleCheckbox` under `[keymaps]` in `config.toml`, and is also in the command palette as “Toggle Checkbox”.' }, { title: 'Reflow a hard-wrapped paragraph', @@ -487,7 +487,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The workflow grammar in one card', body: - 'Six sources open a set: `all`, `folder inbox`, `tag #book`, `search reading list`, `current`, `selection` (the Trash and Archive stay out unless you name them, like `folder trash`; the four system names `inbox`, `quick`, `archive`, `trash` mean those folders wherever the vault keeps them, so on a vault whose notes live at the root `folder inbox` is the vault root). Twelve steps filter and shape it: `where rating >= 4`, `tagged` / `not-tagged #x`, `in inbox/projects`, `matching inbox/**/*.md`, `contains TODO`, `since 7d`, `sort finished desc`, `limit 25`, `dedupe`, and `union` / `subtract ` to combine named wires. Mutating steps change every note on the wire, always behind the dry-run confirmation: `set status done`, `add-tag` / `remove-tag`, `move`, `rename {{date}}-{{title}}`, `append` / `prepend`, `apply-template`, `archive`, `trash`. Outputs turn the wire into text: `render table title, rating` feeds `write "Log.md"`, `write-section "Log.md" "Finished"`, `create-each "inbox/{{title}}.md"`, `notify`, or `clipboard`, and `call ` folds another workflow into the same run. `{{title}}`, `{{date}}`, `{{count}}` and any frontmatter field expand per note. Press `?` in the Workflows view for the live reference, with a one-line description and a real example for every step.' + 'Six sources open a set: `all`, `folder inbox`, `tag #book`, `search reading list`, `current`, `selection` (the Trash and Archive stay out unless you name them, like `folder trash`; the four system names `inbox`, `quick`, `archive`, `trash` mean those folders wherever the vault keeps them, so on a vault whose notes live at the root `folder inbox` is the vault root). Thirteen steps filter and shape it: `where rating >= 4`, `tagged` / `not-tagged #x`, `no-tags`, `in inbox/projects`, `matching inbox/**/*.md`, `contains TODO`, `since 7d`, `sort finished desc`, `limit 25`, `dedupe`, and `union` / `subtract ` to combine named wires. `tag`, `tagged` and `not-tagged` also take a comma-separated list and match any tag in it, so `all | not-tagged #someday, #reference` keeps the notes carrying neither, and `all | no-tags` keeps the notes with no tag at all. Mutating steps change every note on the wire, always behind the dry-run confirmation: `set status done`, `add-tag` / `remove-tag`, `move`, `rename {{date}}-{{title}}`, `append` / `prepend`, `apply-template`, `archive`, `trash`. Outputs turn the wire into text: `render table title, rating` feeds `write "Log.md"`, `write-section "Log.md" "Finished"`, `create-each "inbox/{{title}}.md"`, `notify`, or `clipboard`, and `call ` folds another workflow into the same run. `{{title}}`, `{{date}}`, `{{count}}` and any frontmatter field expand per note. Press `?` in the Workflows view for the live reference, with a one-line description and a real example for every step.' }, { title: 'Event triggers', @@ -529,7 +529,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Shift+Mod+T', action: 'Reopen closed tab', detail: 'Reopen the most recently closed tab, restoring its position and pinned state. Repeat to walk back through your close history.' }, { keys: 'Mod+O', action: 'Open file', detail: 'Desktop only: pick a Markdown file with the native dialog. A file inside a known vault opens against that vault; anything else opens in a standalone external-file window. Links in that window follow from the file\'s own folder: a relative link such as `../README.md` opens the file it names, and a `[[wikilink]]` finds a page of that name in the folder or below it, each in its own window (or in its vault, when the target lives in one).' }, { keys: 'Mod+4 / Mod+5 / Mod+6', action: 'Edit / Split / Preview mode', detail: 'Switch the active note between the raw editor, side-by-side split, and rendered preview. Preview opens at the line you were editing; Edit and Split open on the section you were reading when you scrolled away from the cursor in Preview, and keep the cursor where it was when you only peeked.' }, - { keys: 'Mod+L', action: 'Toggle checkbox', detail: 'Turn the current line into a checkbox and toggle it on repeat. See the “Any line becomes a checkbox” card in Core concepts for the full state rules.' }, + { keys: 'Mod+L', action: 'Toggle checkbox', detail: 'Turn the current line into a checkbox and toggle it on repeat. See the “Check off a task with ⌘L” card in Core concepts for the full state rules.' }, { keys: 'Alt+Q (macOS: Ctrl+Q)', action: 'Reflow paragraph', detail: 'Join the hard-wrapped lines of the paragraph under the cursor (or every paragraph in the selection) into one line, so the editor wraps it to the pane. Headings, lists, tables, code, and explicit line breaks are untouched. See the “Reflow a hard-wrapped paragraph” card. Remappable as editor.reflowParagraph.' }, { keys: 'Shift+Mod+E', action: 'Export note as PDF', detail: 'Export the active note as a PDF file.' }, { keys: 'Mod+=', action: 'Zoom in', detail: 'Scale the whole app up, including chrome, editor, and preview.' }, @@ -953,6 +953,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Open a line, continuing the list', detail: 'On a list item, `o` (below) and `O` (above) carry the marker forward like pressing Enter: bullets repeat, numbered lists advance and renumber, checkboxes start a fresh unchecked box, and indentation is kept. On a non-list line they open a plain new line as usual.' }, + { + command: 'Mod+L', + summary: 'Check off the task under the cursor', + detail: 'Flips the checkbox on the cursor line between `[ ]` and `[x]` without leaving the mode you are in: normal mode stays normal, insert mode keeps typing. In visual mode it toggles every selected line (`Vjj` then Mod+L checks off three tasks), and one `u` undoes the lot. An in-progress `[/]` checks off to `[x]`, forwarded `[>]` and cancelled `[-]` lines are left alone, and a line with no checkbox becomes one. Rebind it as `editor.toggleCheckbox` (a single key chord, not a Vim sequence).' + }, { command: ' / on the ex line', summary: 'Complete ex commands', @@ -1116,7 +1121,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Vault text search backend and binary paths', detail: 'Choose Auto, the built-in searcher, ripgrep, or fzf for vault-wide text search. Auto prefers system tools when they are installed and falls back cleanly when they are not, you can provide explicit binary paths for ripgrep or fzf if they are not on your PATH, and Settings now shows the resolved runtime backend that will actually be used.' }, { label: 'Live preview', detail: 'Hide markdown syntax on lines you are not actively editing. Also draws math, tables, and `mermaid` diagrams in place; each turns back into its source when the cursor enters it.' }, { label: 'Math size', detail: 'Scales inline `$…$` and block `$$…$$` math relative to the surrounding text, 50 to 200 percent, in the editor and the reading view, for both KaTeX and Typst; `math_font_scale` under `[editor]` in `config.toml`.' }, - { label: 'Render tables in live preview', detail: 'Show Markdown tables as interactive WYSIWYG widgets (edit cells, drag, right-click/`m` menu). Turn it off to keep tables as plain markdown text so you can edit them with the keyboard and Vim motions like any other line. When widgets are on, Arrow keys (and h/j/k/l) navigate cells; Shift+V then Shift+J/Shift+K move whole lines in the raw source. In Vim mode the cell cursor speaks the editor\'s language: motions (w/b/e, f/t, 0/$), operators (d/c/y with motions and text objects), visual selections, and yank/paste (y/p/P) through the same registers as the rest of the note, so you can yank in a cell and paste in the body, or the other way around.' }, + { label: 'Render tables in live preview', detail: 'Show Markdown tables as interactive WYSIWYG widgets (edit cells, drag, right-click/`m` menu). Turn it off to keep tables as plain markdown text so you can edit them with the keyboard and Vim motions like any other line. When widgets are on, Arrow keys (and h/j/k/l) navigate cells; Shift+V then Shift+J/Shift+K move whole lines in the raw source. In Vim mode the cell cursor speaks the editor\'s language: motions (w/b/e, f/t, 0/$), operators (d/c/y with motions and text objects), visual selections, and yank/paste (y/p/P) through the same registers as the rest of the note, so you can yank in a cell and paste in the body, or the other way around. Shift+V selects whole rows: y takes them as Markdown table rows (from the header, with its divider row), so p in the note pastes a working table, and ⌘C (Ctrl+C on Windows and Linux) copies a selection without changing it. The `m` menu can also copy the whole table as Markdown or duplicate it below.' }, { label: 'Sync title heading on rename', detail: 'On by default. A new note is created as `# `, and with this on a rename carries that heading along — rename `Untitled` to `Groceries` and line one becomes `# Groceries`, from the breadcrumb, the sidebar, or the note list alike. Only an existing top-level `#` heading is rewritten and one is never invented, so a note that opens with prose, a list, or a `##` heading is untouched; deleting the `#` line opts that note out permanently. The heading is found after any frontmatter, and the rest of the note is left byte for byte as it was.' }, { label: 'Grammar and spelling with Harper', detail: 'Off by default. Turn it on to underline grammar and spelling problems as you write, checked on this device by Harper (writewithharper.com); no text leaves the app. Hover a mark for the fixes, or in Vim mode press z= at the problem, ]s and [s to move between problems, zg to add a word to this vault\'s dictionary and zG to ignore one suggestion. Choose the English dialect below the toggle. Code, links and frontmatter are never checked, and notes past 120,000 characters are skipped.' }, { label: 'Heading level labels', detail: 'Show H1 through H6 badges before headings. Heading fold arrows stay available whether labels are on or off.' }, diff --git a/packages/app-core/src/lib/pane-nav.ts b/packages/app-core/src/lib/pane-nav.ts index 68aee013..76c03330 100644 --- a/packages/app-core/src/lib/pane-nav.ts +++ b/packages/app-core/src/lib/pane-nav.ts @@ -7,7 +7,7 @@ * mental model matches what they see on screen — sibling panes in a * deeply nested split still look like simple neighbors. */ -import { isAtlasViewActive, isTasksViewActive, useStore } from '../store' +import { isAtlasViewActive, isTagsViewActive, isTasksViewActive, useStore } from '../store' import { getVisiblePanelsNow, resolveNextPanel, type Panel } from './vim-nav' import { ROW_PANEL_DEFS, @@ -106,7 +106,8 @@ function getVisiblePanelList(state: ReturnType<typeof useStore.getState>): Panel noteListOpen: state.noteListOpen, unifiedSidebar: state.unifiedSidebar, tasksViewOpen: isTasksViewActive(state), - atlasViewOpen: isAtlasViewActive(state) + atlasViewOpen: isAtlasViewActive(state), + tagsViewOpen: isTagsViewActive(state) }) } diff --git a/packages/app-core/src/lib/scrollbar-style.test.ts b/packages/app-core/src/lib/scrollbar-style.test.ts new file mode 100644 index 00000000..0a2dc43a --- /dev/null +++ b/packages/app-core/src/lib/scrollbar-style.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const stylesSource = readFileSync(new URL('../styles/index.css', import.meta.url), 'utf8').replace( + /\/\*[\s\S]*?\*\//g, + '' +) + +// Every `::-webkit-scrollbar...` rule body in the stylesheet, keyed by selector. +function scrollbarRules(): Array<{ selector: string; body: string }> { + return [...stylesSource.matchAll(/([^{}]*::-webkit-scrollbar[^{}]*)\{([^}]*)\}/g)].map((match) => ({ + selector: match[1].trim(), + body: match[2] + })) +} + +describe('scrollbar styles', () => { + it('never sets opacity on a scrollbar part, which Chromium ignores', () => { + // The sidebar thumb once asked for `opacity: 0.3` and was painted at full + // text colour, because scrollbar pseudo-elements do not take opacity. + const withOpacity = scrollbarRules().filter((rule) => /(^|[\s;])opacity\s*:/.test(rule.body)) + expect(withOpacity.map((rule) => rule.selector)).toEqual([]) + }) + + it('tints the sidebar thumb with its text colour at 30%, and 50% under the pointer (#451)', () => { + const rules = scrollbarRules() + const thumb = rules.find((rule) => rule.selector === '.zn-sidebar-scroll::-webkit-scrollbar-thumb') + const hover = rules.find((rule) => rule.selector === '.zn-sidebar-scroll::-webkit-scrollbar-thumb:hover') + expect(thumb?.body).toMatch(/background:\s*color-mix\(in srgb, currentColor 30%, transparent\)/) + expect(hover?.body).toMatch(/background:\s*color-mix\(in srgb, currentColor 50%, transparent\)/) + }) +}) diff --git a/packages/app-core/src/lib/vim-nav.test.ts b/packages/app-core/src/lib/vim-nav.test.ts index 43b09394..e23259c0 100644 --- a/packages/app-core/src/lib/vim-nav.test.ts +++ b/packages/app-core/src/lib/vim-nav.test.ts @@ -127,6 +127,17 @@ describe('getVisiblePanels — the focus cycle (#285, #477)', () => { ...over }) + // Tornado300 (Discord, 2026-10-09): Ctrl-w l from the sidebar landed on + // 'editor', where the Tags view's keys stand down. + it('puts the Tags view in the editor slot so pane navigation can return to it', () => { + expect(getVisiblePanels(visibility({ tagsViewOpen: true, outlineOpen: true }))).toEqual([ + 'sidebar', + 'notelist', + 'tags', + 'outline' + ]) + }) + it('appends the calendar last (after connections/comments) when open', () => { expect(getVisiblePanels(visibility({ calendarOpen: true }))).toEqual([ 'sidebar', diff --git a/packages/app-core/src/lib/vim-nav.ts b/packages/app-core/src/lib/vim-nav.ts index 3e6c768d..057a2f8e 100644 --- a/packages/app-core/src/lib/vim-nav.ts +++ b/packages/app-core/src/lib/vim-nav.ts @@ -65,6 +65,7 @@ export interface PanelVisibility { calendarOpen: boolean tasksViewOpen: boolean atlasViewOpen?: boolean + tagsViewOpen?: boolean } /** Every panel on screen, ordered left to right — the focus order both pane @@ -74,7 +75,15 @@ export function getVisiblePanels(visibility: PanelVisibility): Panel[] { const panels: Panel[] = [] if (visibility.sidebarOpen) panels.push('sidebar') if (visibility.noteListOpen && !visibility.unifiedSidebar) panels.push('notelist') - panels.push(visibility.tasksViewOpen ? 'tasks' : visibility.atlasViewOpen ? 'atlas' : 'editor') + panels.push( + visibility.tasksViewOpen + ? 'tasks' + : visibility.atlasViewOpen + ? 'atlas' + : visibility.tagsViewOpen + ? 'tags' + : 'editor' + ) if (visibility.connectionsOpen) panels.push('connections') if (visibility.commentsOpen) panels.push('comments') if (visibility.outlineOpen) panels.push('outline') @@ -105,6 +114,7 @@ export function getVisiblePanelsNow(state: { unifiedSidebar: boolean tasksViewOpen: boolean atlasViewOpen?: boolean + tagsViewOpen?: boolean }): Panel[] { const onScreen = (selector: string): boolean => typeof document !== 'undefined' && document.querySelector(selector) !== null @@ -114,6 +124,7 @@ export function getVisiblePanelsNow(state: { unifiedSidebar: state.unifiedSidebar, tasksViewOpen: state.tasksViewOpen, atlasViewOpen: state.atlasViewOpen, + tagsViewOpen: state.tagsViewOpen, connectionsOpen: onScreen(PANEL_MARKERS.connections), commentsOpen: onScreen(PANEL_MARKERS.comments), outlineOpen: onScreen(PANEL_MARKERS.outline), diff --git a/packages/app-core/src/store-new-task.test.ts b/packages/app-core/src/store-new-task.test.ts new file mode 100644 index 00000000..aec0412f --- /dev/null +++ b/packages/app-core/src/store-new-task.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TASKS_TAB_PATH } from '@shared/tasks' +import { makeLeaf } from './lib/pane-layout' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() +}) + +async function setup(onTasksView: boolean) { + const createNote = vi.fn(async (_folder: string, title: string) => ({ + path: `inbox/${title}.md`, + title, + folder: 'inbox' + })) + const writeNote = vi.fn(async () => {}) + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}), createNote, writeNote } + }) + const { useStore } = await import('./store') + const { useToastStore } = await import('./lib/toast') + const prompts = await import('./lib/prompt-requests') + const selectNote = vi.fn(async () => {}) + const leaf = onTasksView ? makeLeaf([TASKS_TAB_PATH], TASKS_TAB_PATH) : makeLeaf(['inbox/Notes.md'], 'inbox/Notes.md') + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + paneLayout: leaf, + activePaneId: leaf.id, + refreshTasks: vi.fn(async () => {}), + selectNote + }) + const create = async (title: string | null): Promise<string | null> => { + const pending = useStore.getState().newTaskFile() + await vi.waitFor(() => expect(prompts.getPromptRequest()).not.toBeNull()) + prompts.settlePromptRequest(prompts.getPromptRequest()!, title) + return pending + } + return { create, createNote, selectNote, toasts: () => useToastStore.getState().toasts } +} + +describe('newTaskFile', () => { + it('says the task landed, and opens it on request, when the Tasks view is not showing', async () => { + const s = await setup(false) + expect(await s.create('Buy milk')).toBe('inbox/Buy milk.md') + const toast = s.toasts().find((t) => t.message === 'Task added') + expect(toast?.type).toBe('success') + expect(toast?.action?.label).toBe('Open') + toast?.action?.onClick() + expect(s.selectNote).toHaveBeenCalledWith('inbox/Buy milk.md') + }) + + it('stays quiet on the Tasks view, where the new task appears in the list', async () => { + const s = await setup(true) + expect(await s.create('Buy milk')).toBe('inbox/Buy milk.md') + expect(s.toasts().some((t) => t.message === 'Task added')).toBe(false) + }) + + it('says nothing when the prompt is cancelled', async () => { + const s = await setup(false) + expect(await s.create(null)).toBeNull() + expect(s.createNote).not.toHaveBeenCalled() + expect(s.toasts()).toEqual([]) + }) +}) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 6c1e8932..1f5f3cb9 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -6320,6 +6320,19 @@ export const useStore = create<Store>((set, get) => { composeTaskFile({ title, dateCreated: new Date().toISOString() }) ) await get().refreshTasks() + // On the Tasks view the new task shows up in the list. Anywhere else (the + // palette, `:newtask`, a phone's Action Button) nothing on screen + // changes, so say it landed and offer the note. + if (!isTasksViewActive(get())) { + useToastStore + .getState() + .addToast( + 'Task added', + 'success', + { label: 'Open', onClick: () => void get().selectNote(meta.path) }, + 6000 + ) + } return meta.path } catch (err) { console.error('newTaskFile failed', err) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 481152b1..ed6fdd5c 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -1470,20 +1470,23 @@ textarea, } /* The sidebar is one scroll region for all its sections (Quick access, Notes, - Tags, System). With many tags/notes it scrolls, but the global thumb — a dark - shadow colour at 18% — is nearly invisible on a dark sidebar, so the scroll + Tags, System). With many tags/notes it scrolls, but the global thumb (a dark + shadow colour at 18%) is nearly invisible on a dark sidebar, so the scroll affordance reads as "the list is just cut off". Tint this scroller's thumb with the sidebar's own foreground (currentColor adapts to light/dark themes - and the dark-sidebar option) at a higher opacity so it's discoverable. (#451) */ + and the dark-sidebar option) so it's discoverable. (#451) + The strength lives in the colour because scrollbar parts ignore `opacity`: + the 0.3 this rule first used painted the thumb at full text colour. A browser + without color-mix keeps the plain currentColor line. */ .zn-sidebar-scroll::-webkit-scrollbar-thumb { background: currentColor; + background: color-mix(in srgb, currentColor 30%, transparent); background-clip: padding-box; - opacity: 0.3; } .zn-sidebar-scroll::-webkit-scrollbar-thumb:hover { background: currentColor; + background: color-mix(in srgb, currentColor 50%, transparent); background-clip: padding-box; - opacity: 0.5; } /* Keep the horizontal tab-strip scrollbar slim (6px) so it doesn't eat into the diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 72374d39..30f574f9 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.64.0", + "version": "2.65.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 2d8a0c5f..512911eb 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.64.0", + "version": "2.65.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/workflows/edit.test.ts b/packages/shared-domain/src/workflows/edit.test.ts index 68ce9a7f..cfcd11b2 100644 --- a/packages/shared-domain/src/workflows/edit.test.ts +++ b/packages/shared-domain/src/workflows/edit.test.ts @@ -354,6 +354,29 @@ describe('setStepArg', () => { expect(setStepArg(workflow, { statement: 0, index: 1 }, 'tag', '')).toBe(workflow) }) + it('stores a tag list the way the parser does, typed or pasted', () => { + const workflow = load('a = all | tagged #book') + const at = { statement: 0, index: 1 } + const typed = setStepArg(workflow, at, 'tag', 'book, article, Book') + const pasted = setStepArg(workflow, at, 'tag', '#book,#article') + expect(typed.statements[0].steps[1].args.tag).toBe('book,article') + expect(pasted.statements[0].steps[1].args.tag).toBe('book,article') + expect(body(typed)).toContain('tagged #book, #article') + expectSurvivesSave(typed) + }) + + it('reads a trailing comma as a tag list still being typed', () => { + const workflow = load('a = all | tagged #book') + const next = setStepArg(workflow, { statement: 0, index: 1 }, 'tag', 'book, ') + expect(next.statements[0].steps[1].args.tag).toBe('book') + }) + + it('refuses a tag list holding something that is not a tag', () => { + const workflow = load('a = all | tagged #book') + expect(setStepArg(workflow, { statement: 0, index: 1 }, 'tag', 'book, two words')).toBe(workflow) + expect(setStepArg(workflow, { statement: 0, index: 1 }, 'tag', ', ')).toBe(workflow) + }) + it('accepts a numeric string for a number param', () => { const workflow = load('a = all | limit 5') const next = setStepArg(workflow, { statement: 0, index: 1 }, 'count', '25') diff --git a/packages/shared-domain/src/workflows/edit.ts b/packages/shared-domain/src/workflows/edit.ts index 7fec885a..eb35eef4 100644 --- a/packages/shared-domain/src/workflows/edit.ts +++ b/packages/shared-domain/src/workflows/edit.ts @@ -35,7 +35,7 @@ import type { ArgValue, Workflow, WorkflowStatement, WorkflowStep, WorkflowTrigger } from './types' import type { NodeDef, ParamSpec } from './nodes' -import { RENDER_STYLES, isCompareOp, nodeDef } from './nodes' +import { RENDER_STYLES, isCompareOp, joinTagList, nodeDef } from './nodes' import { parseWorkflow } from './parse' import { serializeWorkflow } from './serialize' @@ -555,6 +555,17 @@ function canonicalArg(spec: ParamSpec, value: ArgValue): ArgValue | null { const bare = text.startsWith('#') ? text.slice(1) : text return TAG_VALUE_RE.test(bare) ? bare : null } + case 'tags': { + // A list from the inspector (`book, article`) or pasted from the file + // (`#book, #article`), stored the way `bindParams` stores it. Empty + // entries are a list still being typed, not a mistake. + const names = text + .split(',') + .map((piece) => piece.trim().replace(/^#/, '')) + .filter(Boolean) + if (names.length === 0 || names.some((name) => !TAG_VALUE_RE.test(name))) return null + return joinTagList(names) + } case 'compare-op': return isCompareOp(text) ? text : null case 'direction': { diff --git a/packages/shared-domain/src/workflows/engine.test.ts b/packages/shared-domain/src/workflows/engine.test.ts index 382b6344..c0729c86 100644 --- a/packages/shared-domain/src/workflows/engine.test.ts +++ b/packages/shared-domain/src/workflows/engine.test.ts @@ -392,6 +392,32 @@ describe('filters', () => { expect(await outTitles([step('all'), step('not-tagged', ['#book'])])).toHaveLength(5) }) + it('a tag list keeps a note carrying any of its tags, nested ones included', async () => { + expect(await outTitles([step('all'), step('tagged', ['#recipe,', '#project'])])).toEqual([ + 'Compiler', + 'Engine', + 'Recipe' + ]) + expect(await outTitles([step('tag', ['#meeting,', '#recipe'])])).toEqual(['Meeting', 'Recipe']) + }) + + it('`not-tagged` with a list drops a note carrying any of them', async () => { + expect(await outTitles([step('all'), step('not-tagged', ['#book,', '#project'])])).toEqual([ + 'Meeting', + 'Recipe', + 'Pipes | Tubes' + ]) + }) + + it('`no-tags` keeps only the notes with no tag at all', async () => { + const loose = note('inbox/Loose.md', 'Loose', 'inbox', [], {}, DAY) + expect( + await outTitles([step('all'), step('no-tags')], { notes: [...NOTES, loose] }) + ).toEqual(['Loose']) + // The untagged archived note stays out with the rest of the archive. + expect(await outTitles([step('all'), step('no-tags')])).toEqual([]) + }) + it('`in` narrows to a folder subtree', async () => { expect(await outTitles([step('all'), step('in', ['inbox/projects'])])).toEqual([ 'Compiler', @@ -1280,6 +1306,18 @@ describe('invariants', () => { expect(titles(plan.wires.out)).toHaveLength(4) }) + it('plans no writes for tags separated only by spaces', async () => { + // Older builds read `not-tagged #book #project` as `not-tagged #book`, so + // the line must not start meaning something wider now that lists exist. + const plan = await planPipeline([ + step('all'), + step('not-tagged', ['#book', '#project']), + step('trash') + ]) + expect(plan.ops).toEqual([]) + expect(plan.diagnostics[0].severity).toBe('error') + }) + it('aborts a pipeline at a source that is not its head', async () => { // A source past the head would REPLACE the wire with a vault-wide query, // so the `trash` after it must never see that widened set. diff --git a/packages/shared-domain/src/workflows/engine.ts b/packages/shared-domain/src/workflows/engine.ts index bb6b18bf..2e5aadc9 100644 --- a/packages/shared-domain/src/workflows/engine.ts +++ b/packages/shared-domain/src/workflows/engine.ts @@ -795,6 +795,14 @@ function renamePatternProblem(pattern: string): string | null { return null } +/** A `tags` argument: one tag, or several stored comma-joined. */ +function tagList(value: string): string[] { + return value + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) +} + /** Tags are hierarchical in ZenNotes, so `tag project` sees `project/compiler`, * matching what clicking `project` in the tag tree shows. */ function hasTag(note: WorkflowNote, tag: string): boolean { @@ -978,7 +986,12 @@ async function runStep( case 'tag': { const tag = argString(step, 'tag') if (tag === null) return missingArg(state, step, 'tag') - return keep(workingNotes(await allNotes(state, step.line)).filter((note) => hasTag(note, tag))) + const tags = tagList(tag) + return keep( + workingNotes(await allNotes(state, step.line)).filter((note) => + tags.some((one) => hasTag(note, one)) + ) + ) } case 'search': { @@ -1035,15 +1048,20 @@ async function runStep( case 'tagged': { const tag = argString(step, 'tag') if (tag === null) return missingArg(state, step, 'tag') - return keep(current.filter((note) => hasTag(note, tag))) + const tags = tagList(tag) + return keep(current.filter((note) => tags.some((one) => hasTag(note, one)))) } case 'not-tagged': { const tag = argString(step, 'tag') if (tag === null) return missingArg(state, step, 'tag') - return keep(current.filter((note) => !hasTag(note, tag))) + const tags = tagList(tag) + return keep(current.filter((note) => !tags.some((one) => hasTag(note, one)))) } + case 'no-tags': + return keep(current.filter((note) => note.tags.length === 0)) + case 'in': { const folder = argString(step, 'folder') if (folder === null) return missingArg(state, step, 'folder') diff --git a/packages/shared-domain/src/workflows/nodes.test.ts b/packages/shared-domain/src/workflows/nodes.test.ts index bcd2cc7c..a15aaa8b 100644 --- a/packages/shared-domain/src/workflows/nodes.test.ts +++ b/packages/shared-domain/src/workflows/nodes.test.ts @@ -124,6 +124,64 @@ describe('bindParams parks what it cannot bind', () => { }) }) +describe('tag lists bind as one comma-separated argument', () => { + const def = (kind: string): NodeDef => { + const found = nodeDef(kind) + if (!found) throw new Error(`no node def for ${kind}`) + return found + } + + it('binds one tag exactly as before lists existed', () => { + expect(bindParams(def('tag'), ['#book'], 1)).toEqual({ args: { tag: 'book' }, diagnostics: [] }) + }) + + it('stores a list comma-joined without the hashes, spaces or not', () => { + expect(bindParams(def('tagged'), ['#book,', '#article'], 1)).toEqual({ + args: { tag: 'book,article' }, + diagnostics: [] + }) + expect(bindParams(def('not-tagged'), ['#a,#b,', 'c'], 1).args.tag).toBe('a,b,c') + }) + + it('drops a repeated tag whatever its case, as tags match', () => { + expect(bindParams(def('tagged'), ['#Book,', '#book,', '#art'], 1).args.tag).toBe('Book,art') + }) + + it('refuses tags separated only by spaces and shows the comma form', () => { + const bound = bindParams(def('not-tagged'), ['#a', '#b'], 1) + expect(bound.args.tag).toBeUndefined() + expect(bound.args[RAW_ARG]).toBe('#a #b') + expect(bound.diagnostics).toEqual([ + { severity: 'error', message: 'separate tags with commas: `not-tagged #a, #b`', line: 1 } + ]) + }) + + it('refuses an empty entry rather than guessing what was meant', () => { + for (const tokens of [['#a,'], ['#a,,', '#b'], [',#a']]) { + const bound = bindParams(def('tagged'), tokens, 1) + expect(bound.args.tag).toBeUndefined() + expect(bound.args[RAW_ARG]).toBe(tokens.join(' ')) + expect(bound.diagnostics[0]?.message).toBe('`tagged` has an empty entry in its tag list') + } + }) + + it('names the entry that is not a tag', () => { + const bound = bindParams(def('tagged'), ['#book,', '#2024'], 1) + expect(bound.args[RAW_ARG]).toBe('#book, #2024') + expect(bound.diagnostics[0]?.message).toBe('`#2024` is not a valid tag') + }) + + it('still needs at least one tag', () => { + expect(bindParams(def('tagged'), [], 1).diagnostics).toEqual([ + { severity: 'error', message: '`tagged` needs tag', line: 1 } + ]) + }) + + it('`no-tags` takes no argument', () => { + expect(bindParams(def('no-tags'), [], 1)).toEqual({ args: {}, diagnostics: [] }) + }) +}) + describe('sanitizeTagChars — what survives typing a tag (#532)', () => { // The canvas inspector runs every keystroke through this. It has to keep // exactly what `bindParams` would accept, in any script: an ASCII-only diff --git a/packages/shared-domain/src/workflows/nodes.ts b/packages/shared-domain/src/workflows/nodes.ts index fd4c6be6..216a2ee8 100644 --- a/packages/shared-domain/src/workflows/nodes.ts +++ b/packages/shared-domain/src/workflows/nodes.ts @@ -19,13 +19,15 @@ import { RAW_ARG } from './types' /** * `rest` swallows every remaining token as a space-joined string (used by - * `render` for its column list and by `append` for free text). Every other type - * consumes exactly one token. + * `render` for its column list and by `append` for free text), and `tags` + * swallows them as a comma-separated list of tags. Every other type consumes + * exactly one token. */ export type ParamType = | 'string' | 'number' | 'tag' + | 'tags' | 'path' | 'folder' | 'field' @@ -165,9 +167,9 @@ export const NODE_DEFS: readonly NodeDef[] = [ category: 'source', title: 'Notes with tag', description: - 'Starts a pipeline with the notes carrying a tag. Trashed and archived notes stay out, like `all`.', + 'Starts a pipeline with the notes carrying a tag, or any tag of a comma-separated list. Trashed and archived notes stay out, like `all`.', example: 'books = tag #book', - params: [p('tag', 'tag')], + params: [p('tag', 'tags')], source: true, mutating: false, terminal: false @@ -235,9 +237,9 @@ export const NODE_DEFS: readonly NodeDef[] = [ category: 'filter', title: 'Tagged', description: - 'Keeps only the notes carrying a tag. Tags nest, so #project also keeps #project/compiler.', - example: 'tagged #book', - params: [p('tag', 'tag')], + 'Keeps only the notes carrying a tag, or any tag of a comma-separated list. Tags nest, so #project also keeps #project/compiler.', + example: 'tagged #book, #article', + params: [p('tag', 'tags')], source: false, mutating: false, terminal: false @@ -247,9 +249,24 @@ export const NODE_DEFS: readonly NodeDef[] = [ category: 'filter', title: 'Not tagged', description: - 'Drops the notes carrying a tag and keeps the rest. Nested tags count as carrying it.', - example: 'not-tagged #someday', - params: [p('tag', 'tag')], + 'Drops the notes carrying a tag, or any tag of a comma-separated list, and keeps the rest. Nested tags count as carrying it.', + example: 'not-tagged #someday, #reference', + params: [p('tag', 'tags')], + source: false, + mutating: false, + terminal: false + }, + { + // Not `untagged`: a step kind wins over a wire of the same name (see the + // header of `./parse`), and `untagged` is exactly how wires get named, next + // to the recipes' `unfiled` and `strays`. Reading as a verb keeps it clear. + kind: 'no-tags', + category: 'filter', + title: 'Without tags', + description: + 'Keeps only the notes that carry no tag at all, in the frontmatter or the text.', + example: 'no-tags', + params: [], source: false, mutating: false, terminal: false @@ -657,6 +674,25 @@ export function bindParams(def: NodeDef, tokens: string[], line: number): BindRe let index = 0 for (const spec of def.params) { + if (spec.type === 'tags') { + const listed = tokens.slice(index) + index = tokens.length + if (listed.length === 0) { + if (spec.required) { + diagnostics.push({ severity: 'error', message: `\`${def.kind}\` needs ${spec.name}`, line }) + } + continue + } + const bound = coerceTagList(listed, def.kind, line) + if (bound.value !== undefined) { + args[spec.name] = bound.value + continue + } + if (bound.diagnostic) diagnostics.push(bound.diagnostic) + // Parked like any other failed token, so the author's text survives a save. + args[RAW_ARG] = listed.join(' ') + return { args, diagnostics } + } if (spec.type === 'rest') { const rest = tokens.slice(index).join(' ') index = tokens.length @@ -717,6 +753,50 @@ export function bindParams(def: NodeDef, tokens: string[], line: number): BindRe return { args, diagnostics } } +/** + * A comma-separated tag list (`#a, #b`), stored comma-joined without the `#`s. + * + * Tags separated only by spaces are an error, not a list. Before lists existed, + * `not-tagged #a #b` bound as `not-tagged #a` and dropped `#b` with a warning, + * so reading the spaces as a list now would quietly change what an existing + * `tagged #a #b | trash` touches. The comma form is just as safe going the other + * way: a build without lists rejects `#a,` as a tag, and an errored statement + * plans no changes. + */ +function coerceTagList( + tokens: string[], + kind: string, + line: number +): { value?: string; diagnostic?: Diagnostic } { + const bad = (message: string): { diagnostic: Diagnostic } => ({ + diagnostic: { severity: 'error', message, line } + }) + const pieces = tokens.join(' ').split(',').map((piece) => piece.trim()) + if (pieces.some((piece) => piece === '')) return bad(`\`${kind}\` has an empty entry in its tag list`) + for (const piece of pieces) { + if (/\s/.test(piece)) { + return bad(`separate tags with commas: \`${kind} ${piece.split(/\s+/).join(', ')}\``) + } + if (!TAG_RE.test(piece)) return bad(`\`${piece}\` is not a valid tag`) + } + return { value: joinTagList(pieces) } +} + +/** + * Valid tags as a list argument stores them: no `#`, comma-joined, and a repeat + * dropped whatever its case (tags match case-insensitively). The parser and the + * inspector both store through here, so a list typed into the inspector saves + * as exactly what the file reads back. + */ +export function joinTagList(tags: string[]): string { + const names: string[] = [] + for (const tag of tags) { + const name = tag.replace(/^#/, '') + if (!names.some((known) => known.toLowerCase() === name.toLowerCase())) names.push(name) + } + return names.join(',') +} + function coerce( spec: ParamSpec, token: string, diff --git a/packages/shared-domain/src/workflows/serialize.test.ts b/packages/shared-domain/src/workflows/serialize.test.ts index a63e7af9..8786fbd1 100644 --- a/packages/shared-domain/src/workflows/serialize.test.ts +++ b/packages/shared-domain/src/workflows/serialize.test.ts @@ -101,6 +101,15 @@ trigger: manual --- all | frobnicate alpha beta | write "O.md" +`], + ['tag lists and untagged notes', `--- +name: Lists +trigger: manual +--- + +reading = tag #book, #article +reading | not-tagged #someday, #reference | clipboard +all | no-tags | render list | write "Untagged.md" `], ['unknown frontmatter keys', `--- name: Meta @@ -134,7 +143,9 @@ describe('serializeWorkflow: unbindable arguments survive the save', () => { ['a bad duration', 'x = all | since 7x'], ['a digit-leading tag', 'x = tag #2024'], ['a bad sort direction with the field bound', 'x = all | sort title sideways'], - ['a quoted token that failed to bind', 'x = all | since "7 x"'] + ['a quoted token that failed to bind', 'x = all | since "7 x"'], + ['tags separated only by spaces', 'x = all | not-tagged #a #b'], + ['a tag list with an empty entry', 'x = all | tagged #a,, #b'] ] for (const [label, line] of PARKED) { @@ -303,6 +314,13 @@ describe('serializeTrigger', () => { }) describe('serializeWorkflow: shape', () => { + it('writes a tag list the one way the docs show it', () => { + const head = `---\nname: W\nstatus: active\ntrigger: manual\n---\n\n` + const parsed = parseWorkflow(`${head}x = all | tagged #a,#b, #A\n`, 'wf') + expect(parsed.diagnostics).toEqual([]) + expect(serializeWorkflow(parsed.workflow)).toBe(`${head}x = all | tagged #a, #b\n`) + }) + it('writes a body-less workflow as frontmatter alone', () => { expect(serializeWorkflow(wf({ name: 'Empty' }))).toBe( '---\nname: Empty\nstatus: active\ntrigger: manual\n---\n' diff --git a/packages/shared-domain/src/workflows/serialize.ts b/packages/shared-domain/src/workflows/serialize.ts index 4aeee1cc..86754aac 100644 --- a/packages/shared-domain/src/workflows/serialize.ts +++ b/packages/shared-domain/src/workflows/serialize.ts @@ -164,10 +164,22 @@ function serializeArg(spec: ParamSpec, value: ArgValue): string { if (typeof value !== 'string') return String(value) // `bindParams` stores tags without the `#`; the file always writes it. if (spec.type === 'tag') return `#${value}` + if (spec.type === 'tags') return tagListText(value) if (ALWAYS_QUOTED.has(spec.type)) return quote(value) return isBareSafe(value, spec.type === 'rest') ? value : quote(value) } +/** A stored tag list (`a,b`) as the file writes it: `#a, #b`. One tag writes + * exactly as a single-tag step always has. */ +export function tagListText(value: string): string { + return value + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) + .map((tag) => `#${tag}`) + .join(', ') +} + /* -------------------------------------------------------------------------- */ /* Quoting */ /* -------------------------------------------------------------------------- */ diff --git a/packages/shared-domain/src/workflows/share.ts b/packages/shared-domain/src/workflows/share.ts index 920c6fe0..2f176edc 100644 --- a/packages/shared-domain/src/workflows/share.ts +++ b/packages/shared-domain/src/workflows/share.ts @@ -35,7 +35,7 @@ import { NODE_DEFS, nodeDef, unquote } from './nodes' import type { NodeDef, ParamSpec } from './nodes' import { parseWorkflow } from './parse' -import { serializeTrigger, serializeWorkflow } from './serialize' +import { serializeTrigger, serializeWorkflow, tagListText } from './serialize' import { validateWorkflow } from './validate' import type { ArgValue, Diagnostic, Workflow } from './types' @@ -93,6 +93,7 @@ function formatArg(spec: ParamSpec, value: ArgValue | undefined): string { if (text === '') return '' // Tags are stored without their `#`; every surface that shows one puts it back. if (spec.type === 'tag') return `#${text}` + if (spec.type === 'tags') return tagListText(text) if (QUOTED_TYPES.has(spec.type)) return `"${unquote(text)}"` return text } diff --git a/packages/shared-domain/src/workflows/suggest.test.ts b/packages/shared-domain/src/workflows/suggest.test.ts index 015dab8a..c5a16ec5 100644 --- a/packages/shared-domain/src/workflows/suggest.test.ts +++ b/packages/shared-domain/src/workflows/suggest.test.ts @@ -231,6 +231,25 @@ describe('suggestAt: parameters complete by declared type', () => { expect([result.from, result.to]).toEqual([16, 21]) }) + it('keeps offering tags along a comma-separated list', () => { + const items = suggestAt(ctxOf(`books | tagged #book, ${CARET}`, VAULT)).items + expect(inserts(items)).toEqual(['#book', '#project', '#meeting']) + }) + + it('offers no second tag after a bare space, which would not parse', () => { + expect(suggestAt(ctxOf(`books | tagged #book ${CARET}`, VAULT)).items).toEqual([]) + }) + + it('completes only the list entry under the caret when commas have no spaces', () => { + const last = suggestAt(ctxOf(`books | not-tagged #book,#pro${CARET}`, VAULT)) + expect(inserts(last.items)).toEqual(['#project']) + expect([last.from, last.to]).toEqual([25, 29]) + + const first = suggestAt(ctxOf(`books | tagged #bo${CARET},#project`, VAULT)) + expect(inserts(first.items)).toEqual(['#book']) + expect([first.from, first.to]).toEqual([15, 18]) + }) + it('offers the render styles with what each one produces', () => { const items = suggestAt(ctxOf(`books | render ${CARET}`, VAULT)).items expect(labels(items)).toEqual(['table', 'list', 'count', 'links']) diff --git a/packages/shared-domain/src/workflows/suggest.ts b/packages/shared-domain/src/workflows/suggest.ts index 90fda518..95d1836f 100644 --- a/packages/shared-domain/src/workflows/suggest.ts +++ b/packages/shared-domain/src/workflows/suggest.ts @@ -443,11 +443,29 @@ function suggestInBody(ctx: SuggestContext, line: Span, offset: number): Suggest const def = spans.length > 0 ? nodeDef(spans[0].text) : null if (!def) return { from, to, items: [] } - const spec = def.params[position - 1] + // A tag list keeps going after a comma (`tagged #a, #b`), so a later position + // belongs to the same list. After a bare space it does not: `#a #b` is an + // error, and offering a tag there would walk the user into it. + const typed = active ? active.text.slice(0, offset - active.start) : '' + const lastSpec = def.params[def.params.length - 1] + let spec = def.params[position - 1] + if (!spec && lastSpec?.type === 'tags') { + if (spans[position - 1]?.text.endsWith(',') || typed.includes(',')) spec = lastSpec + } // Past the last declared param, or on a `rest` param that already swallowed // the tokens before it. Either way the registry says nothing about this // position, and inventing something would be worse than staying quiet. if (!spec) return { from, to, items: [] } + if (spec.type === 'tags' && active) { + // `#a,#b` is one token holding two tags. Only the entry under the caret is + // being completed, so the prefix is `#b` and picking a tag keeps `#a,`. + const ahead = active.text.indexOf(',', typed.length) + return { + from: active.start + typed.lastIndexOf(',') + 1, + to: ahead === -1 ? to : active.start + ahead, + items: paramItems(spec.type, ctx) + } + } return { from, to, items: paramItems(spec.type, ctx) } } @@ -506,6 +524,7 @@ function verbItems(defs: readonly NodeDef[]): Suggestion[] { function paramItems(type: ParamType, ctx: SuggestContext): Suggestion[] { switch (type) { case 'tag': + case 'tags': return tagItems(ctx.tags) case 'field': return fieldItems(ctx.fields) diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 01250207..6216544e 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.64.0", + "version": "2.65.0", "type": "module", "exports": { ".": "./src/index.ts"