From fab6f18271e8a4f133eab8f513e5cd9e96f4cf77 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 09:40:36 -0500 Subject: [PATCH 01/34] Feat(share): Share > ZenNotes saves a page or a selection from Safari and other apps to Quick Notes charles b. asked on Discord for a native Share to ZenNotes extension, so Safari and other apps could send a URL or selected text straight to Quick Notes. ZenNotes.app now carries ZenNotesShare.appex in Contents/PlugIns (apps/share-extension), next to the Quick Look extension. The sheet previews what is shared (the page title and site, the selection quoted), takes an optional note line and a vault, and saves on Return. Escape cancels, and Cmd+1 to Cmd+9 pick a vault: with macOS's default keyboard settings Tab never reaches a pop-up button. Safari runs ShareExtensionPreprocessing.js on the page, so the sheet gets the page's own title, address and selection; other apps hand a selection over as plain text, RTF or HTML, and all three are read as text. The extension is sandboxed, as macOS requires, and reaches no vault. It leaves each share as one JSON file in a share inbox, ~/Library/Application Support/ZenNotes/Share Inbox, the one folder a temporary-exception entitlement opens to it (the kind Quick Look already ships). The app files the shares (main/share-inbox.ts): at startup, as they arrive while it runs, and when a server a share names connects. It writes the note through vault.ts's createNote, so a Quick Notes folder remapped in systemFolderPaths is honored and no Swift copy of the folder rules exists, and it deletes a share only after its note is written. The app keeps vaults.json in the inbox, following every config save, so the sheet can offer the local and Cloud vaults and the servers the app knows. What it chooses: - Not an App Group. macOS refuses the unsandboxed app's own mkdir of its group folder even with the entitlement; only containermanagerd creates it, for a Foundation caller, which Electron's main process is not. The inbox folder needs no change to the app's own entitlements. - A share made while ZenNotes is closed waits for the next launch; nothing launches behind the user's back. - The note: the page title as heading and filename (fetched through link-metadata.ts when the sharing app gives none; else the first line of the text, then the host), the selection quoted, the note line, and [title](url) last. Text alone follows Quick Capture: its first line is the title. deriveTitleFromBody and timestampTitle move to shared-domain so both surfaces name notes alike. - A share file is untrusted input. Its vault is a key looked up in the app's remembered vaults, never a path; sizes are capped; a vault whose folder is gone is not recreated by filing into it; unreadable files are set aside, not deleted. A share for a self-hosted server waits until that server is the connected workspace. What it does not do: files, images and Finder documents are not accepted; there is no toast in the app (the note appears in Quick Notes as the watcher sees it); macOS may list a newly registered share extension switched off, and the app does not switch it on for the user. The in-app manual and the website docs describe it. Verified on packaged builds through a test copy with its own bundle ids and scratch vaults: a share made with the app closed was filed on the next launch into a remapped Quick Notes folder, titled from the fetched page; with the app running a text share was filed within two seconds and showed under Recent; inside the extension, Cmd+2, typing and Return saved to the second vault, and Escape cancelled; RTF text and a link with a multi-line selection came through; a share for a self-hosted server waited, then was filed through the server into its remapped folder once it connected. The release pack signs the extension with the Developer ID, hardened runtime and a timestamp, carries no self-test code, and opens a page target in two seconds. A share from Safari itself was not exercised. --- apps/desktop/build/after-pack.js | 25 +- apps/desktop/src/main/index.ts | 139 ++++- apps/desktop/src/main/share-inbox.test.ts | 254 ++++++++++ apps/desktop/src/main/share-inbox.ts | 403 +++++++++++++++ apps/desktop/src/main/vault.ts | 16 + apps/share-extension/README.md | 58 +++ .../macos/Extension.entitlements | 18 + apps/share-extension/macos/Info.plist | 57 +++ .../macos/ShareExtensionPreprocessing.js | 13 + .../macos/ShareViewController.swift | 475 ++++++++++++++++++ .../scripts/build-share-extension.mjs | 80 +++ packages/app-core/src/lib/help.ts | 5 + .../app-core/src/lib/quick-capture-save.ts | 23 +- .../shared-domain/src/quick-note-title.ts | 28 ++ 14 files changed, 1566 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/main/share-inbox.test.ts create mode 100644 apps/desktop/src/main/share-inbox.ts create mode 100644 apps/share-extension/README.md create mode 100644 apps/share-extension/macos/Extension.entitlements create mode 100644 apps/share-extension/macos/Info.plist create mode 100644 apps/share-extension/macos/ShareExtensionPreprocessing.js create mode 100644 apps/share-extension/macos/ShareViewController.swift create mode 100644 apps/share-extension/scripts/build-share-extension.mjs create mode 100644 packages/shared-domain/src/quick-note-title.ts diff --git a/apps/desktop/build/after-pack.js b/apps/desktop/build/after-pack.js index f35497284..f01c7f2db 100644 --- a/apps/desktop/build/after-pack.js +++ b/apps/desktop/build/after-pack.js @@ -38,11 +38,11 @@ async function rewriteAsFreshFiles(dir) { /** * The identity the app will be signed with, decided the way electron-builder - * decides it for the app itself (MacPackager.sign), so the Quick Look - * extension carries the same one; null when the app is not signed, and the - * extension is then signed ad hoc. + * decides it for the app itself (MacPackager.sign), so the extensions in + * Contents/PlugIns carry the same one; null when the app is not signed, and + * the extensions are then signed ad hoc. */ -async function quickLookSigning(context) { +async function appSigning(context) { const { findIdentity, isSignAllowed } = require('app-builder-lib/out/codeSign/macCodeSign') const config = context.packager.platformSpecificBuildOptions if (!isSignAllowed(false) || config.identity === null) return null @@ -75,15 +75,28 @@ exports.default = async function afterPack(context) { // Contents/PlugIns, which electron-builder never signs, so it is signed // here, before the app's own signature seals it in. const arch = { 1: 'x64', 3: 'arm64' }[context.arch] + const appPath = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + const signing = await appSigning(context) const { buildQuickLookExtension } = await import(pathToFileURL(path.resolve(__dirname, '../../quicklook/scripts/build-extension.mjs')).href) await buildQuickLookExtension({ - appPath: path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`), + appPath, arch, appId: context.packager.appInfo.id, version: context.packager.appInfo.version, - signing: await quickLookSigning(context), + signing, selfTest: process.env.ZENNOTES_QUICKLOOK_SELFTEST === '1' && !process.env.CI }) + // Share > ZenNotes in Safari and other apps (apps/share-extension). + const { buildShareExtension } = await import(pathToFileURL(path.resolve(__dirname, '../../share-extension/scripts/build-share-extension.mjs')).href) + await buildShareExtension({ + appPath, + arch, + appId: context.packager.appInfo.id, + version: context.packager.appInfo.version, + iconPath: path.resolve(context.packager.projectDir, context.packager.platformSpecificBuildOptions.icon || 'build/icon.icns'), + signing, + selfTest: process.env.ZENNOTES_SHARE_SELFTEST === '1' && !process.env.CI + }) } if (platform !== 'linux') return await rewriteAsFreshFiles(path.join(context.appOutDir, 'resources', 'arch-extras')) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 65a7ca399..b2a14b1e6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -18,7 +18,7 @@ import { import { AsyncLocalStorage } from "node:async_hooks"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { openAsBlob, promises as fsp } from "node:fs"; +import { openAsBlob, promises as fsp, watch as watchPath } from "node:fs"; import { homedir, hostname } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -88,6 +88,7 @@ import { listFolders, listNotes, loadConfig, + onConfigSaved, moveNote, moveAsset, moveToTrash, @@ -252,6 +253,13 @@ import { } from "./undo-history-store"; import { renderTikz } from "./tikz"; import { fetchLinkMetadata } from "./link-metadata"; +import { + drainShareInbox, + SHARE_CAPTURES_FOLDER, + shareInboxFromInfoPlist, + shareVaultsFile, + writeShareVaults, +} from "./share-inbox"; import { RemoteRequestError, RemoteServerClient } from "./remote/server-client"; import { getMcpClientStatuses, @@ -819,6 +827,132 @@ function handleStartupDeepLinks(argv: string[]): ExternalOpenUrlResult { return result; } +// Share > ZenNotes from the macOS Share menu (share-inbox.ts). The share +// extension leaves each share in the share inbox; they are filed in +// Quick Notes at startup, as they arrive, and when a server one names +// connects. A share made while the app was closed waits for the next launch. +let shareInboxDir: string | null = null; +let shareDrainRunning = false; +let shareDrainAgain = false; +let shareDrainTimer: ReturnType | null = null; + +async function resolveShareInboxDir(): Promise { + if (process.platform !== "darwin") return null; + // A scratch inbox for tests, gated like the user-data override: a packaged + // app honors it only with ZEN_PERF=1. + const override = process.env["ZENNOTES_SHARE_INBOX_DIR"]?.trim(); + if (override && (process.env["ZEN_PERF"] === "1" || !app.isPackaged)) { + return path.resolve(override); + } + if (!app.isPackaged) return null; + try { + const plist = await fsp.readFile( + path.join( + process.resourcesPath, + "..", + "PlugIns", + "ZenNotesShare.appex", + "Contents", + "Info.plist", + ), + "utf8", + ); + const inbox = shareInboxFromInfoPlist(plist); + return inbox ? path.join(app.getPath("home"), inbox) : null; + } catch { + return null; // this build carries no share extension + } +} + +function scheduleShareDrain(): void { + if (!shareInboxDir) return; + if (shareDrainTimer) clearTimeout(shareDrainTimer); + shareDrainTimer = setTimeout(() => { + shareDrainTimer = null; + void runShareDrain(); + }, 150); +} + +async function runShareDrain(): Promise { + const dir = shareInboxDir; + if (!dir) return; + if (shareDrainRunning) { + shareDrainAgain = true; + return; + } + shareDrainRunning = true; + try { + do { + shareDrainAgain = false; + const result = await drainShareInbox({ + capturesDir: path.join(dir, SHARE_CAPTURES_FOLDER), + loadConfig, + fetchTitle: async (url) => { + const meta = await fetchLinkMetadata(url); + return meta.ok ? meta.title : undefined; + }, + fileLocal: async (root, title, body) => { + const meta = await createNote(root, "quick", title); + await writeNote(root, meta.path, body); + return meta.path; + }, + fileRemote: async (profileId, title, body) => { + const client = remoteWorkspaceClient; + if (!client || currentRemoteWorkspaceProfileId !== profileId) { + return null; + } + const meta = await client.createNote("quick", title); + await client.writeNote(meta.path, body); + return meta.path; + }, + isDirectory: (candidate) => + fsp.stat(candidate).then( + (stat) => stat.isDirectory(), + () => false, + ), + }); + if (result.filed.length > 0 || result.setAside > 0) { + console.log( + `[share] filed ${result.filed.length}, waiting ${result.waiting}, set aside ${result.setAside}`, + ); + } + } while (shareDrainAgain); + } catch (err) { + console.warn("Share inbox drain failed", err); + } finally { + shareDrainRunning = false; + } +} + +async function startShareInbox(): Promise { + const dir = await resolveShareInboxDir(); + if (!dir) return; + const captures = path.join(dir, SHARE_CAPTURES_FOLDER); + try { + await fsp.mkdir(captures, { recursive: true }); + await writeShareVaults(dir, shareVaultsFile(await loadConfig())); + } catch (err) { + console.warn("Share inbox unavailable", err); + return; + } + shareInboxDir = dir; + onConfigSaved((cfg) => { + writeShareVaults(dir, shareVaultsFile(cfg)).catch((err) => + console.warn("Could not update the share sheet's vaults", err), + ); + }); + try { + // An unhandled watcher error (the inbox deleted under it) would throw in + // main; shares made after it still file at the next launch. + watchPath(captures, () => scheduleShareDrain()).on("error", (err) => + console.warn("Share inbox watch stopped", err), + ); + } catch (err) { + console.warn("Share inbox watch failed", err); + } + await runShareDrain(); +} + function focusWindow(win: BrowserWindow): void { if (win.isDestroyed()) return; if (win.isMinimized()) win.restore(); @@ -2397,6 +2531,8 @@ async function setRemoteWorkspace( baseUrl: client.baseUrl, }; startRemoteWatch(client, capabilities); + // Shares sent to this server while it was not connected file now. + scheduleShareDrain(); if (options.persist !== false) { await updateConfig((cfg) => ({ @@ -6227,6 +6363,7 @@ app.whenReady().then(async () => { }); appStartupComplete = true; + void startShareInbox(); // A second instance that arrived while startup was still running queued its // paths (the eager flush stands down until here, #178) and nothing else // drains that queue: without this, `zn open` during app launch is silently diff --git a/apps/desktop/src/main/share-inbox.test.ts b/apps/desktop/src/main/share-inbox.test.ts new file mode 100644 index 000000000..61b8c59fc --- /dev/null +++ b/apps/desktop/src/main/share-inbox.test.ts @@ -0,0 +1,254 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + drainShareInbox, + localShareKey, + parseShareCapture, + pendingShareFiles, + remoteShareKey, + resolveShareTarget, + SHARE_UNREADABLE_FOLDER, + shareCaptureBody, + shareCaptureTitle, + shareInboxFromInfoPlist, + shareVaultsFile, + writeShareVaults, + type ShareCapture, + type ShareConfig, + type ShareInboxDeps +} from './share-inbox' + +const NOW = new Date(2026, 9, 28, 9, 5) + +function capture(fields: Partial): ShareCapture { + return { id: 'a', createdAt: 1, vault: null, ...fields } +} + +describe('parseShareCapture', () => { + it('reads a share and drops what is not a web link', () => { + const parsed = parseShareCapture( + JSON.stringify({ version: 1, id: 'x1', createdAt: 5, vault: 'local:/v', url: 'https://a.example/p?q=1', title: ' Page \n Title ', text: ' hi ', note: '', sourceApp: 'com.apple.Safari' }) + ) + expect(parsed).toEqual({ + kind: 'capture', + capture: { id: 'x1', createdAt: 5, vault: 'local:/v', url: 'https://a.example/p?q=1', title: 'Page Title', text: 'hi', sourceApp: 'com.apple.Safari' } + }) + const local = parseShareCapture(JSON.stringify({ version: 1, id: 'x2', url: 'file:///etc/passwd', text: 'kept' })) + expect(local).toEqual({ kind: 'capture', capture: { id: 'x2', createdAt: 0, vault: null, text: 'kept' } }) + }) + + it('tells empty, newer and unreadable files apart', () => { + expect(parseShareCapture(JSON.stringify({ version: 1, id: 'e', text: ' ' }))).toEqual({ kind: 'empty' }) + expect(parseShareCapture(JSON.stringify({ version: 2, id: 'n', text: 'later' }))).toEqual({ kind: 'newer' }) + expect(parseShareCapture('{not json')).toEqual({ kind: 'unreadable' }) + expect(parseShareCapture(JSON.stringify([1]))).toEqual({ kind: 'unreadable' }) + expect(parseShareCapture(JSON.stringify({ version: 1, text: 'no id' }))).toEqual({ kind: 'unreadable' }) + expect(parseShareCapture(JSON.stringify({ version: '1', id: 'v', text: 'x' }))).toEqual({ kind: 'unreadable' }) + }) + + it('caps what a share may carry', () => { + const parsed = parseShareCapture(JSON.stringify({ version: 1, id: 'big', text: 'x'.repeat(1_200_000), title: 't'.repeat(900) })) + expect(parsed.kind).toBe('capture') + if (parsed.kind !== 'capture') return + expect(parsed.capture.text).toHaveLength(1_000_000) + expect(parsed.capture.title).toHaveLength(500) + }) +}) + +describe('shareCaptureTitle', () => { + it('prefers the supplied title, then the fetched one', () => { + expect(shareCaptureTitle(capture({ url: 'https://a.example/', title: 'Given' }), 'Fetched', NOW)).toBe('Given') + expect(shareCaptureTitle(capture({ url: 'https://a.example/', text: 'Some text' }), 'Fetched', NOW)).toBe('Fetched') + }) + + it('falls back to the text, the host, the note and a timestamp', () => { + expect(shareCaptureTitle(capture({ url: 'https://a.example/x', text: '## First line\nmore' }), undefined, NOW)).toBe('First line') + expect(shareCaptureTitle(capture({ url: 'https://docs.example.com/x' }), undefined, NOW)).toBe('docs.example.com') + expect(shareCaptureTitle(capture({ note: '- remember this' }), undefined, NOW)).toBe('remember this') + expect(shareCaptureTitle(capture({ text: '\n\n' }), undefined, NOW)).toBe('Quick capture 2026-10-28 0905') + }) +}) + +describe('shareCaptureBody', () => { + it('quotes a page selection, then the note, then the link', () => { + const shared = capture({ url: 'https://a.example/read', title: 'A [great] read', text: 'Line one\n\nLine two ', note: 'my note' }) + expect(shareCaptureBody(shared, 'A [great] read')).toBe( + '# A [great] read\n\n> Line one\n>\n> Line two\n\nmy note\n\n[A \\[great\\] read](https://a.example/read)\n' + ) + }) + + it('links with the fetched title, or the bare URL when there is none', () => { + expect(shareCaptureBody(capture({ url: 'https://a.example/' }), 'Fetched', 'Fetched')).toBe('# Fetched\n\n[Fetched](https://a.example/)\n') + expect(shareCaptureBody(capture({ url: 'https://a.example/' }), 'a.example')).toBe('# a.example\n\n\n') + }) + + it('keeps a parenthesis inside the link', () => { + const body = shareCaptureBody(capture({ url: 'https://en.example.org/wiki/Foo_(bar)', title: 'Foo' }), 'Foo') + expect(body).toContain('[Foo](https://en.example.org/wiki/Foo_%28bar%29)') + }) + + it('files text alone the Quick Capture way: its first line is the title', () => { + const shared = capture({ text: 'Groceries\n- milk', note: 'for Sunday' }) + const title = shareCaptureTitle(shared, undefined, NOW) + expect(title).toBe('Groceries') + expect(shareCaptureBody(shared, title)).toBe('Groceries\n- milk\n\nfor Sunday\n') + expect(shareCaptureBody(capture({ text: 'Body text', title: 'From Notes' }), 'From Notes')).toBe('# From Notes\n\nBody text\n') + }) +}) + +const BASE_CONFIG: ShareConfig = { + workspaceMode: 'local', + vaultRoot: '/vaults/Work', + localVaults: [ + { root: '/vaults/Work', name: 'Work', lastOpenedAt: 2 }, + { root: '/vaults/Home', name: 'Home', lastOpenedAt: 1 } + ], + remoteWorkspaceProfileId: null, + remoteWorkspaceProfiles: [{ id: 'p1', name: 'Team', baseUrl: 'https://notes.example.com:7878', lastConnectedAt: 3 }] +} + +describe('share vaults', () => { + it('lists local and remote vaults with the current one', () => { + expect(shareVaultsFile(BASE_CONFIG)).toEqual({ + version: 1, + vaults: [ + { id: localShareKey('/vaults/Work'), name: 'Work', kind: 'local' }, + { id: localShareKey('/vaults/Home'), name: 'Home', kind: 'local' }, + { id: 'remote:p1', name: 'Team', kind: 'remote', detail: 'notes.example.com:7878' } + ], + current: localShareKey('/vaults/Work') + }) + const remote = shareVaultsFile({ ...BASE_CONFIG, workspaceMode: 'remote', remoteWorkspaceProfileId: 'p1' }) + expect(remote.current).toBe('remote:p1') + }) + + it('includes the open vault even before it is remembered', () => { + const file = shareVaultsFile({ ...BASE_CONFIG, vaultRoot: '/vaults/New', localVaults: [] }) + expect(file.vaults[0]).toEqual({ id: localShareKey('/vaults/New'), name: 'New', kind: 'local' }) + }) + + it('resolves only keys the app remembers, never a path from the share', () => { + expect(resolveShareTarget(localShareKey('/vaults/Home'), BASE_CONFIG)).toEqual({ kind: 'local', key: localShareKey('/vaults/Home'), root: path.resolve('/vaults/Home') }) + expect(resolveShareTarget('remote:p1', BASE_CONFIG)).toEqual({ kind: 'remote', key: 'remote:p1', profileId: 'p1' }) + const current = { kind: 'local', key: localShareKey('/vaults/Work'), root: path.resolve('/vaults/Work') } + expect(resolveShareTarget(localShareKey('/etc'), BASE_CONFIG)).toEqual(current) + expect(resolveShareTarget('remote:gone', BASE_CONFIG)).toEqual(current) + expect(resolveShareTarget(null, BASE_CONFIG)).toEqual(current) + expect(resolveShareTarget(null, { ...BASE_CONFIG, vaultRoot: null, localVaults: [] })).toBeNull() + }) +}) + +describe('shareInboxFromInfoPlist', () => { + it('reads the inbox the extension was built with, inside the home folder only', () => { + const plist = (value: string): string => `ZenNotesShareInbox\n ${value}` + expect(shareInboxFromInfoPlist(plist('Library/Application Support/ZenNotes/Share Inbox'))).toBe('Library/Application Support/ZenNotes/Share Inbox') + expect(shareInboxFromInfoPlist(plist('/etc'))).toBeNull() + expect(shareInboxFromInfoPlist(plist('Library/../../etc'))).toBeNull() + expect(shareInboxFromInfoPlist('CFBundleNamex')).toBeNull() + }) +}) + +describe('drainShareInbox', () => { + let dir: string + let filed: Array<{ where: string; title: string; body: string }> + let connected: string | null + let directories: Set + + async function write(name: string, value: unknown): Promise { + await fs.writeFile(path.join(dir, name), typeof value === 'string' ? value : JSON.stringify(value)) + } + + function deps(overrides: Partial = {}): ShareInboxDeps { + return { + capturesDir: dir, + loadConfig: async () => BASE_CONFIG, + fetchTitle: async (url) => (url.includes('titled') ? 'Fetched title' : undefined), + fileLocal: async (root, title, body) => { + filed.push({ where: root, title, body }) + return `quick/${title}.md` + }, + fileRemote: async (profileId, title, body) => { + if (connected !== profileId) return null + filed.push({ where: `remote:${profileId}`, title, body }) + return `quick/${title}.md` + }, + isDirectory: async (candidate) => directories.has(candidate), + now: () => NOW, + ...overrides + } + } + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-share-inbox-')) + filed = [] + connected = null + directories = new Set([path.resolve('/vaults/Work'), path.resolve('/vaults/Home')]) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('files shares in order and deletes each once written', async () => { + await write('200-b.json', { version: 1, id: 'b', vault: localShareKey('/vaults/Home'), text: 'Second' }) + await write('100-a.json', { version: 1, id: 'a', url: 'https://titled.example/' }) + await write('.300-c.json.tmp', { version: 1, id: 'c', text: 'half written' }) + const result = await drainShareInbox(deps()) + expect(filed.map((entry) => [entry.where, entry.title])).toEqual([ + [path.resolve('/vaults/Work'), 'Fetched title'], + [path.resolve('/vaults/Home'), 'Second'] + ]) + expect(filed[0].body).toBe('# Fetched title\n\n[Fetched title](https://titled.example/)\n') + expect(result.filed.map((entry) => entry.id)).toEqual(['a', 'b']) + expect(await fs.readdir(dir)).toEqual(['.300-c.json.tmp']) + }) + + it('keeps a share whose server is not connected, and files it once it is', async () => { + await write('100-r.json', { version: 1, id: 'r', vault: 'remote:p1', text: 'For the team' }) + expect((await drainShareInbox(deps())).waiting).toBe(1) + expect(await pendingShareFiles(dir)).toEqual(['100-r.json']) + connected = 'p1' + const result = await drainShareInbox(deps()) + expect(result.filed).toEqual([{ id: 'r', title: 'For the team', path: 'quick/For the team.md', target: 'remote:p1' }]) + expect(await pendingShareFiles(dir)).toEqual([]) + }) + + it('keeps a share when writing it fails', async () => { + await write('100-a.json', { version: 1, id: 'a', text: 'Keep me' }) + const result = await drainShareInbox(deps({ fileLocal: async () => { throw new Error('disk full') } })) + expect(result.waiting).toBe(1) + expect(await pendingShareFiles(dir)).toEqual(['100-a.json']) + }) + + it('never recreates a vault folder that is gone', async () => { + await write('100-a.json', { version: 1, id: 'a', vault: localShareKey('/vaults/Home'), text: 'Moved vault' }) + directories.delete(path.resolve('/vaults/Home')) + await drainShareInbox(deps()) + expect(filed.map((entry) => entry.where)).toEqual([path.resolve('/vaults/Work')]) + filed = [] + await write('200-b.json', { version: 1, id: 'b', text: 'Nowhere to go' }) + directories.clear() + expect((await drainShareInbox(deps())).waiting).toBe(1) + expect(filed).toEqual([]) + }) + + it('sets unreadable files aside, leaves newer ones and drops empty ones', async () => { + await write('100-a.json', '{broken') + await write('200-b.json', { version: 9, id: 'b', text: 'from the future' }) + await write('300-c.json', { version: 1, id: 'c', text: ' ' }) + const result = await drainShareInbox(deps()) + expect(result).toMatchObject({ setAside: 1, waiting: 1, filed: [] }) + expect(await pendingShareFiles(dir)).toEqual(['200-b.json']) + expect(await fs.readdir(path.join(dir, SHARE_UNREADABLE_FOLDER))).toEqual(['100-a.json']) + }) + + it('writes vaults.json for the sheet', async () => { + await writeShareVaults(dir, shareVaultsFile(BASE_CONFIG)) + const written = JSON.parse(await fs.readFile(path.join(dir, 'vaults.json'), 'utf8')) + expect(written.current).toBe(localShareKey('/vaults/Work')) + expect(written.vaults).toHaveLength(3) + expect(remoteShareKey('p1')).toBe(written.vaults[2].id) + }) +}) diff --git a/apps/desktop/src/main/share-inbox.ts b/apps/desktop/src/main/share-inbox.ts new file mode 100644 index 000000000..1dde83666 --- /dev/null +++ b/apps/desktop/src/main/share-inbox.ts @@ -0,0 +1,403 @@ +/** + * Shares from the macOS Share menu: Share > ZenNotes in Safari and any other + * app. The share extension (apps/share-extension) is sandboxed and can reach + * no vault, so it leaves each share as one JSON file in the share inbox, the + * one folder its sandbox opens to it (inside the app's Application Support), + * and the app files them in Quick Notes: at startup, and as they arrive while + * it runs. The app leaves `vaults.json` there in turn, so the share sheet can + * offer the vaults it knows. + * + * A share file is untrusted input: anything that can write the inbox can + * write one. Its vault is a key looked up in the app's own remembered + * vaults, never a path, and sizes are capped. A share is deleted only after + * its note is written; one that cannot be filed yet stays for the next drain. + */ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { deriveTitleFromBody, timestampTitle } from '@shared/quick-note-title' + +export const SHARE_CAPTURE_VERSION = 1 +export const SHARE_CAPTURES_FOLDER = 'Captures' +export const SHARE_VAULTS_FILE = 'vaults.json' +/** Files a drain could not read, kept out of the queue but not deleted. */ +export const SHARE_UNREADABLE_FOLDER = 'Unreadable' + +const MAX_FILE_BYTES = 2 * 1024 * 1024 +const MAX_TEXT = 1_000_000 +const MAX_NOTE = 10_000 +const MAX_TITLE = 500 +const MAX_URL = 8192 + +export interface ShareCapture { + id: string + createdAt: number + /** A key from vaults.json, or null for the app's current vault. */ + vault: string | null + url?: string + /** The page or document title the sharing app supplied. */ + title?: string + text?: string + note?: string + /** Bundle identifier of the app the share came from. */ + sourceApp?: string +} + +export type ParsedShareCapture = + | { kind: 'capture'; capture: ShareCapture } + /** Nothing to file: no text, no link and no note. */ + | { kind: 'empty' } + /** Written by a newer share extension; left for a newer app. */ + | { kind: 'newer' } + | { kind: 'unreadable' } + +function cleanString(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.replace(/\0/g, '').trim() + return trimmed ? trimmed.slice(0, max) : undefined +} + +function webUrl(value: unknown): string | undefined { + const raw = cleanString(value, MAX_URL) + if (!raw) return undefined + try { + const parsed = new URL(raw) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : undefined + } catch { + return undefined + } +} + +export function parseShareCapture(raw: string): ParsedShareCapture { + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return { kind: 'unreadable' } + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return { kind: 'unreadable' } + const record = data as Record + if (typeof record.version !== 'number' || !Number.isInteger(record.version) || record.version < 1) { + return { kind: 'unreadable' } + } + if (record.version > SHARE_CAPTURE_VERSION) return { kind: 'newer' } + const id = cleanString(record.id, 100) + if (!id) return { kind: 'unreadable' } + const createdAt = typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) ? record.createdAt : 0 + const url = webUrl(record.url) + const text = cleanString(record.text, MAX_TEXT) + const note = cleanString(record.note, MAX_NOTE) + if (!url && !text && !note) return { kind: 'empty' } + const title = cleanString(record.title, MAX_TITLE)?.replace(/\s+/g, ' ') + return { + kind: 'capture', + capture: { + id, + createdAt, + vault: cleanString(record.vault, 4096) ?? null, + ...(url ? { url } : {}), + ...(title ? { title } : {}), + ...(text ? { text } : {}), + ...(note ? { note } : {}), + ...(cleanString(record.sourceApp, 255) ? { sourceApp: cleanString(record.sourceApp, 255) } : {}) + } + } +} + +function hostOf(url: string | undefined): string { + if (!url) return '' + try { + return new URL(url).host + } catch { + return '' + } +} + +/** + * The note's title, first that exists: the title the sharing app supplied, + * the page title the app fetched, the first line of the shared text (Quick + * Capture's rule), the link's host (the iPhone share's rule), the first line + * of the note, and last a timestamp, so a share never lands as "Untitled". + */ +export function shareCaptureTitle(capture: ShareCapture, fetchedTitle?: string, now = new Date()): string { + return ( + capture.title || + cleanString(fetchedTitle, MAX_TITLE)?.replace(/\s+/g, ' ') || + deriveTitleFromBody(capture.text ?? '') || + hostOf(capture.url) || + deriveTitleFromBody(capture.note ?? '') || + timestampTitle(now) + ) +} + +function escapeLinkText(text: string): string { + return text.replace(/[\\[\]]/g, (ch) => `\\${ch}`) +} + +/** A bare `)` would end a markdown link destination early. */ +function linkDestination(url: string): string { + return url.replace(/\(/g, '%28').replace(/\)/g, '%29') +} + +function quote(text: string): string { + return text + .split('\n') + .map((line) => (line.trim() ? `> ${line.trimEnd()}` : '>')) + .join('\n') +} + +/** + * The note: the title as its heading, the shared text (quoted when it came + * with a page, as written when it is the whole share), the note line, and + * the link last. A share of text alone follows Quick Capture instead: its + * first line is already the title, so it gets no heading of its own. + */ +export function shareCaptureBody(capture: ShareCapture, title: string, pageTitle?: string): string { + const parts: string[] = [] + const titledByText = !capture.title && !pageTitle && !!capture.text && title === deriveTitleFromBody(capture.text) + if (!titledByText) parts.push(`# ${title}`) + if (capture.text) parts.push(capture.url ? quote(capture.text) : capture.text.trimEnd()) + if (capture.note) parts.push(capture.note) + if (capture.url) { + const label = capture.title || pageTitle + parts.push(label ? `[${escapeLinkText(label)}](${linkDestination(capture.url)})` : `<${capture.url}>`) + } + return `${parts.join('\n\n')}\n` +} + +// Vaults the share sheet offers. `local:` and `remote:`; +// the app resolves either against its own config before filing anything. + +export interface ShareConfig { + workspaceMode: 'local' | 'remote' + vaultRoot: string | null + localVaults: Array<{ root: string; name: string; lastOpenedAt: number }> + remoteWorkspaceProfileId: string | null + remoteWorkspaceProfiles: Array<{ + id: string + name: string + baseUrl: string + lastConnectedAt: number | null + }> +} + +export type ShareTarget = + | { kind: 'local'; key: string; root: string } + | { kind: 'remote'; key: string; profileId: string } + +export interface ShareVaultChoice { + id: string + name: string + kind: 'local' | 'remote' + /** The server's host for a remote vault. */ + detail?: string +} + +export interface ShareVaultsFile { + version: 1 + vaults: ShareVaultChoice[] + /** The vault a share goes to when the sheet names none. */ + current: string | null +} + +export function localShareKey(root: string): string { + return `local:${path.resolve(root)}` +} + +export function remoteShareKey(profileId: string): string { + return `remote:${profileId}` +} + +function localEntries(cfg: ShareConfig): ShareConfig['localVaults'] { + const entries = [...cfg.localVaults] + if (cfg.vaultRoot && !entries.some((entry) => path.resolve(entry.root) === path.resolve(cfg.vaultRoot!))) { + entries.unshift({ root: cfg.vaultRoot, name: path.basename(cfg.vaultRoot), lastOpenedAt: 0 }) + } + return entries +} + +function currentShareTarget(cfg: ShareConfig): ShareTarget | null { + if (cfg.workspaceMode === 'remote' && cfg.remoteWorkspaceProfileId) { + const profile = cfg.remoteWorkspaceProfiles.find((entry) => entry.id === cfg.remoteWorkspaceProfileId) + if (profile) return { kind: 'remote', key: remoteShareKey(profile.id), profileId: profile.id } + } + const root = cfg.vaultRoot ?? localEntries(cfg)[0]?.root + return root ? { kind: 'local', key: localShareKey(root), root: path.resolve(root) } : null +} + +export function shareVaultsFile(cfg: ShareConfig): ShareVaultsFile { + const vaults: ShareVaultChoice[] = [] + const seen = new Set() + for (const entry of localEntries(cfg)) { + const id = localShareKey(entry.root) + if (seen.has(id)) continue + seen.add(id) + vaults.push({ id, name: entry.name || path.basename(entry.root), kind: 'local' }) + } + for (const profile of cfg.remoteWorkspaceProfiles) { + const id = remoteShareKey(profile.id) + if (seen.has(id)) continue + seen.add(id) + vaults.push({ id, name: profile.name, kind: 'remote', detail: hostOf(profile.baseUrl) || undefined }) + } + return { version: 1, vaults, current: currentShareTarget(cfg)?.key ?? null } +} + +/** The vault a share names, when the app still remembers it; otherwise the + * app's current vault. */ +export function resolveShareTarget(key: string | null, cfg: ShareConfig): ShareTarget | null { + if (key?.startsWith('local:')) { + const entry = localEntries(cfg).find((candidate) => localShareKey(candidate.root) === key) + if (entry) return { kind: 'local', key, root: path.resolve(entry.root) } + } else if (key?.startsWith('remote:')) { + const profileId = key.slice('remote:'.length) + if (cfg.remoteWorkspaceProfiles.some((profile) => profile.id === profileId)) { + return { kind: 'remote', key, profileId } + } + } + return currentShareTarget(cfg) +} + +// Draining. + +export interface ShareInboxDeps { + /** `/Captures`. */ + capturesDir: string + loadConfig: () => Promise + /** The page title for a link shared without one; undefined when offline. */ + fetchTitle: (url: string) => Promise + /** Creates the note in the local vault's Quick Notes; resolves its path. */ + fileLocal: (root: string, title: string, body: string) => Promise + /** Creates the note on a server, or null while that server is not the + * connected workspace: the share then waits. */ + fileRemote: (profileId: string, title: string, body: string) => Promise + isDirectory: (dir: string) => Promise + now?: () => Date +} + +export interface ShareDrainResult { + filed: Array<{ id: string; title: string; path: string; target: string }> + waiting: number + setAside: number +} + +/** Share files in the order they were made: names start with the time. */ +export async function pendingShareFiles(capturesDir: string): Promise { + let names: string[] + try { + names = await fs.readdir(capturesDir) + } catch { + return [] + } + return names.filter((name) => name.endsWith('.json') && !name.startsWith('.')).sort() +} + +async function setAside(capturesDir: string, name: string): Promise { + const dir = path.join(capturesDir, SHARE_UNREADABLE_FOLDER) + await fs.mkdir(dir, { recursive: true }) + await fs.rename(path.join(capturesDir, name), path.join(dir, name)) +} + +async function fallbackFor( + missing: Extract, + current: ShareTarget | null, + deps: ShareInboxDeps +): Promise { + if (!current) return null + if (current.kind === 'remote') return current + if (current.root === missing.root) return null + return (await deps.isDirectory(current.root)) ? current : null +} + +export async function drainShareInbox(deps: ShareInboxDeps): Promise { + const result: ShareDrainResult = { filed: [], waiting: 0, setAside: 0 } + const names = await pendingShareFiles(deps.capturesDir) + if (names.length === 0) return result + const cfg = await deps.loadConfig() + const current = currentShareTarget(cfg) + + for (const name of names) { + const file = path.join(deps.capturesDir, name) + let raw: string + try { + const stat = await fs.stat(file) + if (!stat.isFile()) continue + if (stat.size > MAX_FILE_BYTES) { + await setAside(deps.capturesDir, name) + result.setAside++ + continue + } + raw = await fs.readFile(file, 'utf8') + } catch { + continue // gone already, or unreadable for now: the next drain retries + } + const parsed = parseShareCapture(raw) + if (parsed.kind === 'newer') { + result.waiting++ + continue + } + if (parsed.kind === 'empty') { + await fs.rm(file, { force: true }) + continue + } + if (parsed.kind === 'unreadable') { + await setAside(deps.capturesDir, name).catch(() => {}) + result.setAside++ + continue + } + const capture = parsed.capture + let target = resolveShareTarget(capture.vault, cfg) + // A remembered vault whose folder is gone (moved, unmounted) must not be + // recreated by filing into it; the share goes to the current vault. + if (target?.kind === 'local' && !(await deps.isDirectory(target.root))) { + target = await fallbackFor(target, current, deps) + } + if (!target) { + result.waiting++ + continue + } + + const pageTitle = capture.url && !capture.title ? await deps.fetchTitle(capture.url).catch(() => undefined) : undefined + const title = shareCaptureTitle(capture, pageTitle, deps.now?.() ?? new Date()) + const body = shareCaptureBody(capture, title, pageTitle) + let notePath: string | null + try { + notePath = + target.kind === 'local' + ? await deps.fileLocal(target.root, title, body) + : await deps.fileRemote(target.profileId, title, body) + } catch (err) { + console.warn(`Could not file the share ${capture.id}`, err) + result.waiting++ + continue + } + if (notePath === null) { + result.waiting++ + continue + } + await fs.rm(file, { force: true }) + result.filed.push({ id: capture.id, title, path: notePath, target: target.key }) + } + return result +} + +/** Writes vaults.json for the share sheet, atomically: the sheet may read it + * at any moment. */ +export async function writeShareVaults(groupDir: string, file: ShareVaultsFile): Promise { + await fs.mkdir(groupDir, { recursive: true }) + const target = path.join(groupDir, SHARE_VAULTS_FILE) + const tmp = `${target}.${process.pid}.tmp` + await fs.writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`, 'utf8') + await fs.rename(tmp, target) +} + +/** The share inbox the bundled share extension names in its Info.plist, + * relative to the home folder; null when the plist names none, or names a + * path that could leave the home folder. */ +export function shareInboxFromInfoPlist(plist: string): string | null { + const match = plist.match(/ZenNotesShareInbox<\/key>\s*([^<]+)<\/string>/) + const relative = match?.[1].trim() + if (!relative || relative.startsWith('/') || relative.split('/').some((part) => part === '..' || part === '')) { + return null + } + return relative +} diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index e89a34c71..83d4e0c55 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -715,6 +715,22 @@ export async function saveConfig(cfg: PersistedConfig): Promise { } throw err } + for (const listener of configSavedListeners) { + try { + listener(sanitized) + } catch (err) { + console.warn('Config listener failed', err) + } + } +} + +const configSavedListeners = new Set<(cfg: PersistedConfig) => void>() + +/** Hears each config save once it is on disk. The macOS share sheet's vault + * list follows the remembered vaults this way (share-inbox.ts). */ +export function onConfigSaved(listener: (cfg: PersistedConfig) => void): () => void { + configSavedListeners.add(listener) + return () => configSavedListeners.delete(listener) } /** The scratch file `writeFileAtomic` renames from: `...tmp`. diff --git a/apps/share-extension/README.md b/apps/share-extension/README.md new file mode 100644 index 000000000..436642768 --- /dev/null +++ b/apps/share-extension/README.md @@ -0,0 +1,58 @@ +# Share extension (macOS) + +Share > ZenNotes in Safari and any other app with a Share menu saves the page +(its link and title), the selected text, or both into Quick Notes. +`ZenNotesShare.appex` ships inside `ZenNotes.app/Contents/PlugIns`. + +- `macos/ShareViewController.swift`: the sheet. It previews what is shared, + takes an optional note line and a vault, and on Save leaves the share as one + JSON file in `Captures/` inside the share inbox, + `~/Library/Application Support/ZenNotes/Share Inbox/`. Return saves, Escape + cancels, Tab reaches the vault menu. +- The app files the shares: `apps/desktop/src/main/share-inbox.ts` drains + `Captures/` at startup, as files arrive while it runs, and when a server a + share names connects. It creates the note with the vault code every other + note goes through, so a remapped Quick Notes folder is honored, and deletes + a share only after its note is written. It keeps `vaults.json` in the same + folder for the sheet's vault menu. + +The extension is sandboxed (macOS loads no other kind) and reaches no vault on +its own: a temporary-exception entitlement opens the share inbox to it and +nothing else, the same kind of entitlement the Quick Look extension reads +notes with. Not an App Group: macOS creates a group's folder only for a +process that asks through Foundation, and Electron's main process cannot ask, +so it could never write the vault list before the first share. A share made +while ZenNotes is closed waits in the inbox until the next launch. + +## Building + +`scripts/build-share-extension.mjs` compiles the Swift for the architecture, +signs it, and copies it into the app. electron-builder's afterPack hook +(`apps/desktop/build/after-pack.js`) calls it for each macOS build with the +identity the app is signed with, because electron-builder never signs +anything under `Contents/PlugIns`. + +The inbox path is `SHARE_INBOX` in the build script. It goes into the +extension's Info.plist (`ZenNotesShareInbox`), where the app's main process +reads it back, and into its entitlements, so the two never disagree. The app +itself needs no new entitlement. + +## Testing locally + +1. `npx vitest run src/main/share-inbox.test.ts` in `apps/desktop` for the + filing rules (title, note body, vault lookup, keep-on-failure). +2. Pack with the self-test compiled in: + `ZENNOTES_SHARE_SELFTEST=1 npm run pack`. Sharing anything that contains + `share-selftest-save` then fills the note line with `selftest note` and + presses Save by itself, and what the sheet received goes to the log + (`log show --info --predicate 'subsystem BEGINSWITH "com.adibhanna.zennotes"'`). + Release and CI builds never compile it in. +3. Use a test copy of the packed app, never a second copy under the real + identifiers: give the app and the extension their own bundle identifiers + (keep `CFBundleName`: Electron finds its helper apps by it), drop the copy's + `CFBundleURLTypes` and `CFBundleDocumentTypes`, add `ZEN_PERF=1` and + scratch `ZENNOTES_USER_DATA_PATH` / `ZENNOTES_CONFIG_DIR` to its + `LSEnvironment`, re-sign it inside out with the same entitlements, then + `lsregister -f` it and `pluginkit -a` its extension. Afterwards + `pluginkit -r`, `lsregister -u`, delete the copy, and empty the share + inbox of test files. diff --git a/apps/share-extension/macos/Extension.entitlements b/apps/share-extension/macos/Extension.entitlements new file mode 100644 index 000000000..d4846a963 --- /dev/null +++ b/apps/share-extension/macos/Extension.entitlements @@ -0,0 +1,18 @@ + + + + + + com.apple.security.app-sandbox + + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /__SHARE_INBOX__/ + + + diff --git a/apps/share-extension/macos/Info.plist b/apps/share-extension/macos/Info.plist new file mode 100644 index 000000000..a26f731a2 --- /dev/null +++ b/apps/share-extension/macos/Info.plist @@ -0,0 +1,57 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ZenNotes + CFBundleExecutable + ZenNotesShare + CFBundleIconFile + icon + CFBundleIdentifier + __APP_ID__.ShareExtension + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ZenNotesShare + CFBundlePackageType + XPC! + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 12.0 + + ZenNotesShareInbox + __SHARE_INBOX__ + ZenNotesAppBundleIdentifier + __APP_ID__ + NSExtension + + NSExtensionAttributes + + + NSExtensionJavaScriptPreprocessingFile + ShareExtensionPreprocessing + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + ShareViewController + + + diff --git a/apps/share-extension/macos/ShareExtensionPreprocessing.js b/apps/share-extension/macos/ShareExtensionPreprocessing.js new file mode 100644 index 000000000..a988c3a50 --- /dev/null +++ b/apps/share-extension/macos/ShareExtensionPreprocessing.js @@ -0,0 +1,13 @@ +// Safari runs this on the page being shared (NSExtensionJavaScriptPreprocessingFile +// in Info.plist) and hands the result to the share extension: the page's own +// title and address, and whatever is selected on it. +var ZenNotesSharePreprocessing = function () {} + +ZenNotesSharePreprocessing.prototype = { + run: function (args) { + var selection = window.getSelection ? String(window.getSelection()) : '' + args.completionFunction({ url: document.URL, title: document.title, selection: selection }) + } +} + +var ExtensionPreprocessingJS = new ZenNotesSharePreprocessing() diff --git a/apps/share-extension/macos/ShareViewController.swift b/apps/share-extension/macos/ShareViewController.swift new file mode 100644 index 000000000..e2f6e1ed6 --- /dev/null +++ b/apps/share-extension/macos/ShareViewController.swift @@ -0,0 +1,475 @@ +import Cocoa +import OSLog +import UniformTypeIdentifiers + +private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ZenNotesShare", category: "share") + +/// What the sharing app handed over: a page (its link and usually its title), +/// text, or both when Safari shares a page with a selection. +private struct SharedContent { + var url: URL? + var title: String? + var text: String? +} + +/// One vault the sheet offers, from the `vaults.json` the app keeps in the +/// share inbox (apps/desktop/src/main/share-inbox.ts). +private struct VaultChoice { + let id: String + let name: String + let kind: String + let detail: String? + + var label: String { + guard kind == "remote" else { return name } + return detail.map { "\(name) (\($0))" } ?? name + } +} + +private let maxText = 1_000_000 + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} + +/// Share > ZenNotes. The sheet previews what is shared, takes an optional +/// note line and a vault, and on Save leaves the share as one JSON file in +/// the share inbox, the one folder its sandbox opens to it. The extension +/// reaches no vault: ZenNotes files the share in Quick Notes, at once when it +/// is running and the next time it opens when it is not. +@objc(ShareViewController) +final class ShareViewController: NSViewController { + private let titleLabel = NSTextField(labelWithString: "") + private let detailLabel = NSTextField(labelWithString: "") + private let excerptLabel = NSTextField(wrappingLabelWithString: "") + private let noteField = NSTextField() + private let vaultMenu = NSPopUpButton() + private let vaultHint = NSTextField(labelWithString: "") + private var vaultRow: NSStackView! + private let notice = NSTextField(wrappingLabelWithString: "") + private let saveButton = NSButton(title: "Save", target: nil, action: nil) + private let cancelButton = NSButton(title: "Cancel", target: nil, action: nil) + + private var content = SharedContent() + private var keyMonitor: Any? + private var vaults: [VaultChoice] = [] + private var appRunning = true + private var saved = false + + private var info: [String: Any] { Bundle.main.infoDictionary ?? [:] } + private var shareInbox: String? { info["ZenNotesShareInbox"] as? String } + private var appBundleIdentifier: String? { info["ZenNotesAppBundleIdentifier"] as? String } + private static let lastVaultKey = "lastVault" + + override func loadView() { + let icon = NSImageView() + icon.image = Bundle.main.image(forResource: "icon") + icon.imageScaling = .scaleProportionallyUpOrDown + icon.widthAnchor.constraint(equalToConstant: 28).isActive = true + icon.heightAnchor.constraint(equalToConstant: 28).isActive = true + let heading = NSTextField(labelWithString: "Save to Quick Notes") + heading.font = .boldSystemFont(ofSize: 14) + let header = NSStackView(views: [icon, heading]) + header.spacing = 8 + + titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.maximumNumberOfLines = 2 + titleLabel.cell?.wraps = true + detailLabel.font = .systemFont(ofSize: 11) + detailLabel.textColor = .secondaryLabelColor + detailLabel.lineBreakMode = .byTruncatingMiddle + excerptLabel.font = .systemFont(ofSize: 12) + excerptLabel.textColor = .secondaryLabelColor + excerptLabel.maximumNumberOfLines = 6 + excerptLabel.lineBreakMode = .byWordWrapping + excerptLabel.cell?.truncatesLastVisibleLine = true + let bar = NSBox() + bar.boxType = .custom + bar.fillColor = .tertiaryLabelColor + bar.borderWidth = 0 + bar.widthAnchor.constraint(equalToConstant: 2).isActive = true + let excerpt = NSStackView(views: [bar, excerptLabel]) + excerpt.alignment = .top + excerpt.distribution = .fill + excerpt.spacing = 8 + bar.heightAnchor.constraint(equalTo: excerptLabel.heightAnchor).isActive = true + excerpt.identifier = NSUserInterfaceItemIdentifier("excerpt") + + let preview = NSStackView(views: [titleLabel, detailLabel, excerpt]) + preview.orientation = .vertical + preview.alignment = .leading + preview.spacing = 4 + + noteField.placeholderString = "Add a note (optional)" + noteField.setAccessibilityLabel("Note") + noteField.lineBreakMode = .byTruncatingTail + noteField.usesSingleLineMode = true + // Return in the note line saves. The field editor takes Return before + // the window's default button would see it. + noteField.target = self + noteField.action = #selector(save(_:)) + + let vaultLabel = NSTextField(labelWithString: "Vault") + vaultLabel.textColor = .secondaryLabelColor + vaultMenu.setAccessibilityLabel("Vault") + vaultMenu.target = self + vaultMenu.action = #selector(vaultChanged) + vaultHint.font = .systemFont(ofSize: 11) + vaultHint.textColor = .tertiaryLabelColor + vaultRow = NSStackView(views: [vaultLabel, vaultMenu, vaultHint]) + vaultRow.spacing = 8 + vaultRow.isHidden = true + + notice.font = .systemFont(ofSize: 11) + notice.textColor = .secondaryLabelColor + notice.isHidden = true + + cancelButton.target = self + cancelButton.action = #selector(cancel(_:)) + cancelButton.keyEquivalent = "\u{1b}" + saveButton.target = self + saveButton.action = #selector(save(_:)) + saveButton.keyEquivalent = "\r" + saveButton.isEnabled = false + let spacer = NSView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + let buttons = NSStackView(views: [spacer, cancelButton, saveButton]) + buttons.spacing = 8 + + let stack = NSStackView(views: [header, preview, noteField, vaultRow, notice, buttons]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 18, right: 20) + stack.setCustomSpacing(16, after: preview) + for view in [preview, noteField, notice, buttons] { + view.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -40).isActive = true + } + stack.translatesAutoresizingMaskIntoConstraints = false + + let root = NSView() + root.addSubview(stack) + NSLayoutConstraint.activate([ + root.widthAnchor.constraint(equalToConstant: 440), + stack.leadingAnchor.constraint(equalTo: root.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: root.trailingAnchor), + stack.topAnchor.constraint(equalTo: root.topAnchor), + stack.bottomAnchor.constraint(equalTo: root.bottomAnchor) + ]) + view = root + } + + override func viewDidLoad() { + super.viewDidLoad() + if let appBundleIdentifier { + appRunning = !NSRunningApplication.runningApplications(withBundleIdentifier: appBundleIdentifier).isEmpty + } + loadVaults() + loadContent() + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.defaultButtonCell = saveButton.cell as? NSButtonCell + view.window?.makeFirstResponder(noteField) + // Cmd+1 to Cmd+9 pick a vault from anywhere in the sheet: with macOS's + // default keyboard settings Tab never reaches a pop-up button. + keyMonitor = keyMonitor ?? NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, event.window === self.view.window, + event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command, + let digit = event.charactersIgnoringModifiers.flatMap(Int.init), + digit >= 1, digit <= min(9, self.vaults.count) else { return event } + self.vaultMenu.selectItem(at: digit - 1) + self.vaultChanged() + return nil + } + } + + override func viewWillDisappear() { + super.viewWillDisappear() + if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } + keyMonitor = nil + } + + // MARK: What was shared + + private func loadContent() { + let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? [] + let group = DispatchGroup() + // Every load lands on the main queue, so these need no lock; the HTML + // reader in plainText(from:type:) must run there anyway. + var urls: [URL] = [] + var texts: [String] = [] + var page: [String: Any]? + var title: String? + var contentText: String? + + for item in items { + if let value = item.attributedTitle?.string.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { + title = title ?? value + } + if let value = item.attributedContentText?.string.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { + contentText = contentText ?? value + } + for provider in item.attachments ?? [] { + log.info("shared item types: \(provider.registeredTypeIdentifiers.joined(separator: ","), privacy: .public)") + if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) { + // Safari ran ShareExtensionPreprocessing.js on the page: its + // title, address and selection, exactly as the page has them. + group.enter() + provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { item, _ in + let results = (item as? [String: Any])?[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any] + DispatchQueue.main.async { + page = page ?? results + group.leave() + } + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in + let url = (item as? URL) ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } + DispatchQueue.main.async { + if let url, Self.isWebLink(url) { urls.append(url) } + group.leave() + } + } + } else if let type = Self.textType(of: provider) { + // Other apps send a selection as plain text, RTF or HTML. + group.enter() + provider.loadItem(forTypeIdentifier: type.identifier, options: nil) { item, _ in + let data = item as? Data + let value = item as? String ?? (item as? NSAttributedString)?.string + DispatchQueue.main.async { + if let text = value ?? data.flatMap({ Self.plainText(from: $0, type: type) }), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + texts.append(text) + } + group.leave() + } + } + } + } + } + + group.notify(queue: .main) { [weak self] in + guard let self else { return } + var shared = SharedContent() + if let page { + shared.url = (page["url"] as? String).flatMap(URL.init(string:)).flatMap { Self.isWebLink($0) ? $0 : nil } + shared.title = (page["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + if let selection = (page["selection"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty { + texts.insert(selection, at: 0) + } + } + shared.url = shared.url ?? urls.first + shared.title = shared.title ?? title + // Without the page script, a link's content text is most often the + // page title (one short line); anything longer is what was selected. + if let contentText, contentText != shared.title, contentText != shared.url?.absoluteString { + if shared.url != nil, shared.title == nil, !contentText.contains("\n"), contentText.count <= 300 { + shared.title = contentText + } else if texts.isEmpty { + texts.append(contentText) + } + } + let text = texts.joined(separator: "\n\n") + shared.text = text.isEmpty ? nil : String(text.prefix(maxText)) + self.content = shared + log.info("shared: url \(shared.url != nil), title \(shared.title != nil), page script \(page != nil), text \(shared.text?.count ?? 0) chars") + self.showContent() + } + } + + private static func isWebLink(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + return scheme == "http" || scheme == "https" + } + + /// The text type to load from an attachment, plain text first. A file + /// shared from Finder is out of scope, even a Markdown one. + private static func textType(of provider: NSItemProvider) -> UTType? { + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { return nil } + return [UTType.utf8PlainText, .plainText, .rtf, .html, .text].first { + provider.hasItemConformingToTypeIdentifier($0.identifier) + } + } + + private static func plainText(from data: Data, type: UTType) -> String? { + if type.conforms(to: .rtf) { return NSAttributedString(rtf: data, documentAttributes: nil)?.string } + if type.conforms(to: .html) { return NSAttributedString(html: data, documentAttributes: nil)?.string } + return String(data: data, encoding: .utf8) + } + + private func showContent() { + let url = content.url + let firstLine = content.text? + .split(separator: "\n", omittingEmptySubsequences: true) + .first.map { $0.trimmingCharacters(in: .whitespaces) } + titleLabel.stringValue = content.title ?? (url == nil ? firstLine : nil) ?? url?.host ?? "Shared text" + detailLabel.stringValue = url.map { $0.host ?? $0.absoluteString } ?? "" + detailLabel.isHidden = url == nil + let excerptText = url == nil && content.title == nil + ? content.text?.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: true).dropFirst().first.map(String.init) + : content.text + excerptLabel.stringValue = excerptText ?? "" + (excerptLabel.superview as? NSStackView)?.isHidden = (excerptText ?? "").isEmpty + saveButton.isEnabled = url != nil || content.text != nil + updateNotice() + #if SHARE_SELFTEST + let marker = [content.title, content.text, url?.absoluteString].compactMap { $0 }.joined(separator: " ") + log.error("selftest received: title=\(self.content.title ?? "-", privacy: .public) url=\(url?.absoluteString ?? "-", privacy: .public) text=\(self.content.text?.prefix(80) ?? "-", privacy: .public)") + if marker.contains("share-selftest-save") { + noteField.stringValue = "selftest note" + save(nil) + } else if marker.contains("share-selftest-keys") { + // Cmd+2 picks the second vault, "ok" goes in the note line, Return saves. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.postKeys([("2", 19, .command), ("o", 31, []), ("k", 40, []), ("\r", 36, [])]) + } + } else if marker.contains("share-selftest-escape") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.postKeys([("\u{1b}", 53, [])]) + } + } + #endif + } + + #if SHARE_SELFTEST + /// Real key events through the sheet's own event handling, so the + /// self-test proves the keyboard path, not just the save. + private func postKeys(_ keys: [(String, UInt16, NSEvent.ModifierFlags)]) { + guard let window = view.window else { + log.error("selftest keys: the sheet has no window") + return + } + for (characters, keyCode, flags) in keys { + for type in [NSEvent.EventType.keyDown, .keyUp] { + if let event = NSEvent.keyEvent(with: type, location: .zero, modifierFlags: flags, timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, characters: characters, charactersIgnoringModifiers: characters, isARepeat: false, keyCode: keyCode) { + NSApp.postEvent(event, atStart: false) + } + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + log.error("selftest keys: vault \(self?.selectedVault?.id ?? "-", privacy: .public) note \(self?.noteField.stringValue ?? "-", privacy: .public) saved \(self?.saved ?? false)") + } + } + #endif + + // MARK: Vaults + + /// The inbox in the real home folder: inside the sandbox NSHomeDirectory() + /// is the extension's own container. + private func inboxFolder() -> URL? { + guard let shareInbox, let entry = getpwuid(getuid()), let home = entry.pointee.pw_dir else { return nil } + return URL(fileURLWithPath: String(cString: home), isDirectory: true) + .appendingPathComponent(shareInbox, isDirectory: true) + } + + private func loadVaults() { + defer { updateNotice() } + guard let folder = inboxFolder(), + let data = try? Data(contentsOf: folder.appendingPathComponent("vaults.json")), + let file = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let list = file["vaults"] as? [[String: Any]] else { return } + vaults = list.compactMap { entry in + guard let id = entry["id"] as? String, let name = entry["name"] as? String else { return nil } + return VaultChoice(id: id, name: name, kind: entry["kind"] as? String ?? "local", detail: entry["detail"] as? String) + } + guard !vaults.isEmpty else { return } + vaultMenu.removeAllItems() + for (index, vault) in vaults.enumerated() { + vaultMenu.addItem(withTitle: vault.label) + if index < 9, let item = vaultMenu.lastItem { + item.keyEquivalent = String(index + 1) + item.keyEquivalentModifierMask = .command + } + } + let remembered = UserDefaults.standard.string(forKey: Self.lastVaultKey) + let current = file["current"] as? String + let selected = vaults.firstIndex { $0.id == remembered } ?? vaults.firstIndex { $0.id == current } ?? 0 + vaultMenu.selectItem(at: selected) + vaultRow.isHidden = vaults.count < 2 + vaultHint.stringValue = "\u{2318}1 to \u{2318}\(min(9, vaults.count))" + } + + private var selectedVault: VaultChoice? { + let index = vaultMenu.indexOfSelectedItem + return vaults.indices.contains(index) ? vaults[index] : nil + } + + @objc private func vaultChanged() { + updateNotice() + } + + private func updateNotice(error: String? = nil) { + if let error { + notice.stringValue = error + notice.textColor = .systemRed + notice.isHidden = false + return + } + notice.textColor = .secondaryLabelColor + if selectedVault?.kind == "remote" { + notice.stringValue = "ZenNotes files it in Quick Notes once it is connected to this server." + } else if !appRunning { + notice.stringValue = "ZenNotes files it in Quick Notes the next time it opens." + } else { + notice.stringValue = "" + } + notice.isHidden = notice.stringValue.isEmpty + } + + // MARK: Save and cancel + + @objc private func save(_ sender: Any?) { + guard !saved, saveButton.isEnabled else { return } + do { + try writeCapture() + } catch { + log.error("could not save the share: \(error.localizedDescription, privacy: .public)") + updateNotice(error: "Could not save: \(error.localizedDescription)") + return + } + saved = true + if let vault = selectedVault { UserDefaults.standard.set(vault.id, forKey: Self.lastVaultKey) } + extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + } + + @objc private func cancel(_ sender: Any?) { + extensionContext?.cancelRequest(withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + override func cancelOperation(_ sender: Any?) { + cancel(sender) + } + + private func writeCapture() throws { + guard let folder = inboxFolder() else { + throw CocoaError(.fileNoSuchFile, userInfo: [NSLocalizedDescriptionKey: "the ZenNotes share inbox is unavailable"]) + } + let captures = folder.appendingPathComponent("Captures", isDirectory: true) + try FileManager.default.createDirectory(at: captures, withIntermediateDirectories: true) + let id = UUID().uuidString.lowercased() + let now = Date() + var record: [String: Any] = [ + "version": 1, + "id": id, + "createdAt": Int64(now.timeIntervalSince1970 * 1000), + "vault": selectedVault?.id ?? NSNull() + ] + if let url = content.url { record["url"] = url.absoluteString } + if let title = content.title { record["title"] = title } + if let text = content.text { record["text"] = text } + let note = noteField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if !note.isEmpty { record["note"] = note } + let data = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + // The name starts with the time so the app files shares in order; the + // app ignores dot files, so it never reads one half written. + let name = String(format: "%013lld-%@.json", Int64(now.timeIntervalSince1970 * 1000), id) + let temporary = captures.appendingPathComponent(".\(name).tmp") + try data.write(to: temporary) + try FileManager.default.moveItem(at: temporary, to: captures.appendingPathComponent(name)) + log.info("saved share \(name, privacy: .public)") + } +} diff --git a/apps/share-extension/scripts/build-share-extension.mjs b/apps/share-extension/scripts/build-share-extension.mjs new file mode 100644 index 000000000..43ae36042 --- /dev/null +++ b/apps/share-extension/scripts/build-share-extension.mjs @@ -0,0 +1,80 @@ +// Builds the Share extension (ZenNotesShare.appex) into a packaged +// ZenNotes.app: Share > ZenNotes in Safari and other apps. electron-builder's +// afterPack hook calls this for each macOS architecture, before the app is +// signed. electron-builder never signs anything under Contents/PlugIns, so +// the extension arrives signed here, sandboxed (macOS loads no other kind), +// with the same identity the app is signed with. +import { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +const run = promisify(execFile) +const here = dirname(fileURLToPath(import.meta.url)) +const sources = join(here, '..', 'macos') + +export const SHARE_BUNDLE = 'ZenNotesShare.appex' +/** Where shares wait, relative to the home folder: inside the app's own + * Application Support, the one folder the extension's sandbox opens. The + * app's main process reads it back from the extension's Info.plist. */ +export const SHARE_INBOX = 'Library/Application Support/ZenNotes/Share Inbox' +const SWIFT_TARGETS = { arm64: 'arm64-apple-macos12.0', x64: 'x86_64-apple-macos12.0' } + +/** + * @param {object} options + * @param {string} options.appPath the packaged ZenNotes.app + * @param {'arm64'|'x64'} options.arch + * @param {string} options.appId the app's bundle identifier + * @param {string} options.version + * @param {string} options.iconPath the app's .icns, shown in the Share menu + * @param {{ identity: string, keychain: string | null, timestamp?: string } | null} options.signing + * null signs ad hoc + * @param {boolean} [options.selfTest] compile the save self-test used when validating a build + */ +export async function buildShareExtension({ appPath, arch, appId, version, iconPath, signing, selfTest = false }) { + const target = SWIFT_TARGETS[arch] + if (!target) throw new Error(`Unsupported Share extension architecture: ${arch}`) + const work = await mkdtemp(join(tmpdir(), 'zennotes-share-')) + try { + const appex = join(work, SHARE_BUNDLE) + await mkdir(join(appex, 'Contents', 'MacOS'), { recursive: true }) + await mkdir(join(appex, 'Contents', 'Resources'), { recursive: true }) + const fill = (text) => + text.replaceAll('__APP_ID__', appId).replaceAll('__SHARE_INBOX__', SHARE_INBOX).replaceAll('__VERSION__', version) + await writeFile(join(appex, 'Contents', 'Info.plist'), fill(await readFile(join(sources, 'Info.plist'), 'utf8'))) + const entitlements = join(work, 'Extension.entitlements') + await writeFile(entitlements, fill(await readFile(join(sources, 'Extension.entitlements'), 'utf8'))) + await copyFile(iconPath, join(appex, 'Contents', 'Resources', 'icon.icns')) + await copyFile(join(sources, 'ShareExtensionPreprocessing.js'), join(appex, 'Contents', 'Resources', 'ShareExtensionPreprocessing.js')) + + const sdk = (await run('xcrun', ['--show-sdk-path', '--sdk', 'macosx'])).stdout.trim() + await run('xcrun', [ + 'swiftc', '-target', target, '-sdk', sdk, '-O', + ...(selfTest ? ['-D', 'SHARE_SELFTEST'] : []), + '-parse-as-library', '-application-extension', + '-module-name', 'ZenNotesShare', + '-Xlinker', '-e', '-Xlinker', '_NSExtensionMain', + '-framework', 'Cocoa', '-framework', 'UniformTypeIdentifiers', + '-o', join(appex, 'Contents', 'MacOS', 'ZenNotesShare'), + join(sources, 'ShareViewController.swift') + ], { maxBuffer: 16 * 1024 * 1024 }) + + const args = ['--force', '--options', 'runtime'] + if (signing) { + args.push('--sign', signing.identity, signing.timestamp ? `--timestamp=${signing.timestamp}` : '--timestamp') + if (signing.keychain) args.push('--keychain', signing.keychain) + } else { + args.push('--sign', '-') + } + await run('codesign', [...args, '--entitlements', entitlements, appex]) + + const plugIns = join(appPath, 'Contents', 'PlugIns') + await mkdir(plugIns, { recursive: true }) + await rm(join(plugIns, SHARE_BUNDLE), { recursive: true, force: true }) + await run('ditto', [appex, join(plugIns, SHARE_BUNDLE)]) + } finally { + await rm(work, { recursive: true, force: true }) + } +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 44688ef4f..1942519b8 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -96,6 +96,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Press the quick capture hotkey (CommandOrControl+Shift+Space by default, configurable under Settings → Editor) to drop a small always-on-top window over whatever app you are using. You type in one place: the first line becomes the note title and the rest is the body. Mod+Enter saves the note into Quick Notes and hides the window; Mod+N saves and immediately opens a fresh blank capture so you can jot several in a row; Mod+P loads an existing note to edit instead. Editing the first line of a Quick note renames that note in place rather than creating a duplicate, and you can drag the window by its top bar to reposition it.' }, + { + title: 'Save a page or text from Safari and other apps (Share → ZenNotes)', + body: + 'On macOS, choose Share → ZenNotes in Safari or any app with a Share menu to save the page you are on, the text you selected, or both, into Quick Notes. The sheet shows what you are saving, a line for an optional note, and, when you use more than one vault, the vault it goes to: your local and Cloud vaults and the servers you have connected to. Return saves and Escape cancels; ⌘1 to ⌘9 pick a vault in the order the menu lists them, and the sheet remembers the vault you picked last. A page becomes a note named after the page, with your selection quoted, your note under it and a link back to the page; text on its own becomes a note named from its first line, the way the quick capture window names one. The note lands in Quick Notes even when you mapped Quick Notes to another folder. With ZenNotes running the note appears right away; when it is not running, the share waits and is filed the next time you open ZenNotes, and a share for a server is filed once ZenNotes is connected to that server. The extension comes with the app and registers once you have opened ZenNotes; macOS may list it switched off at first, so if ZenNotes is missing from a Share menu, choose Edit Extensions… at the bottom of that menu and turn ZenNotes on, or turn it on in System Settings → General → Login Items & Extensions → Sharing.' + }, { title: 'Create a note in the folder you are already in', body: diff --git a/packages/app-core/src/lib/quick-capture-save.ts b/packages/app-core/src/lib/quick-capture-save.ts index ef6c665da..f91ad3e6e 100644 --- a/packages/app-core/src/lib/quick-capture-save.ts +++ b/packages/app-core/src/lib/quick-capture-save.ts @@ -14,28 +14,9 @@ * unit-testable; the component just executes the returned plan. */ -/** Derive the note title from the body's first non-empty line. A leading - * `# ` heading or list/quote marker is stripped so the title reads - * cleanly. Returns '' for an empty buffer — callers decide the fallback; - * this never invents a timestamp. */ -export function deriveTitleFromBody(body: string): string { - for (const raw of body.split('\n')) { - const line = raw.trim() - if (!line) continue - const heading = line.match(/^#{1,6}\s+(.+)$/u) - if (heading) return heading[1].trim().slice(0, 80) - return line.replace(/^[*\-+>\s]+/u, '').slice(0, 80) - } - return '' -} +import { deriveTitleFromBody, timestampTitle } from '@shared/quick-note-title' -/** Fallback title for a buffer with no usable first line — unreachable in - * practice (saves are guarded on a non-empty body) but keeps a note from - * ever landing as "Untitled". */ -export function timestampTitle(now = new Date()): string { - const pad = (n: number): string => String(n).padStart(2, '0') - return `Quick capture ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}${pad(now.getMinutes())}` -} +export { deriveTitleFromBody, timestampTitle } /** Minimal shape of a loaded note — structurally satisfied by NoteMeta. */ export interface QuickCaptureNote { diff --git a/packages/shared-domain/src/quick-note-title.ts b/packages/shared-domain/src/quick-note-title.ts new file mode 100644 index 000000000..987f1b959 --- /dev/null +++ b/packages/shared-domain/src/quick-note-title.ts @@ -0,0 +1,28 @@ +/** + * The title a quick note takes from its text. Quick Capture names its note + * from the first line you type, and a capture shared from another app + * (the macOS Share menu) falls back to the same rule when the page it came + * from has no title, so both surfaces name notes alike. + */ + +/** Derive the note title from the body's first non-empty line. A leading + * `# ` heading or list/quote marker is stripped so the title reads + * cleanly. Returns '' for an empty buffer: callers decide the fallback, + * this never invents a timestamp. */ +export function deriveTitleFromBody(body: string): string { + for (const raw of body.split('\n')) { + const line = raw.trim() + if (!line) continue + const heading = line.match(/^#{1,6}\s+(.+)$/u) + if (heading) return heading[1].trim().slice(0, 80) + return line.replace(/^[*\-+>\s]+/u, '').slice(0, 80) + } + return '' +} + +/** Fallback title for a body with no usable first line, so a note never + * lands as "Untitled". */ +export function timestampTitle(now = new Date()): string { + const pad = (n: number): string => String(n).padStart(2, '0') + return `Quick capture ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}${pad(now.getMinutes())}` +} From 15cee9f2434df6b5fd90b2a440d59aaf2a780cfb Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 09:50:42 -0500 Subject: [PATCH 02/34] Feat(covers): Unsplash photos in the cover picker, credited to their photographers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit charles b. asked on Discord for Unsplash as a source of covers. The cover picker now has a second tab, Unsplash, next to Covers. Tab in the search field switches between them and keeps the text, so "fog" on Covers then Tab searches Unsplash for fog. Space l u in Vim mode, Add Cover from Unsplash… in the palette and :cover unsplash open the tab directly; the last fills in the search with the first photo selected, so Enter then sets it. Photos appear as you type, after a 400 ms pause and from two characters. The arrows, Ctrl+N/P and Ctrl+J/K move a row, reaching the last row loads the next page, and the line under the grid credits the selected photo, "Photo by on Unsplash", with both names linked. A tab rather than another section, on purpose: a section would send every search typed in the picker to the network, the names of vault images included, and would spend an hourly allowance that every ZenNotes user shares on people who never wanted a photo. The Covers tab never goes online. Unsplash's API terms shape the rest. They require the photo to be hotlinked ("you must directly use or embed the related image URLs returned by the API", and their own example is a picked background), so unlike a gradient or a gallery photo an Unsplash pick is not copied into assets/. It is an https cover, which covers already were, with the photographer's credit on the lines under it: banner: "https://images.unsplash.com/photo-...?ixid=...&w=2400&q=80&auto=format" banner_credit: Jane Doe banner_credit_url: "https://unsplash.com/@janedoe" The size parameters are appended as text, so Unsplash's own query string, ixid included, stays byte for byte. The credit keys are generic (note-cover.ts reads and writes them for either cover key) and belong to the picture: a new image or a removed cover takes them away with banner_y, and the whole pick is one edit, so u takes it back. For a photo on images.unsplash.com the cover draws "Photo by ... on Unsplash" in its bottom-left corner, adding the referral tags Unsplash asks for at that moment so the file keeps clean links; any other credit draws as written. It shows with the cover's buttons on hover or focus, all the time on a screen that cannot hover, and all the time on a published note's page. Picking a photo pings its download_location (Unsplash's download tracking), fire and forget, once the note has it. A web cover that cannot load now says "Could not load the cover from images.unsplash.com" instead of calling the image missing. The app holds no Unsplash key: the terms keep it confidential and do not let users bring their own. Search and the ping go through zennotes.org/api/v1/unsplash, which signs them with ZenNotes' key and keeps the budget (the website's feat/unsplash-covers). Two optional bridge methods carry them. Desktop main sends them with net.fetch, no cookies, a bare user agent and no language, after checking the arguments, and caps one run at 40 requests a minute so a renderer bug cannot spend everyone's hour; the renderer's CSP is unchanged. The web client calls the proxy with fetch. A host without the methods, the phone shells today, shows no Unsplash tab. Everything the proxy returns is checked in shared-domain unsplash.ts before it is drawn: images only from images.unsplash.com, links only to unsplash.com, a ping only to an api.unsplash.com download endpoint. ZENNOTES_UNSPLASH_PROXY_URL points a test run at another proxy and, like the other overrides, is honored only unpackaged or with ZEN_PERF=1. The tab says what went wrong when a search fails: Unsplash could not be reached ("the Covers tab works offline"), the shared hourly allowance is spent ("resting until 3:00 PM"), the service is unavailable, or nothing matches. Answers are kept for the session, so switching tabs or reopening the picker asks nothing again. Not here: the phones get the tab when their shells add the two bridge methods after a core release, and Quick Look still leaves web covers out. Verified in the built app over CDP, both stores isolated, against a local stand-in for the proxy (48 checks, Vim on and off): Space l u and its which-key row; one search for "fog" typed letter by letter, with no cookie and a bare agent; hotlinked thumbnails; the credit's tagged links; ↓, Ctrl+N and Ctrl+J, and page 2 at the last row; Enter writing the banner and credit with nothing in assets/ and one ping; the cover loading and its credit showing on hover; u and Ctrl+R; Tab and Shift+Tab with a cached answer; :cover unsplash then Enter; the rate-limited, unavailable, no-match and unreachable messages; an unreachable web cover; a new image dropping the credit; a cover seen before drawing offline, in the same run and after a restart; :cover! restoring the note; with Vim off, the palette route, j typing into the field and ↓ moving. Then live, through the website served locally with a demo key: a real search, real thumbnails and credit, a banner keeping Unsplash's ixid, a 2400 px cover, and the ping counted against the key. Tests: the credit keys, the URL rules, response parsing, the proxy client and its failures, the desktop client's argument checks, cap and override, the search controller's debounce, stale answers, cache and paging, and the read-only cover's credit. shared-domain 2,184, app-core 3,609, desktop 1,202 and quicklook 18 pass; typecheck clean. How to test locally: serve the website's feat/unsplash-covers branch with UNSPLASH_ACCESS_KEY set, build the desktop app, then run ZENNOTES_UNSPLASH_PROXY_URL=http://127.0.0.1:/api/v1/unsplash electron apps/desktop/out/main/index.js. Open a note, press Space l u (or Tab in the Space l c picker), type "fog", move with ↓ and press Enter: the note gains banner, banner_credit and banner_credit_url, and hovering the cover shows "Photo by ... on Unsplash". Without a proxy the tab says Unsplash could not be reached, and the Covers tab works as before. --- apps/desktop/src/main/index.ts | 5 + apps/desktop/src/main/unsplash.test.ts | 55 ++++ apps/desktop/src/main/unsplash.ts | 82 ++++++ apps/desktop/src/preload/index.ts | 5 + apps/share-viewer/src/main.tsx | 5 +- apps/web/src/bridge/http-bridge.ts | 21 ++ packages/app-core/src/components/Editor.tsx | 12 +- .../app-core/src/components/NoteCover.tsx | 19 +- .../src/components/NoteCoverCredit.tsx | 43 +++ .../src/components/NoteCoverPicker.tsx | 185 +++++++++++-- .../src/components/ReadOnlyNoteCover.test.ts | 29 ++ .../src/components/ReadOnlyNoteCover.tsx | 8 +- packages/app-core/src/components/VimNav.tsx | 21 +- packages/app-core/src/lib/commands.ts | 13 + packages/app-core/src/lib/help.ts | 10 +- packages/app-core/src/lib/keymaps.ts | 13 + .../app-core/src/lib/note-cover-actions.ts | 39 ++- .../app-core/src/lib/unsplash-search.test.ts | 152 ++++++++++ packages/app-core/src/lib/unsplash-search.ts | 213 ++++++++++++++ packages/app-core/src/store.ts | 12 +- packages/app-core/src/styles/index.css | 8 + packages/bridge-contract/package.json | 3 +- packages/bridge-contract/src/bridge.ts | 7 + packages/bridge-contract/src/ipc.ts | 2 + packages/bridge-contract/src/unsplash.ts | 40 +++ packages/shared-domain/src/keymaps-catalog.ts | 1 + packages/shared-domain/src/note-cover.test.ts | 36 +++ packages/shared-domain/src/note-cover.ts | 73 ++++- packages/shared-domain/src/unsplash.test.ts | 216 +++++++++++++++ packages/shared-domain/src/unsplash.ts | 261 ++++++++++++++++++ 30 files changed, 1546 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/main/unsplash.test.ts create mode 100644 apps/desktop/src/main/unsplash.ts create mode 100644 packages/app-core/src/components/NoteCoverCredit.tsx create mode 100644 packages/app-core/src/lib/unsplash-search.test.ts create mode 100644 packages/app-core/src/lib/unsplash-search.ts create mode 100644 packages/bridge-contract/src/unsplash.ts create mode 100644 packages/shared-domain/src/unsplash.test.ts create mode 100644 packages/shared-domain/src/unsplash.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 65a7ca399..31a2affe7 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -286,6 +286,7 @@ import { installRaycastExtension, } from "./raycast-integration"; import { sendUsageCount } from "./usage-count"; +import { searchUnsplash, trackUnsplashDownload } from "./unsplash"; import { hideTray, parseTraySyncStatus, @@ -3618,6 +3619,10 @@ function registerIpc(): void { handle(IPC.APP_UPDATER_GET_STATE, () => getAppUpdateState()); handle(IPC.APP_UPDATER_CHECK, async () => await checkForAppUpdates()); handle(IPC.APP_USAGE_COUNT, async (_e, event: unknown) => await sendUsageCount(event)); + handle(IPC.UNSPLASH_SEARCH, async (_e, query: unknown, page: unknown) => await searchUnsplash(query, page)); + handle(IPC.UNSPLASH_TRACK_DOWNLOAD, async (_e, downloadLocation: unknown) => { + await trackUnsplashDownload(downloadLocation); + }); handle(IPC.APP_UPDATER_CHECK_WITH_UI, async () => { await runMenuUpdateCheck(); }); diff --git a/apps/desktop/src/main/unsplash.test.ts b/apps/desktop/src/main/unsplash.test.ts new file mode 100644 index 000000000..6fa8e5078 --- /dev/null +++ b/apps/desktop/src/main/unsplash.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { isPackaged: true }, net: { fetch: vi.fn() } })) + +import type { UnsplashFetch } from '@shared/unsplash' +import { createUnsplashClient, unsplashProxyBaseUrl, UNSPLASH_REQUESTS_PER_MINUTE } from './unsplash' + +const ok = (json: unknown) => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => json }) + +describe('unsplashProxyBaseUrl', () => { + it('is zennotes.org unless a test run names another', () => { + expect(unsplashProxyBaseUrl({}, true)).toBe('https://zennotes.org/api/v1/unsplash') + expect(unsplashProxyBaseUrl({ ZENNOTES_UNSPLASH_PROXY_URL: 'http://127.0.0.1:7/u/' }, false)).toBe( + 'http://127.0.0.1:7/u' + ) + // A packaged copy honors the override only for a test run. + expect(unsplashProxyBaseUrl({ ZENNOTES_UNSPLASH_PROXY_URL: 'http://127.0.0.1:7/u' }, true)).toBe( + 'https://zennotes.org/api/v1/unsplash' + ) + expect(unsplashProxyBaseUrl({ ZENNOTES_UNSPLASH_PROXY_URL: 'http://127.0.0.1:7/u', ZEN_PERF: '1' }, true)).toBe( + 'http://127.0.0.1:7/u' + ) + }) +}) + +describe('createUnsplashClient', () => { + it('sends a search with no cookies, a bare user agent and no language', async () => { + const fetch = vi.fn(async () => ok({ total_pages: 0, results: [] })) + const client = createUnsplashClient({ fetch, baseUrl: () => 'https://p.test' }) + expect(await client.search('fog', 1)).toMatchObject({ ok: true }) + const init = fetch.mock.calls[0]![1] + expect(init.credentials).toBe('omit') + expect(init.headers).toMatchObject({ 'user-agent': 'ZenNotes', 'accept-language': '*' }) + }) + + it('refuses arguments that are not a search', async () => { + const fetch = vi.fn() + const client = createUnsplashClient({ fetch, baseUrl: () => 'https://p.test' }) + expect(await client.search({ query: 'fog' }, 1)).toEqual({ ok: false, reason: 'unavailable', retryAt: null }) + await client.trackDownload(42) + await client.trackDownload('https://example.com/photos/a/download') + expect(fetch).not.toHaveBeenCalled() + }) + + it('caps one run at a minute’s worth of requests, whatever the renderer asks', async () => { + let now = 0 + const fetch = vi.fn(async () => ok({ total_pages: 1, results: [] })) + const client = createUnsplashClient({ fetch, baseUrl: () => 'https://p.test', now: () => now }) + for (let i = 0; i < UNSPLASH_REQUESTS_PER_MINUTE; i++) await client.search(`fog ${i}`, 1) + expect(await client.search('one more', 1)).toEqual({ ok: false, reason: 'rate-limited', retryAt: 60_000 }) + expect(fetch).toHaveBeenCalledTimes(UNSPLASH_REQUESTS_PER_MINUTE) + now = 60_000 + expect(await client.search('later', 1)).toMatchObject({ ok: true }) + }) +}) diff --git a/apps/desktop/src/main/unsplash.ts b/apps/desktop/src/main/unsplash.ts new file mode 100644 index 000000000..d5dbb249e --- /dev/null +++ b/apps/desktop/src/main/unsplash.ts @@ -0,0 +1,82 @@ +/** + * The cover picker's Unsplash search, sent from main the way the usage count + * and Cloud are: the renderer's CSP keeps connect-src to the app's own + * schemes, and this side decides what leaves (a search, a page number, a + * download location it has checked) and how (no cookies, a bare user agent, + * no language). The Unsplash key lives on the zennotes.org proxy, never in + * the app (shared-domain unsplash.ts has the rules). + * + * The proxy keeps one hourly budget for every ZenNotes user, so a renderer bug + * that searched in a loop would spend everyone's. This side caps what one run + * of the app may ask, well above what a person typing can reach. + */ +import { app, net } from 'electron' +import type { UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' +import { + UNSPLASH_PROXY_BASE_URL, + searchUnsplashThroughProxy, + trackUnsplashDownloadThroughProxy, + type UnsplashFetch, + type UnsplashProxyOptions +} from '@shared/unsplash' + +/** Searches and pings one run may send in a minute. */ +export const UNSPLASH_REQUESTS_PER_MINUTE = 40 + +/** The proxy, or the one ZENNOTES_UNSPLASH_PROXY_URL names for a test run. As + * with the other overrides, a packaged app honors it only with ZEN_PERF=1. */ +export function unsplashProxyBaseUrl(env: NodeJS.ProcessEnv, isPackaged: boolean): string { + const override = env.ZENNOTES_UNSPLASH_PROXY_URL?.trim() + if (override && (!isPackaged || env.ZEN_PERF === '1')) return override.replace(/\/+$/, '') + return UNSPLASH_PROXY_BASE_URL +} + +export function createUnsplashClient(deps: { + fetch: UnsplashFetch + baseUrl: () => string + now?: () => number +}): { + search: (query: unknown, page: unknown) => Promise + trackDownload: (downloadLocation: unknown) => Promise +} { + const now = deps.now ?? Date.now + const sent: number[] = [] + /** Room for one more request, or the time the oldest one leaves the window. */ + const take = (): number | null => { + const at = now() + while (sent.length && at - sent[0]! >= 60_000) sent.shift() + if (sent.length >= UNSPLASH_REQUESTS_PER_MINUTE) return sent[0]! + 60_000 + sent.push(at) + return null + } + const options = (): UnsplashProxyOptions => ({ + baseUrl: deps.baseUrl(), + headers: { 'user-agent': 'ZenNotes', 'accept-language': '*' }, + now + }) + return { + search: async (query, page) => { + if (typeof query !== 'string') return { ok: false, reason: 'unavailable', retryAt: null } + const wait = take() + if (wait !== null) return { ok: false, reason: 'rate-limited', retryAt: wait } + return await searchUnsplashThroughProxy( + deps.fetch, + { query, page: typeof page === 'number' && Number.isFinite(page) ? page : 1 }, + options() + ) + }, + trackDownload: async (downloadLocation) => { + if (typeof downloadLocation !== 'string') return + if (take() !== null) return + await trackUnsplashDownloadThroughProxy(deps.fetch, downloadLocation, options()) + } + } +} + +const client = createUnsplashClient({ + fetch: (url, init) => net.fetch(url, init), + baseUrl: () => unsplashProxyBaseUrl(process.env, app.isPackaged) +}) + +export const searchUnsplash = client.search +export const trackUnsplashDownload = client.trackDownload diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index edc47f36b..831e6a530 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -10,6 +10,7 @@ import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' +import type { UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -264,6 +265,10 @@ const api: ZenBridge = { getAppUpdateState: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_GET_STATE), checkForAppUpdates: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK), sendUsageCount: (event: 'install' | 'update'): Promise => ipcRenderer.invoke(IPC.APP_USAGE_COUNT, event), + searchUnsplash: (query: string, page: number): Promise => + ipcRenderer.invoke(IPC.UNSPLASH_SEARCH, query, page), + trackUnsplashDownload: (downloadLocation: string): Promise => + ipcRenderer.invoke(IPC.UNSPLASH_TRACK_DOWNLOAD, downloadLocation), checkForAppUpdatesWithUi: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK_WITH_UI), downloadAppUpdate: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_DOWNLOAD), diff --git a/apps/share-viewer/src/main.tsx b/apps/share-viewer/src/main.tsx index a5645e756..db271ef8c 100644 --- a/apps/share-viewer/src/main.tsx +++ b/apps/share-viewer/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client' import type { AssetMeta, ImportedAssetKind, NoteContent, VaultInfo } from '@shared/ipc' import { readNoteCover } from '@shared/note-cover' import { readOnlyNoteCoverUrl } from '@shared/note-cover-gradients' +import { noteCoverCreditLine } from '@shared/unsplash' import { readSharePagePayload, type SharePagePayload } from './payload' import { THEMES } from '@renderer/lib/themes' import { ReadOnlyNoteCover } from '@renderer/components/ReadOnlyNoteCover' @@ -106,7 +107,9 @@ async function boot(data: SharePagePayload): Promise { ReactDOM.createRoot(rendered).render( - {cover && coverUrl ? : null} + {cover && coverUrl ? ( + + ) : null}
diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 72781907a..65755763d 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -75,6 +75,12 @@ import { createDatabaseOps, type DatabaseVaultLayout } from '@shared/database-op import { isUnknownRouteResponse, parseServerErrorBody } from '@shared/server-error-shape' import { pastedImageFilename } from '@shared/pasted-image' import { createAbsenceAwareReader } from '@shared/remote-absence' +import { + searchUnsplashThroughProxy, + trackUnsplashDownloadThroughProxy, + type UnsplashFetch +} from '@shared/unsplash' +import type { UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' import type { McpClientId, McpClientStatus, @@ -610,6 +616,19 @@ async function fetchLinkMetadata(url: string): Promise { return { url, ok: false } } +// The cover picker's Unsplash search goes straight from the browser to the +// proxy on zennotes.org, which answers any origin and gets no cookies; the +// server this client is served from takes no part (and holds no key). +const unsplashFetch: UnsplashFetch = (url, init) => fetch(url, init) + +async function searchUnsplash(query: string, page: number): Promise { + return await searchUnsplashThroughProxy(unsplashFetch, { query, page }) +} + +async function trackUnsplashDownload(downloadLocation: string): Promise { + await trackUnsplashDownloadThroughProxy(unsplashFetch, downloadLocation) +} + async function revealFolder(_folder: NoteFolder, _subpath: string): Promise { // No OS file manager on the web. } @@ -1588,6 +1607,8 @@ export const httpBridge: ZenBridge = { openExternalUrl: async () => ({ ok: false, error: 'desktop-only' }), openAssetExternally, fetchLinkMetadata, + searchUnsplash, + trackUnsplashDownload, revealNoteTarget, revealFilePath, moveNote, diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 6474e31e6..44eea54fc 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -67,6 +67,7 @@ import { writeClipboardText } from "../lib/clipboard-text"; import { coverNotePath, openCoverPicker, + openUnsplashCoverPicker, removeCover, resolveCoverArgument, setCoverFromBuiltin, @@ -555,8 +556,10 @@ function registerVimCommands(): void { // `:cover` (`:cov`) is the ex twin of Space l c. Bare, it opens the picker; // `:cover!` or `:cover remove` takes the cover off; `paste` uses the // clipboard image; `reposition` enters reposition mode and `40%` sets the - // focal point outright. Anything else names the image: an https URL, a - // vault image by path or unique file name, or a built-in cover by name. + // focal point outright. `unsplash fog` opens the picker's Unsplash tab on + // that search (Enter then picks the first photo; nothing is picked unseen). + // Anything else names the image: an https URL, a vault image by path or + // unique file name, or a built-in cover by name. Vim.defineEx( "cover", "cov", @@ -584,6 +587,11 @@ function registerVimCommands(): void { startCoverReposition(); return; } + const unsplash = arg.match(/^unsplash(?:\s+([\s\S]*))?$/i); + if (unsplash) { + openUnsplashCoverPicker((unsplash[1] ?? "").trim()); + return; + } const percent = arg.match(/^(\d{1,3}(?:\.\d+)?)%$/); if (percent) { void setCoverPosition(path, Number(percent[1]) / 100); diff --git a/packages/app-core/src/components/NoteCover.tsx b/packages/app-core/src/components/NoteCover.tsx index eeea4c6ac..138cb698a 100644 --- a/packages/app-core/src/components/NoteCover.tsx +++ b/packages/app-core/src/components/NoteCover.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import type { DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react' import { clampNoteCoverY, type NoteCover as NoteCoverValue } from '@shared/note-cover' +import { noteCoverCreditLine } from '@shared/unsplash' import { useStore } from '../store' import { resolveLocalAsset } from '../lib/local-assets' import { focusEditorNormalMode } from '../lib/editor-focus' @@ -14,6 +15,7 @@ import { setCoverTarget } from '../lib/note-cover-actions' import { Button } from './ui/Button' +import { NoteCoverCredit } from './NoteCoverCredit' /** One arrow press moves the focal point this far; Shift moves it finely. */ const STEP = 0.05 @@ -30,6 +32,15 @@ interface NoteCoverProps { claimable: boolean } +/** A web cover's host, for the line shown when it cannot load (offline, say). */ +function webCoverHost(url: string): string { + try { + return new URL(url).hostname || url + } catch { + return url + } +} + function resolveCoverUrl( vaultRoot: string | null, notePath: string, @@ -126,7 +137,7 @@ export function NoteCover({ notePath, cover, variant, claimable }: NoteCoverProp const onPointerDown = (e: ReactPointerEvent): void => { if (!repositioning || e.button !== 0) return - if ((e.target as HTMLElement).closest('button')) return + if ((e.target as HTMLElement).closest('button, a')) return e.preventDefault() e.currentTarget.setPointerCapture(e.pointerId) dragRef.current = { startY: e.clientY, startFocal: y, overflow: verticalOverflow() } @@ -211,6 +222,7 @@ export function NoteCover({ notePath, cover, variant, claimable }: NoteCoverProp cover.target.kind === 'url' ? cover.target.url : cover.target.href.slice(cover.target.href.lastIndexOf('/') + 1) + const credit = noteCoverCreditLine(cover) return (
{url === null && !missing && cover.target.kind === 'vault' ? 'Loading cover…' - : `Cover image not found: ${name}`} + : cover.target.kind === 'url' + ? `Could not load the cover from ${webCoverHost(cover.target.url)}` + : `Cover image not found: ${name}`}
)} + {credit && !repositioning && } {repositioning ? ( <>
diff --git a/packages/app-core/src/components/NoteCoverCredit.tsx b/packages/app-core/src/components/NoteCoverCredit.tsx new file mode 100644 index 000000000..b4b7c5e73 --- /dev/null +++ b/packages/app-core/src/components/NoteCoverCredit.tsx @@ -0,0 +1,43 @@ +import type { NoteCoverCreditLine } from '@shared/unsplash' + +/** + * Who made a cover's picture, in its bottom-left corner: "Photo by on + * Unsplash" for an Unsplash photo (Unsplash asks for both links wherever the + * photo shows), the credit as written for any other cover. In the app it + * shows with the cover's buttons; on a page nobody edits (a published note) + * it stays in view. No store imports: the share viewer draws it too. + */ +export function NoteCoverCredit({ + line, + persistent +}: { + line: NoteCoverCreditLine + persistent: boolean +}): JSX.Element { + const link = (label: string, href: string | null): JSX.Element => + href ? ( + + {label} + + ) : ( + {label} + ) + return ( +
+ {line.source ? ( + <> + Photo by {link(line.name, line.nameUrl)} on {link(line.source.name, line.source.url)} + + ) : ( + link(line.name, line.nameUrl) + )} +
+ ) +} diff --git a/packages/app-core/src/components/NoteCoverPicker.tsx b/packages/app-core/src/components/NoteCoverPicker.tsx index ff5efa312..02c2a0a63 100644 --- a/packages/app-core/src/components/NoteCoverPicker.tsx +++ b/packages/app-core/src/components/NoteCoverPicker.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { KeyboardEvent as ReactKeyboardEvent } from 'react' import { readNoteCover } from '@shared/note-cover' +import { UNSPLASH_HOME_URL, unsplashReferralUrl } from '@shared/unsplash' +import type { UnsplashPhoto } from '@zennotes/bridge-contract/unsplash' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' @@ -14,8 +16,10 @@ import { setCoverFromBuiltin, setCoverFromClipboard, setCoverFromFile, + setCoverFromUnsplash, setCoverTarget } from '../lib/note-cover-actions' +import { unsplashSearchAvailable, useUnsplashSearch, type UnsplashSearchState } from '../lib/unsplash-search' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -26,12 +30,42 @@ type Choice = | { kind: 'builtin'; key: string; label: string; detail?: string; thumb: string; cover: BuiltinCover } | { kind: 'vault'; key: string; label: string; detail: string; thumb: string | null; path: string } | { kind: 'link'; key: string; label: string; detail: string; thumb: string; url: string } + | { kind: 'unsplash'; key: string; label: string; detail: string; thumb: string; photo: UnsplashPhoto } interface Section { title: string choices: Choice[] } +type PickerTab = 'covers' | 'unsplash' + +/** Tiles in a row at the picker's width: reaching the last row of Unsplash + * results asks for the next page. */ +const UNSPLASH_ROW = 4 + +/** What the Unsplash tab says instead of tiles, or null when it has tiles. */ +function unsplashMessage(state: UnsplashSearchState, count: number): string | null { + switch (state.status) { + case 'idle': + return 'Search free photos from Unsplash. Searches go through zennotes.org.' + case 'loading': + return 'Searching Unsplash…' + case 'failed': + if (state.reason === 'offline') { + return 'Unsplash could not be reached. Check your connection; the Covers tab works offline.' + } + if (state.reason === 'rate-limited') { + const until = state.retryAt + ? ` until ${new Date(state.retryAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` + : ' for now' + return `Unsplash search is resting${until}: its hourly limit is shared by everyone using ZenNotes. The Covers tab still works.` + } + return 'Unsplash search is not available right now. The Covers tab still works.' + case 'ready': + return count === 0 ? `No Unsplash photos match “${state.query}”.` : null + } +} + /** * The cover picker (`Space l c`, `:cover`, "Change cover"). One search field * over everything a cover can come from: an https link typed into it, the @@ -42,17 +76,29 @@ interface Section { * The field keeps focus the whole time, like every palette here: ↑/↓ (and * Ctrl+N/P, Ctrl+J/K) move a row at a time through the tiles, ←/→ step * through them while the field is empty, Enter picks, Escape closes. + * + * Unsplash is a second tab (Tab switches, keeping the text), not another + * section: a section would send every search typed here to the network, + * vault image names included, and spend the hourly budget every ZenNotes + * user shares on people who never wanted a photo. On the tab, searching is + * the user's own choice each time. */ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element { const setPickerPath = useStore((s) => s.setNoteCoverPickerPath) + const start = useStore((s) => s.noteCoverPickerStart) const vaultRoot = useStore((s) => s.vault?.root ?? null) const assetFiles = useStore((s) => s.assetFiles) const hasCover = useStore((s) => { const body = s.noteContents[notePath]?.body return body != null && readNoteCover(body) !== null }) - const [query, setQuery] = useState('') + const unsplashAvailable = unsplashSearchAvailable() + const [tab, setTab] = useState(() => + start?.tab === 'unsplash' && unsplashAvailable ? 'unsplash' : 'covers' + ) + const [query, setQuery] = useState(() => start?.query ?? '') const [active, setActive] = useState(0) + const { state: unsplash, loadMore } = useUnsplashSearch(query, tab === 'unsplash') const [busy, setBusy] = useState(false) const [dropActive, setDropActive] = useState(false) const inputRef = useRef(null) @@ -62,6 +108,21 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element const sections = useMemo(() => { const trimmed = query.trim() const out: Section[] = [] + if (tab === 'unsplash') { + if (unsplash.status !== 'ready' || unsplash.photos.length === 0) return out + out.push({ + title: 'Unsplash', + choices: unsplash.photos.map((photo) => ({ + kind: 'unsplash', + key: `unsplash:${photo.id}`, + label: photo.photographer.name, + detail: `${photo.alt ? `${photo.alt}\n` : ''}Photo by ${photo.photographer.name} on Unsplash`, + thumb: photo.thumbUrl, + photo + })) + }) + return out + } if (/^https:\/\/\S+$/i.test(trimmed)) { out.push({ title: 'Link', @@ -105,15 +166,23 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element if (photos.length) out.push({ title: 'Gallery', choices: photos }) if (vault.length) out.push({ title: 'Images in this vault', choices: vault }) return out - }, [assetFiles, query, vaultRoot]) + }, [assetFiles, query, tab, unsplash, vaultRoot]) const choices = useMemo(() => sections.flatMap((s) => s.choices), [sections]) + const unsplashQuery = unsplash.status === 'idle' ? '' : unsplash.query useEffect(() => { inputRef.current?.focus() }, []) - useEffect(() => setActive(0), [query]) + // A new search or tab starts at the first tile; a page of results added to + // the same search leaves the selection where it is. + useEffect(() => setActive(0), [query, tab, unsplashQuery]) + + // Nearing the end of the Unsplash results asks for the next page. + useEffect(() => { + if (tab === 'unsplash' && choices.length > 0 && active >= choices.length - UNSPLASH_ROW) loadMore() + }, [active, choices.length, loadMore, tab]) useEffect(() => { gridRef.current @@ -140,6 +209,7 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element if (!choice) return if (choice.kind === 'builtin') void run(() => setCoverFromBuiltin(notePath, choice.cover)) else if (choice.kind === 'vault') void run(() => setCoverTarget(notePath, { kind: 'vault', href: choice.path })) + else if (choice.kind === 'unsplash') void run(() => setCoverFromUnsplash(notePath, choice.photo)) else void run(() => setCoverTarget(notePath, { kind: 'url', url: choice.url })) } @@ -169,9 +239,16 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element setActive(row[0]!.index) } + const switchTab = (): void => setTab((t) => (t === 'covers' ? 'unsplash' : 'covers')) + const onInputKeyDown = (e: ReactKeyboardEvent): void => { if (isImeComposing(e)) return - if (isPaletteNextKey(e)) { + if (e.key === 'Tab' && unsplashAvailable && !e.altKey && !e.ctrlKey && !e.metaKey) { + // Two tabs, so Shift+Tab goes to the other one as well. + e.preventDefault() + e.stopPropagation() + switchTab() + } else if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() moveRow(1) @@ -241,26 +318,53 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element setQuery(e.target.value)} onKeyDown={onInputKeyDown} className="w-full bg-transparent text-base text-ink-900 outline-none placeholder:text-ink-400" />
- - + {unsplashAvailable && ( +
+ {(['covers', 'unsplash'] as const).map((id) => ( + + ))} +
+ )} + {tab === 'covers' && ( + <> + + + + )} {hasCover && ( )} - or drop or paste an image here + + {tab === 'unsplash' ? 'Photos stay on Unsplash and load from there' : 'or drop or paste an image here'} +
-
+
{ + const el = e.currentTarget + if (tab === 'unsplash' && el.scrollTop + el.clientHeight >= el.scrollHeight - 80) loadMore() + }} + > {sections.length === 0 ? ( -
No covers match.
+
+ {tab === 'unsplash' ? unsplashMessage(unsplash, choices.length) : 'No covers match.'} +
) : ( sections.map((section) => (
@@ -300,7 +413,10 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element selected ? 'ring-2 ring-accent' : 'ring-paper-300 hover:ring-paper-400' ].join(' ')} > - + {choice.thumb && ( )} @@ -315,6 +431,12 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element )}
+ {tab === 'unsplash' && } + {unsplashAvailable && ( + + tab {tab === 'covers' ? 'Unsplash' : 'covers'} + + )} ↑↓←→ move @@ -329,3 +451,34 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element ) } + +/** The footer's credit for the selected photo, both links the way Unsplash + * asks: the photographer's profile and Unsplash, with the referral tags. */ +function UnsplashCreditLine({ choice }: { choice: Choice | undefined }): JSX.Element { + const home = unsplashReferralUrl(UNSPLASH_HOME_URL) + const unsplashLink = ( + + Unsplash + + ) + return ( + + {choice?.kind === 'unsplash' ? ( + <> + Photo by{' '} + + {choice.photo.photographer.name} + {' '} + on {unsplashLink} + + ) : ( + <>Photos from {unsplashLink} + )} + + ) +} diff --git a/packages/app-core/src/components/ReadOnlyNoteCover.test.ts b/packages/app-core/src/components/ReadOnlyNoteCover.test.ts index f5b6ad58f..0c87cfe47 100644 --- a/packages/app-core/src/components/ReadOnlyNoteCover.test.ts +++ b/packages/app-core/src/components/ReadOnlyNoteCover.test.ts @@ -4,6 +4,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { gradientCoverDataUrl, NOTE_COVER_GRADIENTS } from '@shared/note-cover-gradients' +import { noteCoverCreditLine } from '@shared/unsplash' import { ReadOnlyNoteCover, readOnlyCoverSource } from './ReadOnlyNoteCover' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -49,6 +50,34 @@ describe('ReadOnlyNoteCover', () => { expect(img.style.objectPosition).toBe('50% 25%') }) + it('keeps an Unsplash credit in view, with both links tagged for Unsplash', () => { + const url = 'https://images.unsplash.com/photo-1?ixid=x&w=2400&q=80&auto=format' + const credit = noteCoverCreditLine({ + key: 'banner', + target: { kind: 'url', url }, + y: 0.5, + credit: { name: 'Jane Doe', url: 'https://unsplash.com/@janedoe' } + }) + act(() => root.render(createElement(ReadOnlyNoteCover, { url, y: 0.5, credit }))) + const line = host.querySelector('[data-note-cover-credit]') as HTMLElement + expect(line.textContent).toBe('Photo by Jane Doe on Unsplash') + expect(line.className).not.toContain('opacity-0') + const links = Array.from(line.querySelectorAll('a')).map((a) => [a.textContent, a.getAttribute('href'), a.target]) + expect(links).toEqual([ + ['Jane Doe', 'https://unsplash.com/@janedoe?utm_source=zennotes&utm_medium=referral', '_blank'], + ['Unsplash', 'https://unsplash.com/?utm_source=zennotes&utm_medium=referral', '_blank'] + ]) + }) + + it('draws a plain credit as written, and none without one', () => { + const credit = noteCoverCreditLine({ key: 'banner', target: { kind: 'vault', href: 'a.png' }, y: 0.5, credit: { name: 'Me', url: null } }) + act(() => root.render(createElement(ReadOnlyNoteCover, { url: 'https://example.com/a.png', y: 0.5, credit }))) + expect(host.querySelector('[data-note-cover-credit]')?.textContent).toBe('Me') + expect(host.querySelector('[data-note-cover-credit] a')).toBeNull() + act(() => root.render(createElement(ReadOnlyNoteCover, { url: 'https://example.com/a.png', y: 0.5 }))) + expect(host.querySelector('[data-note-cover-credit]')).toBeNull() + }) + it('draws no cover for a source it refuses', () => { act(() => root.render(createElement(ReadOnlyNoteCover, { url: 'javascript:alert(1)', y: 0.5 }))) expect(host.querySelector('[data-note-cover]')).toBeNull() diff --git a/packages/app-core/src/components/ReadOnlyNoteCover.tsx b/packages/app-core/src/components/ReadOnlyNoteCover.tsx index d708020ce..50c222aa6 100644 --- a/packages/app-core/src/components/ReadOnlyNoteCover.tsx +++ b/packages/app-core/src/components/ReadOnlyNoteCover.tsx @@ -1,9 +1,14 @@ import { useEffect, useState } from 'react' +import type { NoteCoverCreditLine } from '@shared/unsplash' +import { NoteCoverCredit } from './NoteCoverCredit' interface ReadOnlyNoteCoverProps { url: string /** Vertical focal point, 0 (top) to 1 (bottom). */ y: number + /** Who made the picture; always in view here, since nothing hovers on a + * page nobody edits. */ + credit?: NoteCoverCreditLine | null } /** What a read-only cover may load: an http(s) image (the share page's @@ -25,7 +30,7 @@ export function readOnlyCoverSource(url: string): string | null { * imports, which would carry the cover gallery into those bundles. An image * that does not load takes its frame with it: a reader cannot fix it. */ -export function ReadOnlyNoteCover({ url, y }: ReadOnlyNoteCoverProps): JSX.Element | null { +export function ReadOnlyNoteCover({ url, y, credit }: ReadOnlyNoteCoverProps): JSX.Element | null { const [failed, setFailed] = useState(false) useEffect(() => setFailed(false), [url]) const src = readOnlyCoverSource(url) @@ -43,6 +48,7 @@ export function ReadOnlyNoteCover({ url, y }: ReadOnlyNoteCoverProps): JSX.Eleme className="pointer-events-none h-full w-full object-cover" style={{ objectPosition: `50% ${Math.round(y * 1000) / 10}%` }} /> + {credit && }
) } diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index d5e1ac0d0..5926f2b8e 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -65,7 +65,8 @@ import { import { requestSettingsTarget } from '../lib/settings-navigation' import { hideWindowToTray, useTrayStore } from '../lib/tray-runtime' import { EXCALIDRAW_SURFACE, SELF_KEYED_SURFACES } from '../lib/self-keyed-surfaces' -import { openCoverPicker } from '../lib/note-cover-actions' +import { openCoverPicker, openUnsplashCoverPicker } from '../lib/note-cover-actions' +import { unsplashSearchAvailable } from '../lib/unsplash-search' import { isWorkspaceVirtualTabPath } from '../lib/workspace-tabs' import { isExcalidrawPath, @@ -281,7 +282,16 @@ export function VimNav(): JSX.Element | null { keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderNoteCover'), label: 'Cover image', detail: 'Add, change or remove the banner image across the top of the note.' - } + }, + ...(unsplashSearchAvailable() + ? [ + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderNoteCoverUnsplash'), + label: 'Cover from Unsplash', + detail: 'Search free Unsplash photos for the cover.' + } + ] + : []) ] } if (whichKeyState.stage === 'leader-s') { @@ -1172,6 +1182,13 @@ export function VimNav(): JSX.Element | null { openCoverPicker() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderNoteCoverUnsplash')) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + openUnsplashCoverPicker() + return + } resetLeader() } diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 1736d39b5..6b16eb240 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -45,10 +45,12 @@ import { activeNoteCover, coverNotePath, openCoverPicker, + openUnsplashCoverPicker, removeCover, setCoverFromClipboard, startCoverReposition } from './note-cover-actions' +import { unsplashSearchAvailable } from './unsplash-search' import { csvPathFromDatabaseTab } from '@shared/databases' import { canRenameVault, renameVaultWithPrompt } from './rename-vault' import { promptImageWidth } from './image-resize' @@ -393,6 +395,17 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma openCoverPicker() } }, + { + id: 'note.cover.unsplash', + title: 'Add Cover from Unsplash…', + category: 'Note', + keywords: 'banner header image picture photo stock unsplash', + shortcut: chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCoverUnsplash'), + when: () => !!coverNotePath() && unsplashSearchAvailable(), + run: () => { + openUnsplashCoverPicker() + } + }, { id: 'note.cover.paste', title: 'Paste Cover from Clipboard', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 44688ef4f..83ad316d5 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -139,7 +139,12 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Give a note a cover image', body: - 'A cover is a wide banner image across the top of a note. Press `Space l c` in Vim mode, run “Add Cover…” from the command palette, or type `:cover` to open the cover picker. Type to search it, move with the arrow keys and press Enter to pick a gradient, a photograph from the gallery (public-domain works from The Met and NASA), or an image already in the vault. Upload image… takes a file from disk, and an image you paste (⌘V / Ctrl+V) or drop anywhere in the picker becomes the cover; paste an https link into the search field to use a picture from the web. Gradients, gallery photos and uploads are copied into the vault’s `assets/` folder like a pasted image, so the cover works offline, on a self-hosted server and through Cloud sync, end-to-end encrypted vaults included. Hover the cover for Change cover, Reposition and Remove, or drop an image straight onto it. Reposition (also “Reposition Cover” in the palette and `:cover reposition`) lets you drag the picture up or down, or move it with ↑/↓ (j/k in Vim mode, Shift for small steps); Enter keeps the new position and Escape puts the old one back. The cover is plain frontmatter, `banner: "![[assets/photo.jpg]]"` with the focal point in `banner_y` (0 is the top edge, 1 the bottom), the keys Obsidian’s banner plugins read, so a shared vault shows the same banner there; a `cover:` image, or one written as a plain `[[link]]`, shows too. The Assets view counts a cover image as used, renaming or moving the image updates the note, and `u` (or Undo) takes back a cover change made in the editor. Settings → Typography → Note covers decides whether the cover scrolls away with the text, stays pinned above the editor and Read view, or is hidden. A published note’s public page shows its cover too, and so does Quick Look when you press Space on a note in Finder on macOS: both draw it read-only at the note’s focal point. Quick Look follows the Hidden setting and leaves out a cover from the web, since a preview never goes online.' + 'A cover is a wide banner image across the top of a note. Press `Space l c` in Vim mode, run “Add Cover…” from the command palette, or type `:cover` to open the cover picker. Type to search it, move with the arrow keys and press Enter to pick a gradient, a photograph from the gallery (public-domain works from The Met and NASA), or an image already in the vault. Upload image… takes a file from disk, and an image you paste (⌘V / Ctrl+V) or drop anywhere in the picker becomes the cover; paste an https link into the search field to use a picture from the web, or press Tab for the Unsplash tab and its free photos (see the next guide). Gradients, gallery photos and uploads are copied into the vault’s `assets/` folder like a pasted image, so the cover works offline, on a self-hosted server and through Cloud sync, end-to-end encrypted vaults included. Hover the cover for Change cover, Reposition and Remove, or drop an image straight onto it. Reposition (also “Reposition Cover” in the palette and `:cover reposition`) lets you drag the picture up or down, or move it with ↑/↓ (j/k in Vim mode, Shift for small steps); Enter keeps the new position and Escape puts the old one back. The cover is plain frontmatter, `banner: "![[assets/photo.jpg]]"` with the focal point in `banner_y` (0 is the top edge, 1 the bottom), the keys Obsidian’s banner plugins read, so a shared vault shows the same banner there; a `cover:` image, or one written as a plain `[[link]]`, shows too. The Assets view counts a cover image as used, renaming or moving the image updates the note, and `u` (or Undo) takes back a cover change made in the editor. Settings → Typography → Note covers decides whether the cover scrolls away with the text, stays pinned above the editor and Read view, or is hidden. A published note’s public page shows its cover too, and so does Quick Look when you press Space on a note in Finder on macOS: both draw it read-only at the note’s focal point. Quick Look follows the Hidden setting and leaves out a cover from the web, since a preview never goes online.' + }, + { + title: 'Use an Unsplash photo as a cover', + body: + 'The cover picker has a second tab, Unsplash, that searches Unsplash’s free photos. Press Tab in the picker to switch between Covers and Unsplash (what you typed comes along), press `Space l u` in Vim mode, run “Add Cover from Unsplash…” from the command palette, or type `:cover unsplash mountains` to open the tab on a search. Photos appear as you type; move with the arrow keys (or Ctrl+N/P, Ctrl+J/K) and press Enter to make the selected one the cover. The line under the grid credits the selected photo’s photographer, with links to their profile and to Unsplash. Unsplash’s rules keep its photos on Unsplash, so unlike a gradient or a gallery photo the picture is not copied into `assets/`: the note links to it, `banner: "https://images.unsplash.com/…"`, with the credit on the two lines under it, `banner_credit` (the photographer) and `banner_credit_url` (their Unsplash profile). Hover the cover to see “Photo by … on Unsplash” with both links; a published note’s page always shows it. The picture needs a connection to load. One you have seen before usually still shows offline; otherwise the cover says it could not load the picture from images.unsplash.com. To keep a photo for offline use, download it from unsplash.com and drop it on the picker. Searches go through zennotes.org, which holds ZenNotes’ Unsplash key and shares one hourly allowance among everyone, so when that runs out the tab says when to try again; the Covers tab never goes online. Choosing another cover or removing this one takes the credit away with it.' }, { title: 'Give a vault a name of its own', @@ -619,6 +624,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space c', action: 'Toggle calendar', detail: 'Show or hide the calendar panel for the active pane.' }, { keys: 'Space l s', action: 'Toggle favorite', detail: 'Add or remove the active note from the Favorites section shown in the sidebar and on the home view. Folders join it from their context menu.' }, { keys: 'Space l c', action: 'Cover image', detail: 'Open the cover picker for the active note: a gradient, a gallery photo, an image in the vault, an upload, a pasted image or an https link. Also “Add Cover…” in the command palette and `:cover`.' }, + { keys: 'Space l u', action: 'Cover from Unsplash', detail: 'Open the cover picker on its Unsplash tab to search free photos for the active note’s cover. Also “Add Cover from Unsplash…” in the command palette and `:cover unsplash {search}`.' }, { keys: 'Space l d', action: 'Convert table to database', detail: 'Turn the Markdown table under the cursor (or the rendered table whose cell you are in) into a `.base` database and leave a `[[link]]` to it in the note. Also “Convert Table to Database…” in the command palette, `:table_to_database`, and the bottom of the table cell menu.' }, { keys: 'Space, then pause', action: 'Show leader hints', detail: 'If enabled in Settings, open a which-key style guide for the next available leader actions. Sticky mode keeps it open until `Space` or `Esc`.' }, { keys: 'Mod+3', action: 'Toggle outline panel', detail: 'Show or hide the persistent outline in the active pane. Once focused (Ctrl+W l or Alt+L from the editor), j / k (or the arrows) walk the headings, gg / G jump to the first and last, Enter jumps the editor to the heading under the cursor, and Esc hands focus back.' }, @@ -872,7 +878,7 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ { command: ':cover [image]', summary: 'Set the note’s cover image', - detail: 'Bare `:cover` (or `:cov`) opens the cover picker. With an argument it sets the cover: an https URL, a vault image by path or unique file name, or a built-in cover by name (`:cover dusk`, `:cover earthrise`). `:cover paste` uses the image on the clipboard, `:cover reposition` enters reposition mode, `:cover 30%` sets the focal point, and `:cover!` (or `:cover remove`) removes the cover.' + detail: 'Bare `:cover` (or `:cov`) opens the cover picker. With an argument it sets the cover: an https URL, a vault image by path or unique file name, or a built-in cover by name (`:cover dusk`, `:cover earthrise`). `:cover unsplash fog` opens the picker’s Unsplash tab on that search (Enter picks the first photo), `:cover paste` uses the image on the clipboard, `:cover reposition` enters reposition mode, `:cover 30%` sets the focal point, and `:cover!` (or `:cover remove`) removes the cover.' }, { command: ':tasks', diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 025f791d9..6ab45817a 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -69,6 +69,7 @@ export type KeymapId = | "vim.leaderToggleFavorite" | "vim.leaderTableToDatabase" | "vim.leaderNoteCover" + | "vim.leaderNoteCoverUnsplash" | "vim.leaderQuickCapture" | "vim.leaderHideToTray" | "vim.leaderTemplatePicker" @@ -738,6 +739,18 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderNoteCoverUnsplash", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader note action: cover from Unsplash", + description: + "Open the cover picker on its Unsplash tab, to search free photos for the active note's cover.", + defaultBinding: "u", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.leaderQuickCapture", kind: "sequence", diff --git a/packages/app-core/src/lib/note-cover-actions.ts b/packages/app-core/src/lib/note-cover-actions.ts index b6cf140c4..3e501d84c 100644 --- a/packages/app-core/src/lib/note-cover-actions.ts +++ b/packages/app-core/src/lib/note-cover-actions.ts @@ -11,7 +11,9 @@ * New images arrive through `importPastedImage`, the one import every host * has (local and remote desktop vaults, the web client over the Go server, * the phone shells), so the file lands in the vault's `assets/` folder by - * the same rules a pasted screenshot does. + * the same rules a pasted screenshot does. An Unsplash photo is the one + * exception: Unsplash's terms keep it on images.unsplash.com, so it is an + * https cover with its photographer's credit (shared-domain unsplash.ts). */ import { readNoteCover, @@ -22,11 +24,14 @@ import { type NoteCoverTarget } from '@shared/note-cover' import { IMAGE_FILE_EXTENSIONS } from '@shared/pasted-image' +import { unsplashCoverUrl, unsplashPhotoCredit } from '@shared/unsplash' +import type { UnsplashPhoto } from '@zennotes/bridge-contract/unsplash' import { useStore } from '../store' import { useToastStore } from './toast' import { minimalTextChange } from './minimal-text-change' import { isClipboardImageFile, readSystemClipboard } from './editor-paste-images' import type { BuiltinCover } from './note-cover-gallery' +import { unsplashSearchAvailable } from './unsplash-search' /** The note whose cover the cover commands act on: the active note, unless it * is in the Trash, where nothing gets edited. */ @@ -37,18 +42,28 @@ export function coverNotePath(): string | null { return active.path } -/** Open the cover picker for the active note. False (with a word why) when - * there is no note to give a cover. */ -export function openCoverPicker(): boolean { +/** Open the cover picker for the active note, on the Covers tab unless told + * otherwise. False (with a word why) when there is no note to give a cover. */ +export function openCoverPicker(start?: { tab: 'covers' | 'unsplash'; query: string }): boolean { const path = coverNotePath() if (!path) { useToastStore.getState().addToast('Open a note to give it a cover', 'info') return false } - useStore.getState().setNoteCoverPickerPath(path) + useStore.getState().setNoteCoverPickerPath(path, start ?? null) return true } +/** The picker on its Unsplash tab, searching for `query` when there is one + * (`Space l u`, `:cover unsplash fog`, the palette). */ +export function openUnsplashCoverPicker(query = ''): boolean { + if (!unsplashSearchAvailable()) { + useToastStore.getState().addToast('Unsplash covers are not available in this app yet', 'info') + return false + } + return openCoverPicker({ tab: 'unsplash', query }) +} + /** Put the active note's cover into reposition mode; the first visible copy * of it in the active pane takes the keyboard. */ export function startCoverReposition(): boolean { @@ -180,6 +195,20 @@ export async function setCoverFromBuiltin(path: string, cover: BuiltinCover): Pr } } +/** An Unsplash photo as the cover: hotlinked at a cover's width, never + * copied into the vault, with its photographer's credit on the lines under + * it. Unsplash hears that the photo was used (their download tracking) only + * once the note has it, and a ping that fails changes nothing. */ +export async function setCoverFromUnsplash(path: string, photo: UnsplashPhoto): Promise { + const url = unsplashCoverUrl(photo.rawUrl) + if (!url) return false + const changed = await editNoteCover(path, (body) => + setNoteCover(body, { kind: 'url', url }, unsplashPhotoCredit(photo)) + ) + if (changed) void window.zen.trackUnsplashDownload?.(photo.downloadLocation)?.catch(() => {}) + return changed +} + /** `:cover `: an https URL, or an image already in the vault, matched by * path or, when unique, by file name. */ export function resolveCoverArgument(arg: string): NoteCoverTarget | null { diff --git a/packages/app-core/src/lib/unsplash-search.test.ts b/packages/app-core/src/lib/unsplash-search.test.ts new file mode 100644 index 000000000..e31696479 --- /dev/null +++ b/packages/app-core/src/lib/unsplash-search.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UnsplashPhoto, UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' +import { + UnsplashAnswerCache, + createUnsplashSearch, + type UnsplashSearchFn, + type UnsplashSearchState +} from './unsplash-search' + +function photo(id: string): UnsplashPhoto { + return { + id, + width: 6000, + height: 4000, + color: '#336699', + alt: null, + rawUrl: `https://images.unsplash.com/photo-${id}?ixid=x`, + thumbUrl: `https://images.unsplash.com/photo-${id}?ixid=x&w=400`, + photographer: { name: `Photographer ${id}`, profileUrl: `https://unsplash.com/@p${id}` }, + downloadLocation: `https://api.unsplash.com/photos/${id}/download?ixid=x` + } +} + +function answer(query: string, page: number, ids: string[], totalPages = 3): UnsplashSearchResult { + return { ok: true, page: { query, page, totalPages, photos: ids.map(photo) } } +} + +function setup(search: UnsplashSearchFn) { + const states: UnsplashSearchState[] = [] + const controller = createUnsplashSearch({ + search, + onChange: (s) => states.push(s), + cache: new UnsplashAnswerCache() + }) + return { controller, states, last: () => states[states.length - 1] } +} + +describe('createUnsplashSearch', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('waits for a pause in typing and asks once, for the last text', async () => { + const search = vi.fn(async (q, page) => answer(q, page, ['a', 'b'])) + const { controller, last } = setup(search) + controller.setQuery('f') + expect(last()).toEqual({ status: 'idle' }) + controller.setQuery('fo') + controller.setQuery('fog') + expect(last()).toEqual({ status: 'loading', query: 'fog' }) + await vi.advanceTimersByTimeAsync(399) + expect(search).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(search).toHaveBeenCalledTimes(1) + expect(search).toHaveBeenCalledWith('fog', 1) + expect(last()).toMatchObject({ status: 'ready', query: 'fog', page: 1, totalPages: 3 }) + }) + + it('drops an answer that arrives after a newer search', async () => { + let release: (r: UnsplashSearchResult) => void = () => {} + const search = vi.fn((q, page) => + q === 'fog' ? new Promise((resolve) => (release = resolve)) : Promise.resolve(answer(q, page, ['m'])) + ) + const { controller, last } = setup(search) + controller.setQuery('fog') + await vi.advanceTimersByTimeAsync(400) + controller.setQuery('mountain') + await vi.advanceTimersByTimeAsync(400) + expect(last()).toMatchObject({ status: 'ready', query: 'mountain' }) + release(answer('fog', 1, ['f'])) + await vi.advanceTimersByTimeAsync(0) + expect(last()).toMatchObject({ status: 'ready', query: 'mountain' }) + }) + + it('answers a repeated search from the cache without asking', async () => { + const search = vi.fn(async (q, page) => answer(q, page, ['a'])) + const { controller, last } = setup(search) + controller.setQuery('fog') + await vi.advanceTimersByTimeAsync(400) + controller.setQuery('forest') + await vi.advanceTimersByTimeAsync(400) + controller.setQuery('FOG') + expect(last()).toMatchObject({ status: 'ready', query: 'FOG' }) + expect(search).toHaveBeenCalledTimes(2) + }) + + it('appends the next page once, without repeats', async () => { + const search = vi.fn(async (q, page) => + page === 1 ? answer(q, 1, ['a', 'b']) : answer(q, 2, ['b', 'c']) + ) + const { controller, last } = setup(search) + controller.setQuery('fog') + await vi.advanceTimersByTimeAsync(400) + controller.loadMore() + controller.loadMore() + expect(last()).toMatchObject({ loadingMore: true }) + await vi.advanceTimersByTimeAsync(0) + const state = last() + expect(state.status === 'ready' && state.photos.map((p) => p.id)).toEqual(['a', 'b', 'c']) + expect(state).toMatchObject({ page: 2, loadingMore: false }) + expect(search).toHaveBeenCalledTimes(2) + }) + + it('stops at the last page', async () => { + const search = vi.fn(async (q, page) => answer(q, page, ['a'], 1)) + const { controller } = setup(search) + controller.setQuery('fog') + await vi.advanceTimersByTimeAsync(400) + controller.loadMore() + expect(search).toHaveBeenCalledTimes(1) + }) + + it('reports a failure, and asks again when the same search comes back', async () => { + const search = vi + .fn() + .mockResolvedValueOnce({ ok: false, reason: 'rate-limited', retryAt: 5_000 }) + .mockImplementation(async (q, page) => answer(q, page, ['a'])) + const { controller, last } = setup(search) + controller.setQuery('fog') + await vi.advanceTimersByTimeAsync(400) + expect(last()).toEqual({ status: 'failed', query: 'fog', reason: 'rate-limited', retryAt: 5_000 }) + controller.setQuery('fog') + await vi.advanceTimersByTimeAsync(400) + expect(last()).toMatchObject({ status: 'ready', query: 'fog' }) + }) + + it('treats a host that throws as unavailable, and goes quiet once disposed', async () => { + const search = vi.fn(async () => { + throw new Error('bridge gone') + }) + const { controller, last, states } = setup(search) + controller.setQuery('fog') + await vi.advanceTimersByTimeAsync(400) + expect(last()).toMatchObject({ status: 'failed', reason: 'unavailable' }) + controller.setQuery('forest') + controller.dispose() + const seen = states.length + await vi.advanceTimersByTimeAsync(400) + expect(states.length).toBe(seen) + }) +}) + +describe('UnsplashAnswerCache', () => { + it('keeps the most recent answers', () => { + const cache = new UnsplashAnswerCache(2) + for (const q of ['a1', 'b1', 'c1']) { + const r = answer(q, 1, []) + if (r.ok) cache.set(r.page) + } + expect(cache.get('a1', 1)).toBeUndefined() + expect(cache.get('c1', 1)?.query).toBe('c1') + }) +}) diff --git a/packages/app-core/src/lib/unsplash-search.ts b/packages/app-core/src/lib/unsplash-search.ts new file mode 100644 index 000000000..6ac46bde4 --- /dev/null +++ b/packages/app-core/src/lib/unsplash-search.ts @@ -0,0 +1,213 @@ +/** + * The cover picker's Unsplash tab: search as you type, gentle on the one + * hourly budget every ZenNotes user shares through the zennotes.org proxy. + * A query waits for a pause in typing and needs two characters; an answer + * that arrives after a newer query is dropped; the last answers are kept for + * the session, so switching tabs or reopening the picker asks nothing again. + * + * The host does the sending (`window.zen.searchUnsplash`); a host without it + * (the phone shells, until they add it) gets no Unsplash tab at all. + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { + UnsplashPhoto, + UnsplashSearchFailure, + UnsplashSearchPage, + UnsplashSearchResult +} from '@zennotes/bridge-contract/unsplash' +import { UNSPLASH_MIN_QUERY_LENGTH, normalizeUnsplashQuery } from '@shared/unsplash' + +export const UNSPLASH_DEBOUNCE_MS = 400 +const CACHE_LIMIT = 20 + +export type UnsplashSearchState = + | { status: 'idle' } + | { status: 'loading'; query: string } + | { + status: 'ready' + query: string + photos: UnsplashPhoto[] + page: number + totalPages: number + loadingMore: boolean + } + | { status: 'failed'; query: string; reason: UnsplashSearchFailure; retryAt: number | null } + +export type UnsplashSearchFn = (query: string, page: number) => Promise + +export function unsplashSearchAvailable(): boolean { + return typeof window.zen?.searchUnsplash === 'function' +} + +/** Answers by `query\npage`, oldest first, shared by every picker this + * session opens. Only successes are kept: a failure is worth asking again. */ +export class UnsplashAnswerCache { + private readonly pages = new Map() + constructor(private readonly limit = CACHE_LIMIT) {} + + private key(query: string, page: number): string { + return `${query.toLowerCase()}\n${page}` + } + + get(query: string, page: number): UnsplashSearchPage | undefined { + const key = this.key(query, page) + const hit = this.pages.get(key) + if (hit) { + this.pages.delete(key) + this.pages.set(key, hit) + } + return hit + } + + set(page: UnsplashSearchPage): void { + const key = this.key(page.query, page.page) + this.pages.delete(key) + this.pages.set(key, page) + while (this.pages.size > this.limit) this.pages.delete(this.pages.keys().next().value as string) + } +} + +const sessionCache = new UnsplashAnswerCache() + +export interface UnsplashSearchController { + setQuery(query: string): void + loadMore(): void + dispose(): void +} + +/** + * The search itself, without React: `onChange` hears every state. Kept apart + * from the hook so the timing (debounce, stale answers, paging) is testable + * with fake timers. + */ +export function createUnsplashSearch(deps: { + search: UnsplashSearchFn + onChange: (state: UnsplashSearchState) => void + cache?: UnsplashAnswerCache + debounceMs?: number +}): UnsplashSearchController { + const cache = deps.cache ?? sessionCache + const debounceMs = deps.debounceMs ?? UNSPLASH_DEBOUNCE_MS + let state: UnsplashSearchState = { status: 'idle' } + let current = '' + let generation = 0 + let timer: ReturnType | null = null + let disposed = false + + const emit = (next: UnsplashSearchState): void => { + state = next + if (!disposed) deps.onChange(next) + } + + const ask = async (query: string, page: number): Promise => { + const hit = cache.get(query, page) + if (hit) return { ok: true, page: hit } + let result: UnsplashSearchResult + try { + result = await deps.search(query, page) + } catch { + result = { ok: false, reason: 'unavailable', retryAt: null } + } + if (result.ok) cache.set(result.page) + return result + } + + const run = async (query: string, mine: number): Promise => { + const result = await ask(query, 1) + if (mine !== generation || disposed) return + if (!result.ok) { + emit({ status: 'failed', query, reason: result.reason, retryAt: result.retryAt }) + return + } + emit({ + status: 'ready', + query, + photos: result.page.photos, + page: 1, + totalPages: result.page.totalPages, + loadingMore: false + }) + } + + return { + setQuery(raw) { + const query = normalizeUnsplashQuery(raw) + // The same text again keeps what is showing, unless it failed: then + // coming back to the tab is the way to ask again. + if (query === current && state.status !== 'idle' && state.status !== 'failed') return + current = query + generation++ + if (timer) clearTimeout(timer) + timer = null + if (query.length < UNSPLASH_MIN_QUERY_LENGTH) { + emit({ status: 'idle' }) + return + } + const mine = generation + const hit = cache.get(query, 1) + if (hit) { + emit({ status: 'ready', query, photos: hit.photos, page: 1, totalPages: hit.totalPages, loadingMore: false }) + return + } + emit({ status: 'loading', query }) + timer = setTimeout(() => { + timer = null + void run(query, mine) + }, debounceMs) + }, + loadMore() { + const s = state + if (s.status !== 'ready' || s.loadingMore || s.page >= s.totalPages) return + const mine = generation + emit({ ...s, loadingMore: true }) + void ask(s.query, s.page + 1).then((result) => { + if (mine !== generation || disposed || state.status !== 'ready') return + const now = state + if (!result.ok) { + // Keep what is on screen; the next move to the last row asks again. + emit({ ...now, loadingMore: false }) + return + } + const seen = new Set(now.photos.map((p) => p.id)) + emit({ + ...now, + photos: [...now.photos, ...result.page.photos.filter((p) => !seen.has(p.id))], + page: s.page + 1, + totalPages: result.page.totalPages || now.totalPages, + loadingMore: false + }) + }) + }, + dispose() { + disposed = true + if (timer) clearTimeout(timer) + } + } +} + +/** The Unsplash tab's search: idle until `enabled` (the tab is showing). */ +export function useUnsplashSearch( + query: string, + enabled: boolean +): { state: UnsplashSearchState; loadMore: () => void } { + const [state, setState] = useState({ status: 'idle' }) + const controller = useRef(null) + + useEffect(() => { + const search = window.zen?.searchUnsplash + if (!search) return + const c = createUnsplashSearch({ search: (q, page) => search(q, page), onChange: setState }) + controller.current = c + return () => { + c.dispose() + controller.current = null + } + }, []) + + useEffect(() => { + if (enabled) controller.current?.setQuery(query) + }, [enabled, query]) + + const loadMore = useCallback(() => controller.current?.loadMore(), []) + return { state, loadMore } +} diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index a4983b641..8cfea7c29 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3069,6 +3069,9 @@ interface Store { embedDrawingPaletteOpen: boolean /** The note the cover picker is open for, or null while it is closed. */ noteCoverPickerPath: string | null + /** The tab and search the picker opens on (`:cover unsplash fog`, Space l u); + * null for the Covers tab with an empty field. */ + noteCoverPickerStart: { tab: 'covers' | 'unsplash'; query: string } | null /** A cover in reposition mode. `owner` is the on-screen copy that took the * request; null until one does (the palette asks for "the" cover). */ noteCoverReposition: { path: string; owner: string | null } | null @@ -3887,7 +3890,10 @@ interface Store { openThisMonthMonthlyNote: () => Promise setTemplatePaletteOpen: (open: boolean) => void setEmbedDrawingPaletteOpen: (open: boolean) => void - setNoteCoverPickerPath: (path: string | null) => void + setNoteCoverPickerPath: ( + path: string | null, + start?: { tab: 'covers' | 'unsplash'; query: string } | null + ) => void setNoteCoverReposition: (reposition: { path: string; owner: string | null } | null) => void /** Create a new Excalidraw drawing and open it in a dedicated tab. */ newDrawing: () => Promise @@ -6038,6 +6044,7 @@ export const useStore = create((set, get) => { templatePaletteOpen: false, embedDrawingPaletteOpen: false, noteCoverPickerPath: null, + noteCoverPickerStart: null, noteCoverReposition: null, excalidrawPreviewVersion: 0, templatePaletteMode: 'create', @@ -9214,7 +9221,8 @@ export const useStore = create((set, get) => { }), setBufferPaletteOpen: (open) => set({ bufferPaletteOpen: open }), setEmbedDrawingPaletteOpen: (open) => set({ embedDrawingPaletteOpen: open }), - setNoteCoverPickerPath: (path) => set({ noteCoverPickerPath: path }), + setNoteCoverPickerPath: (path, start = null) => + set({ noteCoverPickerPath: path, noteCoverPickerStart: path ? start : null }), setNoteCoverReposition: (reposition) => set({ noteCoverReposition: reposition }), setOutlinePaletteOpen: (open) => set({ outlinePaletteOpen: open }), setQuery: (q) => set({ query: q }), diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 786385c0c..dcf347f46 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -1578,6 +1578,14 @@ textarea, .zen-note-cover.zen-note-cover-pinned { height: clamp(6rem, 18vh, 12rem); } +/* A cover's credit shows with its buttons, on hover or focus. A screen that + cannot hover (a phone, a tablet) shows it all the time, or it would never + show at all. */ +@media (hover: none) { + .zen-note-cover .zen-note-cover-credit { + opacity: 1; + } +} /* In the editor the cover lives in a block widget inside the capped text column; the bleed variables (measured by cm-note-cover.ts) widen it to the whole pane. The gap under it is padding: CodeMirror measures a block widget diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 529d80ae2..af4dd9a84 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -17,7 +17,8 @@ "./application-links": "./src/application-links.ts", "./custom-code-languages": "./src/custom-code-languages.ts", "./app-config": "./src/app-config.ts", - "./platform": "./src/platform.ts" + "./platform": "./src/platform.ts", + "./unsplash": "./src/unsplash.ts" }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 25bb51b5c..4543a1824 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -43,6 +43,7 @@ import type { VaultTextSearchToolPaths } from './ipc.js' import type { CustomTemplateFile, WriteTemplateInput } from './templates.js' +import type { UnsplashSearchResult } from './unsplash.js' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -179,6 +180,12 @@ export interface ZenBridge { * no identifier. The caller checks the setting; the host refuses * development builds and test runs, and sends each event once per run. */ sendUsageCount?(event: 'install' | 'update'): Promise + /** Unsplash photo search for the cover picker, through the proxy on + * zennotes.org (no host holds an Unsplash key). A host without it shows no + * Unsplash tab. Never rejects: a failure comes back as its reason. */ + searchUnsplash?(query: string, page: number): Promise + /** Unsplash's download tracking: a photo became a cover. Fire and forget. */ + trackUnsplashDownload?(downloadLocation: string): Promise checkForAppUpdatesWithUi(): Promise downloadAppUpdate(): Promise installAppUpdate(): Promise diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index b109fc9dd..6ea1a9ade 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -112,6 +112,8 @@ export const IPC = { APP_UPDATER_GET_STATE: 'app-updater:get-state', APP_UPDATER_CHECK: 'app-updater:check', APP_USAGE_COUNT: 'app:usage-count', + UNSPLASH_SEARCH: 'unsplash:search', + UNSPLASH_TRACK_DOWNLOAD: 'unsplash:track-download', APP_UPDATER_CHECK_WITH_UI: 'app-updater:check-with-ui', APP_UPDATER_DOWNLOAD: 'app-updater:download', APP_UPDATER_INSTALL: 'app-updater:install', diff --git a/packages/bridge-contract/src/unsplash.ts b/packages/bridge-contract/src/unsplash.ts new file mode 100644 index 000000000..62e1f8fbc --- /dev/null +++ b/packages/bridge-contract/src/unsplash.ts @@ -0,0 +1,40 @@ +/** + * Unsplash photo search for the cover picker. The app never holds an + * Unsplash key: every host asks the proxy on zennotes.org, which signs the + * request, trims the answer to these fields and keeps the shared hourly + * budget (see shared-domain unsplash.ts for the rules this follows). + */ + +/** One photo, as the picker needs it. */ +export interface UnsplashPhoto { + id: string + width: number + height: number + /** The photo's average colour, drawn while its thumbnail loads. */ + color: string | null + alt: string | null + /** `urls.raw`: every size is built from it, and it carries the `ixid` + * Unsplash counts views by. */ + rawUrl: string + /** `urls.small`, about 400 px wide, for the picker's tiles. */ + thumbUrl: string + photographer: { name: string; profileUrl: string } + /** `links.download_location`, pinged when the photo becomes a cover. */ + downloadLocation: string +} + +export interface UnsplashSearchPage { + query: string + page: number + totalPages: number + photos: UnsplashPhoto[] +} + +/** Why a search came back empty-handed: no connection, the shared hourly + * budget is spent (until `retryAt`, epoch ms, when the proxy says), or the + * service could not answer. */ +export type UnsplashSearchFailure = 'offline' | 'rate-limited' | 'unavailable' + +export type UnsplashSearchResult = + | { ok: true; page: UnsplashSearchPage } + | { ok: false; reason: UnsplashSearchFailure; retryAt: number | null } diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index d8d4e7760..53d53bb53 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -101,6 +101,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderToggleFavorite", group: "vim", defaultBinding: "s", title: "Leader note action: toggle favorite" }, { id: "vim.leaderTableToDatabase", group: "vim", defaultBinding: "d", title: "Leader note action: convert table to database" }, { id: "vim.leaderNoteCover", group: "vim", defaultBinding: "c", title: "Leader note action: cover image" }, + { id: "vim.leaderNoteCoverUnsplash", group: "vim", defaultBinding: "u", title: "Leader note action: cover from Unsplash" }, { id: "vim.leaderQuickCapture", group: "vim", defaultBinding: "q", title: "Leader: open quick capture" }, { id: "vim.leaderHideToTray", group: "vim", defaultBinding: "b", title: "Leader: hide window to tray" }, { id: "vim.leaderTemplatePicker", group: "vim", defaultBinding: "t", title: "Leader: new from template" }, diff --git a/packages/shared-domain/src/note-cover.test.ts b/packages/shared-domain/src/note-cover.test.ts index dfe69df7e..cd7c603c3 100644 --- a/packages/shared-domain/src/note-cover.test.ts +++ b/packages/shared-domain/src/note-cover.test.ts @@ -122,6 +122,42 @@ describe('editing the cover', () => { }) }) +describe('the credit under a cover', () => { + const unsplash = 'https://images.unsplash.com/photo-1?ixid=abc&w=2400&q=80&auto=format' + const credit = { name: 'Jane Doe', url: 'https://unsplash.com/@janedoe' } + + it('writes the name and link on the lines under the image', () => { + const body = '---\ntitle: Trip\nbanner: "[[old.png]]"\ntags: [a]\n---\nBody\n' + expect(setNoteCover(body, { kind: 'url', url: unsplash }, credit)).toBe( + '---\ntitle: Trip\nbanner: "' + + unsplash + + '"\nbanner_credit: Jane Doe\nbanner_credit_url: "https://unsplash.com/@janedoe"\ntags: [a]\n---\nBody\n' + ) + }) + + it('reads it back, and leaves out a link that is not a web page', () => { + const body = setNoteCover('# Trip\n', { kind: 'url', url: unsplash }, credit) + expect(readNoteCover(body)).toEqual({ key: 'banner', target: { kind: 'url', url: unsplash }, y: 0.5, credit }) + expect(readNoteCover('---\nbanner: a.png\nbanner_credit: "Me: myself"\nbanner_credit_url: javascript:x\n---\n')?.credit).toEqual({ + name: 'Me: myself', + url: null + }) + expect(readNoteCover('---\nbanner: a.png\nbanner_credit: ""\n---\n')?.credit).toBeUndefined() + }) + + it('goes with the picture: a new image or a removed cover takes it away', () => { + const body = setNoteCoverPosition(setNoteCover('# Trip\n', { kind: 'url', url: unsplash }, credit), 0.3) + expect(setNoteCover(body, { kind: 'vault', href: 'assets/a.png' })).toBe('---\nbanner: "![[assets/a.png]]"\n---\n# Trip\n') + expect(removeNoteCover(body)).toBe('# Trip\n') + }) + + it('follows the key the note uses', () => { + const body = setNoteCover('---\ncover: "[[c.png]]"\n---\n', { kind: 'url', url: unsplash }, { name: 'A', url: null }) + expect(body).toBe('---\ncover: "' + unsplash + '"\ncover_credit: A\n---\n') + expect(readNoteCover(body)?.credit).toEqual({ name: 'A', url: null }) + }) +}) + describe('a cover follows its file through an asset rename', () => { it('is rewritten by the same rewriter that updates embeds', () => { const body = '---\nbanner: "![[assets/old.png]]"\nbanner_y: 0.3\n---\n# Trip\n' diff --git a/packages/shared-domain/src/note-cover.ts b/packages/shared-domain/src/note-cover.ts index 8299179f8..be440decb 100644 --- a/packages/shared-domain/src/note-cover.ts +++ b/packages/shared-domain/src/note-cover.ts @@ -23,7 +23,19 @@ // notes the asset-rename rewriter (asset-link-rename.ts and its Go port) // rewrites, so a cover follows its file through a rename or a move. A plain // `[[...]]` link would get none of that. Reading accepts both. -import { FRONTMATTER_BLOCK_RE, unquote, updateFrontmatterFields } from './frontmatter' +// +// A cover may also say who made the picture, on the two lines under it: +// +// banner: "https://images.unsplash.com/photo-...?ixid=...&w=2400" +// banner_credit: Jane Doe +// banner_credit_url: "https://unsplash.com/@janedoe" +// +// An Unsplash cover always carries them: Unsplash's API terms require the +// photographer's name and a link to their profile wherever the photo shows, +// and the picture itself has to stay on images.unsplash.com (unsplash.ts). +// The credit belongs to the picture, so a new image or a removed cover takes +// it away with the focal point. +import { FRONTMATTER_BLOCK_RE, unquote, updateFrontmatterFields, yamlValue } from './frontmatter' export type NoteCoverKey = 'banner' | 'cover' @@ -35,12 +47,20 @@ export const NOTE_COVER_DEFAULT_Y = 0.5 export type NoteCoverTarget = { kind: 'vault'; href: string } | { kind: 'url'; url: string } +/** Who made the picture, and where the credit links (an http(s) page). */ +export interface NoteCoverCredit { + name: string + url: string | null +} + export interface NoteCover { /** The frontmatter key holding the image. */ key: NoteCoverKey target: NoteCoverTarget /** Vertical focal point, 0 (top) to 1 (bottom). */ y: number + /** Present only when the note names one. */ + credit?: NoteCoverCredit } /** Top-level `key: value` lines of the leading frontmatter block, keys @@ -115,6 +135,17 @@ export function clampNoteCoverY(y: number): number { return Math.min(1, Math.max(0, y)) } +/** The credit lines under a cover, when there is a name. A link that is not + * an http(s) page is dropped: it becomes a clickable link on the cover. */ +function readNoteCoverCredit(fields: Map, key: NoteCoverKey): NoteCoverCredit | null { + const raw = fields.get(`${key}_credit`) + const name = raw == null ? '' : scalarText(raw) + if (!name) return null + const rawUrl = fields.get(`${key}_credit_url`) + const url = rawUrl == null ? '' : scalarText(rawUrl) + return { name, url: /^https?:\/\/\S+$/i.test(url) ? url : null } +} + /** The note's cover, or null when its frontmatter names none. */ export function readNoteCover(body: string): NoteCover | null { const fields = topLevelFields(body) @@ -123,7 +154,10 @@ export function readNoteCover(body: string): NoteCover | null { if (raw == null) continue const target = parseNoteCoverTarget(scalarText(raw)) if (!target) continue - return { key, target, y: parseNoteCoverY(fields.get(`${key}_y`) ?? fields.get(`${key}-y`)) } + const cover: NoteCover = { key, target, y: parseNoteCoverY(fields.get(`${key}_y`) ?? fields.get(`${key}-y`)) } + const credit = readNoteCoverCredit(fields, key) + if (credit) cover.credit = credit + return cover } return null } @@ -152,18 +186,37 @@ function dropEmptyFrontmatter(body: string): string { return body.slice(m[0].length) } +/** Put the credit lines right under the cover's own line, where a reader of + * the file looks for them; updateFrontmatterFields would append them at the + * end of the block. */ +function insertCreditLines(body: string, key: NoteCoverKey, credit: NoteCoverCredit): string { + const m = FRONTMATTER_BLOCK_RE.exec(body) + if (!m) return body + const lines = m[1].split('\n') + const at = lines.findIndex((line) => new RegExp(`^${key}\\s*:`, 'i').test(line)) + if (at < 0) return body + const added = [`${key}_credit: ${yamlValue(credit.name)}`] + if (credit.url) added.push(`${key}_credit_url: ${yamlValue(credit.url)}`) + lines.splice(at + 1, 0, ...added) + return `---\n${lines.join('\n')}\n---\n` + body.slice(m[0].length) +} + /** * Set the note's cover image, keeping every other frontmatter line as it was. - * A new image starts at the default focal point: the old one's position was - * chosen for a different picture. + * A new image starts at the default focal point and with only the credit it + * is given: the old position and credit were for a different picture. */ -export function setNoteCover(body: string, target: NoteCoverTarget): string { +export function setNoteCover(body: string, target: NoteCoverTarget, credit?: NoteCoverCredit | null): string { const key = coverKeyFor(body) - return updateFrontmatterFields(body, { + const next = updateFrontmatterFields(body, { [key]: noteCoverValue(target), [`${key}_y`]: null, - [`${key}-y`]: null + [`${key}-y`]: null, + [`${key}_credit`]: null, + [`${key}_credit_url`]: null }) + const name = credit?.name.trim() + return name ? insertCreditLines(next, key, { name, url: credit?.url ?? null }) : next } /** Move the focal point of the note's existing cover; no cover, no change. */ @@ -176,7 +229,7 @@ export function setNoteCoverPosition(body: string, y: number): string { }) } -/** Remove the cover and its position. */ +/** Remove the cover, its position and its credit. */ export function removeNoteCover(body: string): string { const cover = readNoteCover(body) if (!cover) return body @@ -184,7 +237,9 @@ export function removeNoteCover(body: string): string { updateFrontmatterFields(body, { [cover.key]: null, [`${cover.key}_y`]: null, - [`${cover.key}-y`]: null + [`${cover.key}-y`]: null, + [`${cover.key}_credit`]: null, + [`${cover.key}_credit_url`]: null }) ) } diff --git a/packages/shared-domain/src/unsplash.test.ts b/packages/shared-domain/src/unsplash.test.ts new file mode 100644 index 000000000..03803309c --- /dev/null +++ b/packages/shared-domain/src/unsplash.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from 'vitest' +import { + isUnsplashDownloadLocation, + isUnsplashImageUrl, + noteCoverCreditLine, + normalizeUnsplashQuery, + parseUnsplashSearchResponse, + searchUnsplashThroughProxy, + trackUnsplashDownloadThroughProxy, + unsplashCoverUrl, + unsplashReferralUrl, + unsplashRetryAt, + type UnsplashFetch, + type UnsplashFetchResponse +} from './unsplash' + +const RAW = 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?ixid=M3w1fDB8MXxzZWFyY2h8MXx8Zm9n&ixlib=rb-4.0.3' + +function result(overrides: Record = {}): Record { + return { + id: 'Bkci_8qcdvQ', + width: 6000, + height: 4000, + color: '#c0d9d9', + alt: 'fog over a valley', + urls: { raw: RAW, small: `${RAW}&w=400` }, + user: { name: 'Jane Doe', profile: 'https://unsplash.com/@janedoe' }, + download_location: 'https://api.unsplash.com/photos/Bkci_8qcdvQ/download?ixid=M3w1fDB8', + ...overrides + } +} + +function response(status: number, json: unknown, headers: Record = {}): UnsplashFetchResponse { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name) => headers[name.toLowerCase()] ?? null }, + json: async () => json + } +} + +describe('Unsplash URLs', () => { + it('builds a cover-sized image from the raw URL, keeping its own parameters as they were', () => { + expect(unsplashCoverUrl(RAW)).toBe(`${RAW}&w=2400&q=80&auto=format`) + expect(unsplashCoverUrl('https://images.unsplash.com/photo-1')).toBe( + 'https://images.unsplash.com/photo-1?w=2400&q=80&auto=format' + ) + }) + + it('takes images only from images.unsplash.com over https', () => { + expect(isUnsplashImageUrl(RAW)).toBe(true) + expect(isUnsplashImageUrl('http://images.unsplash.com/photo-1')).toBe(false) + expect(isUnsplashImageUrl('https://images.unsplash.com.evil.test/photo-1')).toBe(false) + expect(isUnsplashImageUrl('https://plus.unsplash.com/premium_photo-1')).toBe(false) + expect(unsplashCoverUrl('https://example.com/a.jpg')).toBeNull() + }) + + it('accepts a ping only to an api.unsplash.com download endpoint', () => { + expect(isUnsplashDownloadLocation('https://api.unsplash.com/photos/abc_-1/download?ixid=x')).toBe(true) + expect(isUnsplashDownloadLocation('https://api.unsplash.com/photos/abc/download/../../me')).toBe(false) + expect(isUnsplashDownloadLocation('https://api.unsplash.com/me')).toBe(false) + expect(isUnsplashDownloadLocation('https://api.unsplash.com:8443/photos/abc/download')).toBe(false) + expect(isUnsplashDownloadLocation('https://user@api.unsplash.com/photos/abc/download')).toBe(false) + expect(isUnsplashDownloadLocation('https://example.com/photos/abc/download')).toBe(false) + }) + + it('adds the referral parameters to unsplash.com links only', () => { + expect(unsplashReferralUrl('https://unsplash.com/@janedoe')).toBe( + 'https://unsplash.com/@janedoe?utm_source=zennotes&utm_medium=referral' + ) + expect(unsplashReferralUrl('https://unsplash.com/')).toBe( + 'https://unsplash.com/?utm_source=zennotes&utm_medium=referral' + ) + expect(unsplashReferralUrl('https://example.com/me')).toBe('https://example.com/me') + }) + + it('sends search text trimmed, single-spaced and capped', () => { + expect(normalizeUnsplashQuery(' misty forest \n')).toBe('misty forest') + expect(normalizeUnsplashQuery('x'.repeat(300))).toHaveLength(100) + }) +}) + +describe('parseUnsplashSearchResponse', () => { + it('reads the proxy answer into photos', () => { + const page = parseUnsplashSearchResponse({ total_pages: 7, results: [result()] }, 'fog', 1) + expect(page).toEqual({ + query: 'fog', + page: 1, + totalPages: 7, + photos: [ + { + id: 'Bkci_8qcdvQ', + width: 6000, + height: 4000, + color: '#c0d9d9', + alt: 'fog over a valley', + rawUrl: RAW, + thumbUrl: `${RAW}&w=400`, + photographer: { name: 'Jane Doe', profileUrl: 'https://unsplash.com/@janedoe' }, + downloadLocation: 'https://api.unsplash.com/photos/Bkci_8qcdvQ/download?ixid=M3w1fDB8' + } + ] + }) + }) + + it('leaves out a photo with an image, link or ping it should not follow', () => { + const bad = [ + result({ urls: { raw: 'https://example.com/a.jpg', small: RAW } }), + result({ urls: { raw: RAW, small: 'javascript:alert(1)' } }), + result({ user: { name: 'X', profile: 'https://example.com/@x' } }), + result({ user: { name: ' ', profile: 'https://unsplash.com/@x' } }), + result({ download_location: 'https://example.com/photos/a/download' }), + result({ id: '../../me' }), + null, + 'photo' + ] + expect(parseUnsplashSearchResponse({ total_pages: 1, results: [...bad, result()] }, 'fog', 1)?.photos).toHaveLength(1) + }) + + it('drops a colour or caption it cannot use, and is null for an answer that is not one', () => { + const photo = parseUnsplashSearchResponse({ results: [result({ color: 'red;', alt: 42 })] }, 'q', 1)?.photos[0] + expect(photo?.color).toBeNull() + expect(photo?.alt).toBeNull() + expect(parseUnsplashSearchResponse({ results: 'none' }, 'q', 1)).toBeNull() + expect(parseUnsplashSearchResponse(null, 'q', 1)).toBeNull() + }) +}) + +describe('searchUnsplashThroughProxy', () => { + it('posts the query, with no cookies, and returns the page', async () => { + const fetchFn = vi.fn(async () => response(200, { total_pages: 2, results: [result()] })) + const out = await searchUnsplashThroughProxy(fetchFn, { query: ' fog ', page: 2 }, { baseUrl: 'http://127.0.0.1:9/u' }) + expect(out.ok && out.page.photos).toHaveLength(1) + const [url, init] = fetchFn.mock.calls[0]! + expect(url).toBe('http://127.0.0.1:9/u/search') + expect(init.method).toBe('POST') + expect(init.credentials).toBe('omit') + expect(JSON.parse(String(init.body))).toEqual({ query: 'fog', page: 2 }) + }) + + it('asks nothing for a query too short to search', async () => { + const fetchFn = vi.fn() + const out = await searchUnsplashThroughProxy(fetchFn, { query: 'a' }) + expect(out).toEqual({ ok: true, page: { query: 'a', page: 1, totalPages: 0, photos: [] } }) + expect(fetchFn).not.toHaveBeenCalled() + }) + + it('says why when it fails: unreachable, over the hourly budget, or anything else', async () => { + const throwing = vi.fn(async () => { + throw new TypeError('fetch failed') + }) + expect(await searchUnsplashThroughProxy(throwing, { query: 'fog' })).toEqual({ + ok: false, + reason: 'offline', + retryAt: null + }) + const limited = vi.fn(async () => response(429, {}, { 'retry-after': '120' })) + expect(await searchUnsplashThroughProxy(limited, { query: 'fog' }, { now: () => 1_000 })).toEqual({ + ok: false, + reason: 'rate-limited', + retryAt: 121_000 + }) + const down = vi.fn(async () => response(503, { error: 'unavailable' })) + expect(await searchUnsplashThroughProxy(down, { query: 'fog' })).toMatchObject({ ok: false, reason: 'unavailable' }) + const garbage = vi.fn(async () => response(200, '')) + expect(await searchUnsplashThroughProxy(garbage, { query: 'fog' })).toMatchObject({ ok: false, reason: 'unavailable' }) + }) + + it('reads Retry-After as seconds or a date', () => { + expect(unsplashRetryAt('60', 0)).toBe(60_000) + expect(unsplashRetryAt('Wed, 28 Oct 2026 15:00:00 GMT', 0)).toBe(Date.UTC(2026, 9, 28, 15)) + expect(unsplashRetryAt('soon', 0)).toBeNull() + expect(unsplashRetryAt(null, 0)).toBeNull() + }) +}) + +describe('trackUnsplashDownloadThroughProxy', () => { + it('posts a valid download location and swallows a failure', async () => { + const fetchFn = vi.fn(async () => { + throw new Error('offline') + }) + const location = 'https://api.unsplash.com/photos/abc/download?ixid=x' + await expect(trackUnsplashDownloadThroughProxy(fetchFn, location, { baseUrl: 'https://p.test' })).resolves.toBeUndefined() + expect(fetchFn.mock.calls[0]![0]).toBe('https://p.test/downloads') + expect(JSON.parse(String(fetchFn.mock.calls[0]![1].body))).toEqual({ download_location: location }) + }) + + it('sends nothing for any other URL', async () => { + const fetchFn = vi.fn() + await trackUnsplashDownloadThroughProxy(fetchFn, 'https://example.com/photos/abc/download') + expect(fetchFn).not.toHaveBeenCalled() + }) +}) + +describe('noteCoverCreditLine', () => { + it('names Unsplash, with referral links, for an Unsplash photo', () => { + const line = noteCoverCreditLine({ + key: 'banner', + target: { kind: 'url', url: unsplashCoverUrl(RAW)! }, + y: 0.5, + credit: { name: 'Jane Doe', url: 'https://unsplash.com/@janedoe' } + }) + expect(line).toEqual({ + name: 'Jane Doe', + nameUrl: 'https://unsplash.com/@janedoe?utm_source=zennotes&utm_medium=referral', + source: { name: 'Unsplash', url: 'https://unsplash.com/?utm_source=zennotes&utm_medium=referral' } + }) + }) + + it('draws any other credit as written, and nothing without one', () => { + expect( + noteCoverCreditLine({ key: 'banner', target: { kind: 'vault', href: 'a.png' }, y: 0.5, credit: { name: 'Me', url: null } }) + ).toEqual({ name: 'Me', nameUrl: null, source: null }) + expect(noteCoverCreditLine({ key: 'banner', target: { kind: 'vault', href: 'a.png' }, y: 0.5 })).toBeNull() + }) +}) diff --git a/packages/shared-domain/src/unsplash.ts b/packages/shared-domain/src/unsplash.ts new file mode 100644 index 000000000..3b267abe6 --- /dev/null +++ b/packages/shared-domain/src/unsplash.ts @@ -0,0 +1,261 @@ +/** + * Unsplash as a source of note covers, and the rules that come with it (the + * Unsplash API Terms and Guidelines, as read on 2026-10-28): + * + * - Hotlinking. The photo stays on images.unsplash.com: the terms say "you + * must directly use or embed the related image URLs returned by the API", + * and their own example is a picked background. So an Unsplash cover is an + * https cover, never a copy in assets/ the way the built-in gallery is. + * Sizes are `urls.raw` plus imgix parameters, keeping its `ixid`. + * - Download tracking. Making a photo a cover pings its `download_location`. + * - Attribution. "Photo by on Unsplash", the name linking to the + * photographer's profile and Unsplash to unsplash.com, every link with + * utm_source and utm_medium=referral. The note keeps the name and the + * profile (note-cover.ts); the referral parameters are added when drawn. + * - The key stays secret, so search and the ping go through the proxy on + * zennotes.org, which also keeps one hourly budget for every ZenNotes user. + * + * Whatever the proxy hands back is checked here before the app shows it: + * images only from images.unsplash.com, links only to unsplash.com, a ping + * only to an api.unsplash.com download endpoint. + */ +import type { + UnsplashPhoto, + UnsplashSearchPage, + UnsplashSearchResult +} from '@zennotes/bridge-contract/unsplash' +import type { NoteCover, NoteCoverCredit } from './note-cover' + +export const UNSPLASH_PROXY_BASE_URL = 'https://zennotes.org/api/v1/unsplash' +export const UNSPLASH_HOME_URL = 'https://unsplash.com/' +/** The app name Unsplash's referral links carry. */ +export const UNSPLASH_UTM_SOURCE = 'zennotes' +export const UNSPLASH_MIN_QUERY_LENGTH = 2 +export const UNSPLASH_MAX_QUERY_LENGTH = 100 +export const UNSPLASH_MAX_PAGE = 50 + +/** A cover spans the whole pane: 2400 px fills a wide window at 2x without + * pulling the original, which is often 6000 px and more. */ +const COVER_IMAGE_PARAMS = 'w=2400&q=80&auto=format' + +function httpsUrl(text: unknown): URL | null { + if (typeof text !== 'string') return null + try { + const url = new URL(text) + return url.protocol === 'https:' && !url.username && !url.password ? url : null + } catch { + return null + } +} + +export function isUnsplashImageUrl(url: unknown): boolean { + return httpsUrl(url)?.hostname === 'images.unsplash.com' +} + +function isUnsplashPageUrl(url: unknown): boolean { + return httpsUrl(url)?.hostname === 'unsplash.com' +} + +/** An api.unsplash.com download endpoint, the only place the ping may go. */ +export function isUnsplashDownloadLocation(url: unknown): boolean { + const parsed = httpsUrl(url) + return ( + !!parsed && + parsed.hostname === 'api.unsplash.com' && + !parsed.port && + /^\/photos\/[A-Za-z0-9_-]{1,64}\/download$/.test(parsed.pathname) + ) +} + +/** The cover's image: the raw URL with a cover-sized width. The parameters + * are appended as text so the raw URL's own (`ixid` among them) stay byte + * for byte what Unsplash gave. */ +export function unsplashCoverUrl(rawUrl: string): string | null { + if (!isUnsplashImageUrl(rawUrl)) return null + const base = rawUrl.replace(/#.*$/, '') + return `${base}${base.includes('?') ? '&' : '?'}${COVER_IMAGE_PARAMS}` +} + +/** An unsplash.com link with the referral parameters Unsplash asks for. Any + * other link comes back as it was. */ +export function unsplashReferralUrl(url: string): string { + const parsed = httpsUrl(url) + if (!parsed || parsed.hostname !== 'unsplash.com') return url + parsed.searchParams.set('utm_source', UNSPLASH_UTM_SOURCE) + parsed.searchParams.set('utm_medium', 'referral') + return parsed.toString() +} + +/** Search text as it is sent: trimmed, one space between words, capped. */ +export function normalizeUnsplashQuery(query: string): string { + return query.trim().replace(/\s+/g, ' ').slice(0, UNSPLASH_MAX_QUERY_LENGTH) +} + +export function isUnsplashSearchableQuery(query: string): boolean { + return normalizeUnsplashQuery(query).length >= UNSPLASH_MIN_QUERY_LENGTH +} + +function text(value: unknown, max: number): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed.slice(0, max) : null +} + +function size(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : 0 +} + +function photoFrom(entry: unknown): UnsplashPhoto | null { + if (!entry || typeof entry !== 'object') return null + const e = entry as Record + const urls = (e.urls ?? {}) as Record + const user = (e.user ?? {}) as Record + const id = typeof e.id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(e.id) ? e.id : null + const name = text(user.name, 120) + if (!id || !name) return null + if (!isUnsplashImageUrl(urls.raw) || !isUnsplashImageUrl(urls.small)) return null + if (!isUnsplashPageUrl(user.profile) || !isUnsplashDownloadLocation(e.download_location)) return null + return { + id, + width: size(e.width), + height: size(e.height), + color: typeof e.color === 'string' && /^#[0-9a-f]{6}$/i.test(e.color) ? e.color : null, + alt: text(e.alt, 300), + rawUrl: urls.raw as string, + thumbUrl: urls.small as string, + photographer: { name, profileUrl: user.profile as string }, + downloadLocation: e.download_location as string + } +} + +/** The proxy's answer, `{ total_pages, results: [...] }`, as a page of + * photos; a malformed photo is left out, a malformed answer is null. */ +export function parseUnsplashSearchResponse( + json: unknown, + query: string, + page: number +): UnsplashSearchPage | null { + if (!json || typeof json !== 'object') return null + const body = json as Record + if (!Array.isArray(body.results)) return null + const photos = body.results.map(photoFrom).filter((p): p is UnsplashPhoto => p !== null) + const totalPages = Math.min(UNSPLASH_MAX_PAGE, size(body.total_pages)) + return { query, page, totalPages, photos } +} + +/** What a host's fetch returns: Electron's net.fetch and the browser's fetch + * both fit. */ +export interface UnsplashFetchResponse { + ok: boolean + status: number + headers: { get(name: string): string | null } + json(): Promise +} +export type UnsplashFetch = (url: string, init: RequestInit) => Promise + +export interface UnsplashProxyOptions { + baseUrl?: string + /** Extra request headers (the desktop sends a bare user agent). */ + headers?: Record + now?: () => number +} + +function proxyRequest(body: unknown, headers: Record | undefined): RequestInit { + return { + method: 'POST', + // No cookies: the app's session may hold zennotes.org ones from a Cloud + // page, and a search has nothing to do with the account. + credentials: 'omit', + cache: 'no-store', + headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000) + } +} + +/** When to try again after a 429: Retry-After in seconds, or an HTTP date. */ +export function unsplashRetryAt(retryAfter: string | null, now: number): number | null { + if (!retryAfter) return null + const seconds = Number(retryAfter) + if (Number.isFinite(seconds) && seconds >= 0) return now + seconds * 1000 + const date = Date.parse(retryAfter) + return Number.isFinite(date) ? date : null +} + +/** One page of results through the proxy. Never throws: a failure comes back + * as the reason the picker shows. */ +export async function searchUnsplashThroughProxy( + fetchFn: UnsplashFetch, + input: { query: string; page?: number }, + options: UnsplashProxyOptions = {} +): Promise { + const query = normalizeUnsplashQuery(input.query) + const page = Math.min(UNSPLASH_MAX_PAGE, Math.max(1, Math.floor(input.page ?? 1))) + const now = options.now ?? Date.now + if (query.length < UNSPLASH_MIN_QUERY_LENGTH) { + return { ok: true, page: { query, page, totalPages: 0, photos: [] } } + } + let response: UnsplashFetchResponse + try { + response = await fetchFn( + `${options.baseUrl ?? UNSPLASH_PROXY_BASE_URL}/search`, + proxyRequest({ query, page }, options.headers) + ) + } catch { + return { ok: false, reason: 'offline', retryAt: null } + } + if (response.status === 429) { + return { ok: false, reason: 'rate-limited', retryAt: unsplashRetryAt(response.headers.get('retry-after'), now()) } + } + if (!response.ok) return { ok: false, reason: 'unavailable', retryAt: null } + let parsed: UnsplashSearchPage | null = null + try { + parsed = parseUnsplashSearchResponse(await response.json(), query, page) + } catch { + parsed = null + } + return parsed ? { ok: true, page: parsed } : { ok: false, reason: 'unavailable', retryAt: null } +} + +/** Tell Unsplash a photo became a cover. Fire and forget: a ping that does + * not arrive must never undo or delay the pick. */ +export async function trackUnsplashDownloadThroughProxy( + fetchFn: UnsplashFetch, + downloadLocation: string, + options: UnsplashProxyOptions = {} +): Promise { + if (!isUnsplashDownloadLocation(downloadLocation)) return + try { + await fetchFn( + `${options.baseUrl ?? UNSPLASH_PROXY_BASE_URL}/downloads`, + proxyRequest({ download_location: downloadLocation }, options.headers) + ) + } catch { + // Dropped, as above. + } +} + +/** The credit a picked photo leaves in the note. */ +export function unsplashPhotoCredit(photo: UnsplashPhoto): NoteCoverCredit { + return { name: photo.photographer.name, url: photo.photographer.profileUrl } +} + +/** The credit line drawn on a cover: "Photo by on Unsplash" for a + * photo on images.unsplash.com, the name as written otherwise. */ +export interface NoteCoverCreditLine { + name: string + nameUrl: string | null + /** "Unsplash" and its link, for an Unsplash photo. */ + source: { name: string; url: string } | null +} + +export function noteCoverCreditLine(cover: NoteCover): NoteCoverCreditLine | null { + if (!cover.credit) return null + const unsplash = cover.target.kind === 'url' && isUnsplashImageUrl(cover.target.url) + const url = cover.credit.url + return { + name: cover.credit.name, + nameUrl: url ? (unsplash ? unsplashReferralUrl(url) : url) : null, + source: unsplash ? { name: 'Unsplash', url: unsplashReferralUrl(UNSPLASH_HOME_URL) } : null + } +} From d10fdd761f9fc7fe1a6a5a852ffa5f12cc991598 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 20:25:21 -0500 Subject: [PATCH 03/34] Fix(editor): a note opened to read shows its first line rendered, not as raw Markdown Found on the phones once their read-open guard shipped (iPhone 1.34.0, Android 1.1.49): a note opened from Home, a list or a zennotes:// link now opens with the keyboard down, as it should, and with its title drawn as a raw `# Garden plan`. Live preview hides the Markdown markers on every line except the ones the selection covers, and a note opened to be read still has a selection: the caret parked on line 1. Nobody was editing that line, but it read as source. Desktop showed the same wherever an editor sits unfocused: a hovered link's preview popover, the pinned reference pane, and the note a window restores at launch. Source is now revealed only on lines the user is editing. A state field (lib/cm-source-reveal.ts) says whether the editor has taken the focus since its note opened. Until it has, every line renders, the caret's too. The first focus turns revealing on, and a blur leaves it on, so a palette, a menu or a dialog over a note being edited redraws nothing (and a phone's keyboard going down is not the end of editing either). A note put into an editor in place of another starts over, unless the editor holds the focus as it arrives: a tab switch or a followed link from the keyboard keeps revealing. Every reveal asks the same question: the line markers, links, ==highlights==, template tokens, callout heads, rules, fences and bullets, and the math, Mermaid/D2 and embed blocks. The editors that share these extensions (the main panes, the reference pane, the link popover, floating and external-file windows) all carry the field; one without it (Quick Capture, the template editor) reveals as before. The focus is taken up in a microtask of the focus event, not on CodeMirror's own focus notice, which comes 10 ms later: by then a frame with the caret's line rendered would already be painted, and every note desktop opens would flicker its first line. A focus a phone shell turns straight back (the read-open guard blurs it in focusin) never reveals. What it deliberately does not change: when anything takes the focus, and the parser (the note grammar's full reparse is untouched). Desktop opens a note and focuses it exactly as before, so the caret's line shows its markers from the first painted frame. Desktop, visible only where an editor is unfocused: the link preview popover and the reference pane draw their first line rendered, a note a split's other pane switched to while unfocused renders its caret line, and a note restored at launch renders its caret line until the editor is clicked or focused. Tests: cm-source-reveal (an unfocused note renders its caret line; an editor without the field reveals; the first focus reveals before any timer runs; a blur keeps revealing; a refused focus never reveals; a note swapped in unfocused is read, swapped in focused reveals; a note left to read renders again; highlights and inline math follow it). In the built app over CDP with isolated stores, sampled once per painted frame on the 2.78.0 build and on this one: opening from Home, the sidebar, Search and Search Text in Vault, Vim gg/j/k and an A edit that reaches disk, a palette over a focused editor, Read to Edit, Split and the find panel paint the same caret-line states on both; the link popover and the launch restore show the change. On the iPhone shell (web build at phone size, rehearsal core from this tree): a note opened from Home, and the one restored at launch, show their heading rendered with no `#`, and a tap into the text focuses it and reveals that line. --- packages/app-core/README.md | 5 + .../app-core/src/components/EditorPane.tsx | 7 + .../src/components/ExternalFileApp.tsx | 2 + .../src/components/FloatingNoteApp.tsx | 2 + .../app-core/src/components/LinkPopover.tsx | 2 + .../src/components/PinnedReferencePane.tsx | 5 +- packages/app-core/src/lib/cm-embed-render.ts | 10 +- packages/app-core/src/lib/cm-highlight.ts | 4 +- packages/app-core/src/lib/cm-live-preview.ts | 12 +- .../src/lib/cm-live-template-tokens.ts | 4 +- packages/app-core/src/lib/cm-math-render.ts | 4 +- .../app-core/src/lib/cm-mermaid-render.ts | 4 +- .../app-core/src/lib/cm-source-reveal.test.ts | 186 ++++++++++++++++++ packages/app-core/src/lib/cm-source-reveal.ts | 105 ++++++++++ .../app-core/src/lib/cm-wikilink-render.ts | 4 +- .../app-core/src/lib/cm-wysiwyg-blocks.ts | 4 +- 16 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 packages/app-core/src/lib/cm-source-reveal.test.ts create mode 100644 packages/app-core/src/lib/cm-source-reveal.ts diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 7d471b59a..21df1bb8f 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -340,6 +340,11 @@ and writing suggestions through the editor's content attributes. The native keyboard decides which features to provide. It does not install keyboard plugins or change the host's spelling capabilities. +Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) +only on lines the user is editing: once an editor has taken the focus since its +note opened. A note opened to be read renders every line, the caret's too, with +no host configuration. + The measurement callback receives frozen copies of editor and scroll-viewport bounds (`top`, `bottom`, `left`, `right`, `width`, `height`) in CSS pixels. It receives no DOM element or CodeMirror object. Read host geometry there; do not mutate layout diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 216f1ac8e..18827ebfc 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -114,6 +114,7 @@ import type { LineNumberMode } from '../store' import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension, sourceRevealForNewNote } from '../lib/cm-source-reveal' import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair' import { tablePlugin, tableVimEntry } from '../lib/cm-table' import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' @@ -2101,6 +2102,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { extensions: [ noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), noteEditorHostExtension(), + // Outside the live-preview compartment, so toggling it keeps whether + // the note is being edited or read. + sourceRevealExtension, appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), // No text input outside Vim insert mode, so a CJK input method @@ -2534,6 +2538,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { !!markdownCompartment && !!markdownSyntaxCompartment const effects: StateEffect[] = [] + // Another note is read until the editor takes the focus (on desktop, the + // focus every opener hands over right after), unless it holds it now. + if (switched) effects.push(sourceRevealForNewNote(view)) if (deferRichMarkdown && markdownCompartment && markdownSyntaxCompartment) { richMarkdownDeferredRef.current = true effects.push( diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index 286b8b2e2..0270b2666 100644 --- a/packages/app-core/src/components/ExternalFileApp.tsx +++ b/packages/app-core/src/components/ExternalFileApp.tsx @@ -30,6 +30,7 @@ import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' import type { ExternalFileContent } from '@shared/ipc' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension } from '../lib/cm-source-reveal' import { headingFolding } from '../lib/cm-heading-fold' import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, InboxIcon } from './icons' @@ -167,6 +168,7 @@ export function ExternalFileApp(): JSX.Element { doc: bodyRef.current ?? '', extensions: [ appMarkdownSnippetExtension(), + sourceRevealExtension, new Compartment().of(prefs.vimMode ? vim() : []), // No text input outside Vim insert mode, as in every other editor: // without it a CJK input method composed over hjkl and typed `ia` diff --git a/packages/app-core/src/components/FloatingNoteApp.tsx b/packages/app-core/src/components/FloatingNoteApp.tsx index f9bae9a0a..4982eae8d 100644 --- a/packages/app-core/src/components/FloatingNoteApp.tsx +++ b/packages/app-core/src/components/FloatingNoteApp.tsx @@ -48,6 +48,7 @@ import type { NoteContent, VaultChangeEvent } from '@shared/ipc' import type { VimWrappedLineMotionMode } from '@shared/app-config' import type { LineNumberMode } from '../store' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension } from '../lib/cm-source-reveal' import { headingFolding } from '../lib/cm-heading-fold' import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, PinIcon } from './icons' @@ -346,6 +347,7 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element doc: dirtyBodyRef.current ?? content?.body ?? '', extensions: [ appMarkdownSnippetExtension(), + sourceRevealExtension, new Compartment().of(prefs.vimMode ? vim() : []), vimImeGuard(() => prefs.vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, diff --git a/packages/app-core/src/components/LinkPopover.tsx b/packages/app-core/src/components/LinkPopover.tsx index bbba3c378..cbd1f6e32 100644 --- a/packages/app-core/src/components/LinkPopover.tsx +++ b/packages/app-core/src/components/LinkPopover.tsx @@ -35,6 +35,7 @@ import { customCodeFenceHighlightExtension } from '../lib/cm-custom-code-languag import { markdownLinkExtension } from '../lib/cm-markdown-links' import { listIndentWidth, markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension } from '../lib/cm-source-reveal' import { frontmatterStyle, frontmatterTagExtension } from '../lib/cm-frontmatter' import { slashCommandRender, slashCommandSource } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' @@ -429,6 +430,7 @@ function PopoverNoteEditor({ extensions: [ noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: pathRef.current })), appMarkdownSnippetExtension(), + sourceRevealExtension, s0.vimMode ? vim() : [], vimImeGuard(() => useStore.getState().vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index a8960c296..920ad58ab 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -48,6 +48,7 @@ import { autocompletion } from '@codemirror/autocomplete' import { useStore } from '../store' import type { LineNumberMode } from '../store' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension, sourceRevealForNewNote } from '../lib/cm-source-reveal' import { frontmatterStyle, frontmatterTagExtension } from '../lib/cm-frontmatter' import { headingFolding } from '../lib/cm-heading-fold' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' @@ -245,6 +246,7 @@ export function PinnedReferencePane(): JSX.Element | null { extensions: [ noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), appMarkdownSnippetExtension(), + sourceRevealExtension, vimCompartment.of(s0.vimMode ? vim() : []), // The same input-method guard as the main editor (#84, #464, #921). vimImeGuard( @@ -335,7 +337,8 @@ export function PinnedReferencePane(): JSX.Element | null { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextBody }, annotations: [programmatic.of(true), noteEditingSync.of(true)], - selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead } + selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead }, + effects: pathChanged ? sourceRevealForNewNote(view) : undefined }) viewPathRef.current = nextPath }, [content?.body, content?.path]) diff --git a/packages/app-core/src/lib/cm-embed-render.ts b/packages/app-core/src/lib/cm-embed-render.ts index 19649f552..6b54c5c02 100644 --- a/packages/app-core/src/lib/cm-embed-render.ts +++ b/packages/app-core/src/lib/cm-embed-render.ts @@ -15,6 +15,7 @@ import { syntaxTree } from '@codemirror/language' import { RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { renderBookmarkElement, renderEmbedElement } from './embed-renderers' const FENCE_INFO_RE = /^\s*(?:`{3,}|~{3,})\s*([^\s`]*)/ @@ -87,7 +88,7 @@ class EmbedBlockWidget extends WidgetType { /** Cursor/selection overlaps (or just touches an edge of) `[from, to]`. */ function selectionTouches(state: EditorState, from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (Math.max(range.from, from) <= Math.min(range.to, to)) return true } return false @@ -152,7 +153,12 @@ function buildEmbedDecorations(state: EditorState): EmbedRenderValue { const embedRenderField = StateField.define({ create: (state) => buildEmbedDecorations(state), update(value, tr) { - if (tr.docChanged || tr.selection || syntaxTree(tr.startState) !== syntaxTree(tr.state)) { + if ( + tr.docChanged || + tr.selection || + sourceRevealChanged(tr) || + syntaxTree(tr.startState) !== syntaxTree(tr.state) + ) { return buildEmbedDecorations(tr.state) } return value diff --git a/packages/app-core/src/lib/cm-highlight.ts b/packages/app-core/src/lib/cm-highlight.ts index 50ba12795..d42684fb5 100644 --- a/packages/app-core/src/lib/cm-highlight.ts +++ b/packages/app-core/src/lib/cm-highlight.ts @@ -20,6 +20,7 @@ import { ViewPlugin, type ViewUpdate } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' export type HighlightColorId = | 'yellow' @@ -123,7 +124,7 @@ function colorClass(color: string | undefined): string { /** True when the cursor or a selection overlaps `[from, to]` — reveal the raw * syntax then, like the rest of the live-preview. */ function selectionTouches(state: EditorView['state'], from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true continue @@ -247,6 +248,7 @@ const highlightPlugin = ViewPlugin.fromClass( update.selectionSet || update.viewportChanged || update.focusChanged || + sourceRevealChanged(update) || // A parse cut short by its time budget finishes in a later update // that changes nothing else. syntaxTree(update.startState) !== syntaxTree(update.state) diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index 97dc8fdfb..89fa47bfb 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -26,6 +26,7 @@ import { useCloudSyncStatusStore } from './cloud-auto-sync' import { oversizedCloudFileNotice, oversizedCloudFiles } from './cloud-oversized-files' import { noteEditorPath } from './note-editor-context' import { isResolvedMarkdownLink, terminatedLinkTailEnd } from './cm-markdown-links' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { parseBlockAnchors } from './block-anchors' import { setImageBlockDragPayload } from './image-block-dnd' import { imageCacheKey, rememberImageOnLoad, takeCachedImage } from './image-element-cache' @@ -47,7 +48,8 @@ import { * Obsidian-style WYSIWYG feel. When you move off a line the `#`, `**`, * `[`, `](url)`, backticks, etc. fade away and the heading/bold/link * renders cleanly. When you land on that line again, the markers come - * back so you can edit them. + * back so you can edit them. A note that is being read, not edited, + * renders every line, the selection's too (cm-source-reveal.ts). */ /** Node names from @lezer/markdown that correspond to syntax markers. */ @@ -159,7 +161,7 @@ function selectionTouchesRange( from: number, to: number ): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true continue @@ -1318,9 +1320,10 @@ function computeDecorations(view: EditorView): DecorationSet { const { state } = view // Every line that holds part of a selection range is "active" and - // therefore keeps its syntax markers visible for editing. + // therefore keeps its syntax markers visible for editing, once the note is + // being edited rather than read. const activeLines = new Set() - for (const r of state.selection.ranges) { + for (const r of revealedSelectionRanges(state)) { const fromLine = state.doc.lineAt(r.from).number const toLine = state.doc.lineAt(r.to).number for (let l = fromLine; l <= toLine; l++) activeLines.add(l) @@ -1856,6 +1859,7 @@ export const livePreviewPlugin = ViewPlugin.fromClass( update.selectionSet || update.viewportChanged || update.focusChanged || + sourceRevealChanged(update) || syntaxTree(update.startState) !== syntaxTree(update.state) || externalRefresh ) { diff --git a/packages/app-core/src/lib/cm-live-template-tokens.ts b/packages/app-core/src/lib/cm-live-template-tokens.ts index e0aafcbbe..b605fa368 100644 --- a/packages/app-core/src/lib/cm-live-template-tokens.ts +++ b/packages/app-core/src/lib/cm-live-template-tokens.ts @@ -15,6 +15,7 @@ import { type ViewUpdate, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { useStore } from '../store' import { LIVE_TOKEN_RE, formatLiveToken, liveTokenFromMatch } from './live-template-tokens' @@ -52,7 +53,7 @@ function isInsideCode(state: EditorView['state'], pos: number): boolean { } function selectionTouches(state: EditorView['state'], from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true } else if (Math.max(range.from, from) < Math.min(range.to, to)) { @@ -126,6 +127,7 @@ const liveTemplateTokenPlugin = ViewPlugin.fromClass( refreshed || update.docChanged || update.selectionSet || + sourceRevealChanged(update) || update.viewportChanged || // A parse cut short by its time budget finishes in a later update // that changes nothing else. diff --git a/packages/app-core/src/lib/cm-math-render.ts b/packages/app-core/src/lib/cm-math-render.ts index 0a3d21e68..01dad7506 100644 --- a/packages/app-core/src/lib/cm-math-render.ts +++ b/packages/app-core/src/lib/cm-math-render.ts @@ -18,6 +18,7 @@ import { syntaxTree } from '@codemirror/language' import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import katex from 'katex' import type { MathRenderer } from '@shared/app-config' import { peekTypstMathSvg, renderTypstMathToSvg } from './typst-math-render' @@ -220,7 +221,7 @@ class BlockMathWidget extends WidgetType { /** Cursor/selection overlaps (or just touches an edge of) `[from, to]`. */ function selectionTouches(state: EditorState, from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (Math.max(range.from, from) <= Math.min(range.to, to)) return true } return false @@ -342,6 +343,7 @@ const mathRenderField = StateField.define({ if ( tr.docChanged || tr.selection || + sourceRevealChanged(tr) || syntaxTree(tr.startState) !== syntaxTree(tr.state) || tr.startState.facet(mathRendererFacet) !== tr.state.facet(mathRendererFacet) || tr.startState.facet(typstPreambleFacet) !== tr.state.facet(typstPreambleFacet) diff --git a/packages/app-core/src/lib/cm-mermaid-render.ts b/packages/app-core/src/lib/cm-mermaid-render.ts index 6c049fda3..78a8913ab 100644 --- a/packages/app-core/src/lib/cm-mermaid-render.ts +++ b/packages/app-core/src/lib/cm-mermaid-render.ts @@ -29,6 +29,7 @@ import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language' import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { peekD2Svg, renderD2Svg, withUniqueD2Ids } from './d2-render' import { peekMermaidSvg, renderMermaidSvg } from './mermaid-render' @@ -100,7 +101,7 @@ function paint( /** Cursor/selection overlaps (or just touches an edge of) `[from, to]`. */ function selectionTouches(state: EditorState, from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (Math.max(range.from, from) <= Math.min(range.to, to)) return true } return false @@ -271,6 +272,7 @@ const mermaidRenderField = StateField.define({ if ( tr.docChanged || tr.selection || + sourceRevealChanged(tr) || syntaxTree(tr.startState) !== syntaxTree(tr.state) || tr.startState.facet(diagramThemeFacet).key !== tr.state.facet(diagramThemeFacet).key ) { diff --git a/packages/app-core/src/lib/cm-source-reveal.test.ts b/packages/app-core/src/lib/cm-source-reveal.test.ts new file mode 100644 index 000000000..16cc46c61 --- /dev/null +++ b/packages/app-core/src/lib/cm-source-reveal.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState, type Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { livePreviewPlugin } from './cm-live-preview' +import { highlightExtension } from './cm-highlight' +import { mathRenderExtension } from './cm-math-render' +import { + readSourceRevealEffect, + revealedSelectionRanges, + sourceRevealExtension, + sourceRevealForNewNote +} from './cm-source-reveal' + +vi.mock('../store', () => { + const state = { + activeNote: null, + assetFiles: [], + noteRefs: {}, + pdfEmbedInEditMode: 'compact', + pinnedRefKind: 'note', + pinnedRefPath: null, + vault: null + } + const useStore = Object.assign(() => null, { + getState: () => state, + subscribe: () => () => {} + }) + return { useStore } +}) + +const NOTE = '# Garden plan\n\nThe **tomatoes** go in the sunny bed.\n\n## Herbs\n\nBasil and thyme.' + +const views: EditorView[] = [] +function mount(doc: string, extensions: Extension[] = [sourceRevealExtension]): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor: 0 }, + extensions: [markdown({ base: markdownLanguage }), livePreviewPlugin, ...extensions] + }) + }) + forceParsing(view, doc.length, 1000) + views.push(view) + return view +} + +const firstLine = (view: EditorView): string | null => + view.contentDOM.querySelector('.cm-line')?.textContent ?? null +const lineText = (view: EditorView, n: number): string | null => + view.contentDOM.querySelectorAll('.cm-line')[n - 1]?.textContent ?? null +/** The microtask the focus observer queues, and nothing later. */ +const microtasks = (): Promise => Promise.resolve().then(() => undefined) + +beforeEach(() => { + // jsdom reports no window focus; CodeMirror's hasFocus asks for it. + vi.spyOn(document, 'hasFocus').mockReturnValue(true) +}) +afterEach(() => { + while (views.length) views.pop()!.destroy() + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +describe('source reveal', () => { + it('renders the selection line of a note that has not been focused', () => { + const view = mount(NOTE) + expect(view.state.selection.main.head).toBe(0) + expect(firstLine(view)).toBe('Garden plan') + expect(revealedSelectionRanges(view.state)).toEqual([]) + }) + + it('an editor without the extension reveals on the selection line as it always did', () => { + const view = mount(NOTE, []) + expect(firstLine(view)).toBe('# Garden plan') + expect(revealedSelectionRanges(view.state)).toEqual(view.state.selection.ranges) + }) + + it('the first focus reveals the line before the frame paints, not on the 10 ms focus notice', async () => { + vi.useFakeTimers() + try { + const view = mount(NOTE) + view.focus() + expect(view.hasFocus).toBe(true) + await microtasks() + // No timer has run: this is the focus observer, not CodeMirror's + // delayed focus-change transaction. + expect(firstLine(view)).toBe('# Garden plan') + } finally { + vi.useRealTimers() + } + }) + + it('a blur keeps revealing, so a palette over a note being edited redraws nothing', async () => { + const view = mount(NOTE) + view.focus() + await microtasks() + view.dispatch({ selection: { anchor: NOTE.indexOf('Herbs') } }) + expect(lineText(view, 5)).toBe('## Herbs') + view.contentDOM.blur() + await new Promise((r) => setTimeout(r, 30)) + expect(view.hasFocus).toBe(false) + expect(lineText(view, 5)).toBe('## Herbs') + expect(firstLine(view)).toBe('Garden plan') + }) + + it('a focus turned straight back by the host never reveals', async () => { + const view = mount(NOTE) + // A phone shell's read-open guard undoes a focus that lands while held. + const refuse = (event: FocusEvent): void => { + if (event.target === view.contentDOM) view.contentDOM.blur() + } + document.addEventListener('focusin', refuse, true) + try { + view.focus() + await new Promise((r) => setTimeout(r, 30)) + expect(view.hasFocus).toBe(false) + expect(firstLine(view)).toBe('Garden plan') + } finally { + document.removeEventListener('focusin', refuse, true) + } + }) + + it('another note put in while unfocused is read; put in while focused it reveals', async () => { + const view = mount(NOTE) + view.focus() + await microtasks() + view.contentDOM.blur() + const next = '# Reading list\n\nA **short** list.' + // A list opened the note: the editor is not focused as it arrives. + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + selection: { anchor: 0 }, + effects: sourceRevealForNewNote(view) + }) + forceParsing(view, next.length, 1000) + expect(firstLine(view)).toBe('Reading list') + + // A tab switch from the keyboard: the editor keeps the focus throughout. + view.focus() + await microtasks() + expect(firstLine(view)).toBe('# Reading list') + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: NOTE }, + selection: { anchor: 0 }, + effects: sourceRevealForNewNote(view) + }) + forceParsing(view, NOTE.length, 1000) + expect(firstLine(view)).toBe('# Garden plan') + }) + + it('a note left to be read renders its selection line again until the next focus', async () => { + const view = mount(NOTE) + view.focus() + await microtasks() + expect(firstLine(view)).toBe('# Garden plan') + view.contentDOM.blur() + view.dispatch({ effects: readSourceRevealEffect() }) + expect(firstLine(view)).toBe('Garden plan') + view.focus() + await microtasks() + expect(firstLine(view)).toBe('# Garden plan') + }) + + it('gates the span reveals too: highlights and inline math on the selection line', async () => { + const doc = 'A ==marked== word and $x^2$ here.\n\nNext line.' + const view = mount(doc, [sourceRevealExtension, highlightExtension, mathRenderExtension('katex')]) + view.dispatch({ selection: { anchor: doc.indexOf('marked') + 2 } }) + expect(firstLine(view)).not.toContain('==') + view.dispatch({ selection: { anchor: doc.indexOf('x^2') + 1 } }) + expect(view.contentDOM.querySelector('.cm-line')?.textContent).not.toContain('$x^2$') + + view.focus() + await microtasks() + // Now being edited: the math under the caret reads as source again. + expect(firstLine(view)).toContain('$x^2$') + view.dispatch({ selection: { anchor: doc.indexOf('marked') + 2 } }) + expect(firstLine(view)).toContain('==marked==') + }) +}) diff --git a/packages/app-core/src/lib/cm-source-reveal.ts b/packages/app-core/src/lib/cm-source-reveal.ts new file mode 100644 index 000000000..70c40bab7 --- /dev/null +++ b/packages/app-core/src/lib/cm-source-reveal.ts @@ -0,0 +1,105 @@ +import { + StateEffect, + StateField, + type EditorState, + type Extension, + type SelectionRange +} from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +/** + * Which lines live preview gives back their Markdown source. + * + * Live preview draws a note as it reads and shows the source markers (a + * heading's `#`, `**`, `[[ ]]`, `==`, `$$`, a callout's `[!type]`, a fence) + * where the user is editing, which it always took to mean the lines the + * selection covers. But an editor has a selection whether or not anyone is + * editing in it: a note opened to be read keeps its caret parked on line 1, + * so a phone that opened a note with the keyboard down showed its title as a + * raw `# Title`, and so did desktop's reference pane and a hovered link's + * preview. + * + * So the selection's lines reveal their source only once the editor has taken + * the keyboard focus since its note opened; until then every line renders. + * The first focus turns revealing on, and a later blur leaves it on: a + * palette, a menu or a dialog opening over a note being edited must not + * redraw the line under the caret (and on a phone, the keyboard going down + * while editing is not the end of editing). A note put into an editor in + * place of another starts over, unless the editor holds the focus as it + * changes (a tab switch or a followed link from the keyboard), where the + * user is still editing. + * + * Desktop focuses the editor when it opens a note, and the focus is taken up + * at once, not on CodeMirror's own focus notification ten milliseconds later: + * by then a frame with the caret's line rendered could already be on screen, + * and every opened note would flicker its first line. + * + * An editor without this extension reveals on the selection's lines as it + * always did. + */ + +/** Turns revealing on (the editor took the focus) or off (a new note). */ +const setRevealing = StateEffect.define() + +const revealingField = StateField.define({ + create: () => false, + update(value, tr) { + for (const effect of tr.effects) if (effect.is(setRevealing)) value = effect.value + return value + } +}) + +function reading(state: EditorState): boolean { + return state.field(revealingField, false) === false +} + +export const sourceRevealExtension: Extension = [ + revealingField, + // CodeMirror's own notice of the focus, the path for every focus the + // observer below did not already take up. + EditorView.focusChangeEffect.of((state, focusing) => + focusing && reading(state) ? setRevealing.of(true) : null + ), + EditorView.domEventObservers({ + focus(_event, view) { + if (!reading(view.state)) return + // A microtask: it runs after this event's other listeners (a phone shell + // that turns the focus straight back is one) and before the frame paints. + queueMicrotask(() => { + if (view.hasFocus && reading(view.state)) { + view.dispatch({ effects: setRevealing.of(true) }) + } + }) + } + }) +] + +/** + * The selection ranges whose lines and spans show their source: every range + * once the editor is being edited, none while its note is being read. + */ +export function revealedSelectionRanges(state: EditorState): readonly SelectionRange[] { + return reading(state) ? [] : state.selection.ranges +} + +/** True when an update or transaction turned revealing on or off: the + * decorations that read `revealedSelectionRanges` redraw on it, the way they + * redraw on a selection change. */ +export function sourceRevealChanged(update: { + readonly startState: EditorState + readonly state: EditorState +}): boolean { + return reading(update.startState) !== reading(update.state) +} + +/** For the transaction that puts another note into an editor: the new note is + * read until the editor takes the focus, unless the editor holds it already. */ +export function sourceRevealForNewNote(view: EditorView): StateEffect { + return setRevealing.of(view.hasFocus) +} + +/** A note left to be read (a phone's search pick): every line renders again + * until the next focus. */ +export function readSourceRevealEffect(): StateEffect { + return setRevealing.of(false) +} diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts index f1b75fe39..c09440e2e 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -19,6 +19,7 @@ import { ViewPlugin, type ViewUpdate } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { useStore } from '../store' import { isSameFileBlockLink, isSameFileHeadingLink, resolveWikilinkTarget } from './wikilinks' import { openDatabaseFromWikilink, openWikilinkTarget } from './wikilink-navigation' @@ -132,7 +133,7 @@ function selectionTouches( from: number, to: number ): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true } else if (Math.max(range.from, from) < Math.min(range.to, to)) { @@ -227,6 +228,7 @@ const wikilinkRenderPlugin = ViewPlugin.fromClass( refreshed || update.docChanged || update.selectionSet || + sourceRevealChanged(update) || update.viewportChanged || // A parse cut short by its time budget finishes in a later update // that changes nothing else. diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts index e7efcc5cb..326507ac3 100644 --- a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts @@ -16,6 +16,7 @@ import { type ViewUpdate, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { calloutGroupFor } from './callout-types' import { CALLOUT_HEAD_RE, CalloutFoldChevron } from './cm-callout-fold' import { foldedExactly } from './cm-list-fold' @@ -93,7 +94,7 @@ class CalloutTitleWidget extends WidgetType { function activeLineSet(view: EditorView): Set { const lines = new Set() - for (const r of view.state.selection.ranges) { + for (const r of revealedSelectionRanges(view.state)) { const from = view.state.doc.lineAt(r.from).number const to = view.state.doc.lineAt(r.to).number for (let l = from; l <= to; l++) lines.add(l) @@ -269,6 +270,7 @@ export const wysiwygBlocksPlugin = ViewPlugin.fromClass( if ( update.docChanged || update.selectionSet || + sourceRevealChanged(update) || update.viewportChanged || // A parse cut short by its time budget finishes in a later update that // changes nothing else; without this the cards and hidden fences stay From c329a9a56216b3adc7a8a657a31ee79f48d0daff Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 20:25:46 -0500 Subject: [PATCH 04/34] Fix(search): a Search pick on a phone opens the note to read, keyboard down The phones' read-open guard keeps the keyboard down for a note opened from Home, the lists and a zennotes:// link, but a pick in Search still raised it: every one of core's searches ends with focusEditorNormalMode, the hand-off desktop wants (the next keystroke edits or moves in the note), and on a phone that focus is the soft keyboard over half the note the user came to read. Traced on the iPhone shell, that one call was the only editor focus after a pick from the note search, from Search in all notes and from the outline, and after cancelling a search too (the shell's Cancel sends the Escape that App's window handler answers the same way). The outline jump also focused the editor itself. The host now says how it wants a pick to land. EditorHostOptions (the editor host the shells already install for native typing and keyboard insets) gains `openNotesToRead`, asked at each pick. Where it returns true, a pick in the note search (notes and tagged blocks), Search in all notes, the outline palette and the buffer switcher opens the note at the pick without taking the focus, and the note is left to read (every line rendered, see the previous commit). If the closing palette gave the focus back to an editor that opened it (an iPhone keeps the focus in the text under a tap on a button), that hand-back is undone a frame later. Closing one of those searches without a pick leaves the focus where the palette returns it, to what had it before. Where it is absent, false or throws, nothing changes: desktop and the web app focus exactly as before. Why the editor host and not a bridge-contract capability: the answer is the phone layout, which a tablet changes at runtime (Split View), so it has to be a question asked at the pick, not a static capability; the shells already register this host; and the bridge is the vault and IO contract, with nothing for desktop or web to say here. The touch media query was not enough either: it is true on tablets in the desktop layout, which keep core's focus as the guard does. What it deliberately does not do: the list openers the shells' guard already covers keep focusing as before (the guard refuses it), and the command, template and drawing palettes keep their hand-off. Creating a note from Search still focuses it, as a new note should be written in. Shells: one line in src/ui-mobile/editor-host.ts, in both phones: `openNotesToRead: isPhoneViewport` in the installEditorHost options (isPhoneViewport from '../viewport'). Without it, a phone that adopts this core behaves as today for Search. Tests: editor-focus (desktop focuses; a host that answers no or throws keeps it; a reading host leaves the editor unfocused and read, undoes a hand-back to the editor, and leaves the focus alone on close); SearchPalette (a pick goes through the pick hand-off, Escape through the close one) and SearchPaletteBlocks (a block pick). On the iPhone shell (web build at phone size, rehearsal core from this tree, the one line above): picks from Search (from a note and from Home), Search in all notes and the outline open the note at the pick with the editor never focused and the caret line rendered; Cancel keeps it unfocused; a tap into the text focuses it and shows that line's `#`. Without the line, the same picks focus as before. On desktop over CDP every search still focuses the editor, painted the same as on 2.78.0. --- packages/app-core/README.md | 9 ++ packages/app-core/src/App.tsx | 12 ++- .../app-core/src/components/BufferPalette.tsx | 6 +- .../app-core/src/components/EditorPane.tsx | 6 +- .../src/components/OutlinePalette.tsx | 6 +- .../src/components/SearchPalette.test.ts | 23 ++++- .../app-core/src/components/SearchPalette.tsx | 12 ++- .../components/SearchPaletteBlocks.test.ts | 13 ++- .../src/components/VaultTextSearchPalette.tsx | 6 +- packages/app-core/src/editor.ts | 9 ++ .../app-core/src/lib/editor-focus.test.ts | 93 ++++++++++++++++++- packages/app-core/src/lib/editor-focus.ts | 40 ++++++++ packages/app-core/src/lib/editor-host.ts | 13 ++- 13 files changed, 223 insertions(+), 25 deletions(-) diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 21df1bb8f..d7fc716ce 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -321,6 +321,8 @@ import { installEditorHost, revealEditorCaret } from '@zennotes/app-core/editor' const host = installEditorHost({ nativeTyping: true, + // A phone layout reads what a search finds; the keyboard waits for a tap. + openNotesToRead: () => isPhoneLayout(), measureBottomInsets: ({ editor, scroll }) => ({ // These geometry helpers and overlay elements belong to the native shell. layout: bottomOverlap(editor, selectionToolbarBounds()), @@ -340,6 +342,13 @@ and writing suggestions through the editor's content attributes. The native keyboard decides which features to provide. It does not install keyboard plugins or change the host's spelling capabilities. +`openNotesToRead` is asked each time a pick in one of core's searches lands +(the note search, Search in all notes, the outline, the buffer switcher). When +it returns true the note opens at the pick without taking the keyboard focus, +so a soft keyboard stays down until the user taps into the text, and closing +such a search leaves the focus where it was. Absent, false or throwing, the +editor takes the focus as on desktop. Core's other openers are unaffected. + Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) only on lines the user is editing: once an editor has taken the focus since its note opened. A note opened to be read renders every line, the caret's too, with diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 9f9ad5fa4..687be7b5d 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -51,7 +51,7 @@ import { } from './lib/panel-rows' import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' -import { focusEditorNormalMode } from './lib/editor-focus' +import { focusEditorAfterSearchClose, focusEditorNormalMode } from './lib/editor-focus' import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' import { @@ -955,14 +955,16 @@ function App(): JSX.Element { void state.reopenLastClosedTab() return } + // A search closed here hands back the focus the way its own Escape and + // Cancel do: on a phone, that leaves the keyboard where it was. if (e.key === 'Escape' && state.searchOpen) { setSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } if (e.key === 'Escape' && state.vaultTextSearchOpen) { setVaultTextSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } if (e.key === 'Escape' && state.commandPaletteOpen) { @@ -972,7 +974,7 @@ function App(): JSX.Element { } if (e.key === 'Escape' && state.bufferPaletteOpen) { setBufferPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } if (e.key === 'Escape' && state.templatePaletteOpen) { @@ -987,7 +989,7 @@ function App(): JSX.Element { } if (e.key === 'Escape' && state.outlinePaletteOpen) { setOutlinePaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } // ⌘1 — toggle sidebar diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 6637a2987..7d22c848a 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -28,7 +28,7 @@ import { isArchiveTabPath } from '@shared/archive' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/system-folder-labels' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' import { noteFolderLabel } from '../lib/vault-layout' import { Modal } from './ui/Modal' @@ -323,12 +323,12 @@ export function BufferPalette(): JSX.Element { setActivePane(targetPaneId) } - focusEditorNormalMode() + focusEditorAfterPick() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } const closeSelected = async (): Promise => { diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 18827ebfc..1db5d74bc 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -40,7 +40,7 @@ import { import { Vim, getCM, vim } from '@replit/codemirror-vim' import type { AssetMeta, ImportedAsset, NoteComment, NoteFolder } from '@shared/ipc' import { registerNoteEditor } from '../lib/note-editor-context' -import { noteEditorHostExtension } from '../lib/editor-host' +import { hostOpensNotesToRead, noteEditorHostExtension } from '../lib/editor-host' import { indentWithTab, moveLineDown, @@ -1774,7 +1774,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }) }) setFocusedPanel('editor') - view.focus() + // An outline jump is a search pick too: where the host opens notes to + // read, the heading is shown without raising the keyboard. + if (!hostOpensNotesToRead()) view.focus() return true }, [schedulePreviewOutlineJump, setFocusedPanel]) diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index 6f14be253..ade50d33f 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -20,7 +20,7 @@ import { isWorkflowsTabPath } from '@shared/workflows-view' import { isAtlasTabPath } from '@shared/atlas-view' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' function isVirtualPath(path: string | null): boolean { @@ -75,12 +75,12 @@ export function OutlinePalette(): JSX.Element { detail: { line: item.line } }) ) - focusEditorNormalMode() + focusEditorAfterPick() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } return ( diff --git a/packages/app-core/src/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index f3772ffb9..dd10f017a 100644 --- a/packages/app-core/src/components/SearchPalette.test.ts +++ b/packages/app-core/src/components/SearchPalette.test.ts @@ -15,7 +15,13 @@ vi.mock('../lib/confirm-requests', () => ({ confirmApp })) // Closing the palette hands focus back to the editor with a few timed retries; // there is no editor here and the retries would outlive the jsdom window. const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) +const focusEditorAfterPick = vi.hoisted(() => vi.fn()) +const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterPick, + focusEditorAfterSearchClose +})) function note(title: string): NoteMeta { return { @@ -178,6 +184,8 @@ describe('SearchPalette: the create row opens a New note form', () => { createAndOpen = vi.fn(async () => {}) selectNote = vi.fn(async () => {}) focusEditorNormalMode.mockClear() + focusEditorAfterPick.mockClear() + focusEditorAfterSearchClose.mockClear() useStore.setState({ notes: [ { ...note('Alpha'), tags: ['ops', 'prod'] }, @@ -315,6 +323,19 @@ describe('SearchPalette: the create row opens a New note form', () => { await key(search(), 'Enter') expect(selectNote).toHaveBeenCalledWith('inbox/Alpha.md') expect(form()).toBeNull() + // A pick hands over through the pick's own path, which decides between + // focusing the editor (desktop) and leaving the note to be read (phones). + expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('Escape closes through the search close hand-off, not a plain editor focus', async () => { + type(search(), 'Alph') + await key(search(), 'Escape') + expect(useStore.getState().searchOpen).toBe(false) + expect(focusEditorAfterSearchClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + expect(focusEditorAfterPick).not.toHaveBeenCalled() }) it('a typed path fills the Folder field, and the Folder picker filters, picks and moves on to Tags', async () => { diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 1f6d1cd42..169fce3fd 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -8,7 +8,11 @@ import { parseNoteSearchQuery, searchNoteIndex } from '../lib/note-search' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { + focusEditorAfterPick, + focusEditorAfterSearchClose, + focusEditorNormalMode +} from '../lib/editor-focus' import { destinationLabel, parseDestinationText, @@ -144,7 +148,7 @@ export function SearchPalette(): JSX.Element { const open = async (note: NoteMeta): Promise => { setSearchOpen(false) await selectNote(note.path) - focusEditorNormalMode() + focusEditorAfterPick() } // A block opens its note at the block's line, highlighted, and records the @@ -155,7 +159,7 @@ export function SearchPalette(): JSX.Element { setSearchOpen(false) if (row.kind === 'block') { await openTaggedBlock(row.note.path, row.block.startLine) - focusEditorNormalMode() + focusEditorAfterPick() return } await openTagViewFor(tagTokens, freeText) @@ -174,7 +178,7 @@ export function SearchPalette(): JSX.Element { const close = (): void => { setSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } // Trash the highlighted note without leaving the palette (Discord ask: a diff --git a/packages/app-core/src/components/SearchPaletteBlocks.test.ts b/packages/app-core/src/components/SearchPaletteBlocks.test.ts index 51d195100..d1983d292 100644 --- a/packages/app-core/src/components/SearchPaletteBlocks.test.ts +++ b/packages/app-core/src/components/SearchPaletteBlocks.test.ts @@ -12,7 +12,13 @@ import { SearchPalette } from './SearchPalette' const confirmApp = vi.hoisted(() => vi.fn(async () => true)) vi.mock('../lib/confirm-requests', () => ({ confirmApp })) const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) +const focusEditorAfterPick = vi.hoisted(() => vi.fn()) +const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterPick, + focusEditorAfterSearchClose +})) function note(title: string, tags: string[], updatedAt: number): NoteMeta { return { @@ -55,6 +61,8 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { beforeEach(() => { originalState = useStore.getState() + focusEditorNormalMode.mockClear() + focusEditorAfterPick.mockClear() host = document.createElement('div') document.body.append(host) root = createRoot(host) @@ -151,6 +159,9 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { BODIES['inbox/Ideas.md']!.indexOf('Recipe gallery'), { scrollMode: 'center', highlightLine: true } ) + // A block is a pick like a note: read on a phone, edited on desktop. + expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() }) it('opens the Tags view on the query tags from the "+N more" row', async () => { diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index 0f634b465..f640f9568 100644 --- a/packages/app-core/src/components/VaultTextSearchPalette.tsx +++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx @@ -9,7 +9,7 @@ import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' import { recordRendererPerf } from '../lib/perf' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' type ResolvedVaultTextSearchBackend = 'builtin' | 'ripgrep' | 'fzf' @@ -365,12 +365,12 @@ export function VaultTextSearchPalette(): JSX.Element { const openMatch = async (match: VaultTextSearchMatch): Promise => { setOpen(false) await openNoteAtOffset(match.path, match.offset, { scrollMode: 'center' }) - focusEditorNormalMode() + focusEditorAfterPick() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } return ( diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index 4eb07cfe8..852eb11d5 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -34,6 +34,15 @@ export interface EditorHostOptions { readonly nativeTyping?: boolean /** Read-only measurement callback. Return CSS pixels; do not change layout here. */ readonly measureBottomInsets?: (viewport: EditorViewport) => EditorBottomInsets + /** + * Asked as a pick in one of core's searches (the note search, Search in all + * notes, the outline, the buffer switcher) lands: true opens the note to be + * read, at the pick, without handing the editor the keyboard focus, so a + * soft keyboard stays down until the user taps into the text. Closing such + * a search then leaves the focus where it was too. Absent or false, the + * editor takes the focus, as on desktop. + */ + readonly openNotesToRead?: () => boolean } export interface EditorHostRegistration { diff --git a/packages/app-core/src/lib/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index 5b76808cc..150b7b522 100644 --- a/packages/app-core/src/lib/editor-focus.test.ts +++ b/packages/app-core/src/lib/editor-focus.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const storeState = vi.hoisted(() => ({ current: { - editorViewRef: null as null | { dom: HTMLElement; focus: () => void }, + editorViewRef: null as null | Record, vimMode: false, setFocusedPanel: vi.fn() } @@ -23,7 +23,15 @@ vi.mock('@replit/codemirror-vim', () => ({ getCM: vi.fn(() => null) })) -import { focusEditorNormalMode } from './editor-focus' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { + focusEditorAfterPick, + focusEditorAfterSearchClose, + focusEditorNormalMode +} from './editor-focus' +import { installNoteEditorHost } from './editor-host' +import { revealedSelectionRanges, sourceRevealExtension } from './cm-source-reveal' describe('focusEditorNormalMode', () => { beforeEach(() => { @@ -62,3 +70,84 @@ describe('focusEditorNormalMode', () => { expect(document.activeElement).toBe(dom) }) }) + +describe('search hand-offs', () => { + let host: { dispose(): void } | null = null + let view: EditorView | null = null + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + return window.setTimeout(() => callback(performance.now()), 0) + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => window.clearTimeout(id)) + const parent = document.createElement('div') + document.body.appendChild(parent) + view = new EditorView({ + parent, + state: EditorState.create({ doc: '# Title\n\nBody', extensions: [sourceRevealExtension] }) + }) + storeState.current = { editorViewRef: view as never, vimMode: false, setFocusedPanel: vi.fn() } + }) + + afterEach(() => { + host?.dispose() + host = null + view?.destroy() + view = null + vi.restoreAllMocks() + vi.useRealTimers() + document.body.innerHTML = '' + }) + + it('a pick focuses the editor where the host does not open notes to read (desktop)', async () => { + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + expect(storeState.current.setFocusedPanel).toHaveBeenCalledWith('editor') + }) + + it('a host that answers no, or throws, keeps the desktop hand-off', async () => { + host = installNoteEditorHost({ + openNotesToRead: () => { + throw new Error('gone') + } + }) + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + }) + + it('a pick on a host that opens notes to read leaves the keyboard down and the note read', async () => { + host = installNoteEditorHost({ openNotesToRead: () => true }) + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + expect(storeState.current.setFocusedPanel).not.toHaveBeenCalled() + expect(revealedSelectionRanges(view!.state)).toEqual([]) + }) + + it('undoes the focus a closing palette handed back to the editor that opened it', async () => { + host = installNoteEditorHost({ openNotesToRead: () => true }) + view!.focus() + await vi.advanceTimersByTimeAsync(0) + expect(revealedSelectionRanges(view!.state)).not.toEqual([]) + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + expect(revealedSelectionRanges(view!.state)).toEqual([]) + }) + + it('closing a search gives the editor the keyboard on desktop, and leaves the focus alone on a reading host', async () => { + focusEditorAfterSearchClose() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + view!.contentDOM.blur() + + host = installNoteEditorHost({ openNotesToRead: () => true }) + focusEditorAfterSearchClose() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/editor-focus.ts b/packages/app-core/src/lib/editor-focus.ts index 935e76be4..a9bd41b53 100644 --- a/packages/app-core/src/lib/editor-focus.ts +++ b/packages/app-core/src/lib/editor-focus.ts @@ -1,5 +1,7 @@ import { Vim, getCM } from '@replit/codemirror-vim' import { useStore } from '../store' +import { readSourceRevealEffect } from './cm-source-reveal' +import { hostOpensNotesToRead } from './editor-host' /** * Return focus to the active editor pane, dropping out of vim insert @@ -46,3 +48,41 @@ export function focusEditorNormalMode( requestAnimationFrame(() => run(attempts)) } + +/** + * The hand-off after a pick in one of core's searches (the note search, Search + * in all notes, the outline, the buffer switcher) put a note on screen or moved + * within one. Desktop gives the editor the keyboard, so the next keystroke + * edits or moves in the note. A host that opens notes to read (a phone; the + * editor host's `openNotesToRead`) keeps it down: there the focus is the soft + * keyboard over half the note, and the user came to read what they found. The + * note stays at the pick with every line rendered, and a tap into the text + * raises the keyboard as always. + * + * A palette closing hands the focus back to whatever had it when it opened, + * which can be the editor itself (an iPhone keeps the focus in the text under + * a tap on a button). The pick still ends with the note read and the keyboard + * down: that hand-back is undone, a frame on, once the pick has landed. + */ +export function focusEditorAfterPick(): void { + if (!hostOpensNotesToRead()) { + focusEditorNormalMode() + return + } + requestAnimationFrame(() => { + const view = useStore.getState().editorViewRef + if (!view) return + if (view.hasFocus) view.contentDOM.blur() + view.dispatch({ effects: readSourceRevealEffect() }) + }) +} + +/** + * Closing one of those searches without a pick. Desktop gives the editor the + * keyboard back, as it always has; a host that opens notes to read leaves the + * focus where the palette returns it, to what had it before the search, so + * backing out of a search over a note being read keeps the keyboard down. + */ +export function focusEditorAfterSearchClose(): void { + if (!hostOpensNotesToRead()) focusEditorNormalMode() +} diff --git a/packages/app-core/src/lib/editor-host.ts b/packages/app-core/src/lib/editor-host.ts index 30124593b..7e393c0ad 100644 --- a/packages/app-core/src/lib/editor-host.ts +++ b/packages/app-core/src/lib/editor-host.ts @@ -143,7 +143,8 @@ export function installNoteEditorHost(options: EditorHostOptions): EditorHostReg const owner = { options: { nativeTyping: options.nativeTyping, - measureBottomInsets: options.measureBottomInsets + measureBottomInsets: options.measureBottomInsets, + openNotesToRead: options.openNotesToRead } } active = owner @@ -160,6 +161,16 @@ export function installNoteEditorHost(options: EditorHostOptions): EditorHostReg } } +/** Whether the host wants a search pick to open its note to be read + * (`openNotesToRead`). A host that throws answers no, which is desktop's way. */ +export function hostOpensNotesToRead(): boolean { + try { + return active?.options.openNotesToRead?.() === true + } catch { + return false + } +} + export function requestNoteEditorReveal(view: EditorView, isCurrent: () => boolean): boolean { const host = view.plugin(hostPlugin) if (!host) return false From ea958af8abc4539a04a07858866cf46cd69105a2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 20:49:50 -0500 Subject: [PATCH 05/34] Fix(palette): a phone backs out of a palette, and opens a note from a command, keyboard down After 0a46efb4 a Search pick on a phone opened its note to read, but two of core's hand-offs still raised the keyboard there. Closing the command palette, the template palette or the drawing palette without picking anything called focusEditorNormalMode, from the palette's own Escape and from App's window handler that answers the shells' Cancel button. And a command run from the command palette that only goes to a note already there (Go Back, today's daily note) was followed by the same editor focus, twice for the periodic notes: the palette refocuses after every command that lands on the editor, and openDailyNoteForDate and its weekly and monthly twins ask for the focus themselves (#353). Both now go through the editor host's `openNotesToRead`, the question 0a46efb4 added. The two hand-offs it introduced are renamed for what they now cover: focusEditorAfterReadOpen (a note opened to be read, from a search pick or a command) and focusEditorAfterPaletteClose (any of these palettes closed without a pick). On a host that opens notes to read, a palette closed without a pick leaves the focus where the palette returns it, to whatever had it before the palette opened, so backing out over a note being read keeps the keyboard down and backing out over a note being written keeps it up. Desktop and the web app focus the editor exactly as before, with the same retries. Commands say whether they open to read through a new `Command.opensToRead`, asked just before the command runs (afterwards a daily note exists either way). The rule: a command whose whole effect is to put an existing note on screen opens it to read; a command that creates a note, or works on the note in place, keeps the focus. Open to read (the palette hands over like a search pick): - nav.back, nav.forward, nav.toggle-recent (Go Back, Go Forward, Switch to Previous Note): history only holds notes that exist. - tab.reopen, tab.next, tab.previous (Reopen Closed Tab, Next Tab, Previous Tab): another note that is already open. - tab.close (Close Tab): what remains on screen is the neighboring tab. - note.daily.today, note.weekly.thisWeek, note.monthly.thisMonth: only when that note is there already. When the command has to create it, the note is new and is focused to be written in, as today. The command passes `readIfExisting`, so the opener's own focus request follows the same rule (a `read` detail on zen:focus-editor that App lands like a pick); other callers of those openers (the leader keys, the Calendar, Home, the phones' Daily note row) are unchanged. - note.daily.rollover (Roll Over Unfinished Tasks to Today): the same rule on today's note. Keep the focus, unchanged: every command that creates a note to write in (note.new.quick, note.new.inbox, note.new.here, task.new, task.new.folder, database.new, drawing.new, embed.drawing.new, and the template palette's create and insert); commands that act on the open note in place (format, rename, favorite, cover, archive and restore, copy, export, folds, task state, the editor toggles); nav.focus.editor and ref.focus, whose point is the focus; and split.right/split.down. The command palette has no command that follows a link: Open Link Popover peeks at one over the current note and keeps its own focus. Commands that open another palette or dialog still hand nothing over, and a theme picked in the palette still returns to the editor. What it deliberately does not do: change the commands themselves, Home or the Calendar, or anything on a host without `openNotesToRead`. Tests: commands (exactly those eleven commands answer opensToRead, history and tab commands always, the periodic ones only for an existing note, the writing commands never, and the periodic commands ask their opener for readIfExisting); store (the daily opener requests a read landing only for an existing note opened that way); editor-focus (a read open keeps its caller's retries on desktop); a palette test (the command, template and drawing palettes close through the close hand-off, a read command lands through the read hand-off, a writing one still focuses). app-core 3,624 and shared-domain 2,164 pass; typecheck clean (8/8). In the built app over CDP with isolated stores, on 2.78.0 and on this build: Escape out of the command palette (from its input and from a window Escape like the phones' Cancel), the template palette and the drawing palette, then Open Today's Daily Note creating the note, Go Back, Open Today's Daily Note again, Switch to Previous Note, Close Tab, Reopen Closed Tab and Next Tab each leave the same note on screen with the editor focused on both builds. --- packages/app-core/README.md | 14 +- packages/app-core/src/App.tsx | 32 ++-- .../app-core/src/components/BufferPalette.tsx | 6 +- .../src/components/CommandPalette.tsx | 18 ++- .../src/components/EmbedDrawingPalette.tsx | 4 +- .../src/components/OutlinePalette.tsx | 6 +- .../components/PaletteFocusHandoff.test.ts | 147 ++++++++++++++++++ .../src/components/SearchPalette.test.ts | 18 +-- .../app-core/src/components/SearchPalette.tsx | 10 +- .../components/SearchPaletteBlocks.test.ts | 12 +- .../src/components/TemplatePalette.tsx | 4 +- .../src/components/VaultTextSearchPalette.tsx | 6 +- packages/app-core/src/editor.ts | 13 +- packages/app-core/src/lib/commands.test.ts | 92 +++++++++++ packages/app-core/src/lib/commands.ts | 42 ++++- .../app-core/src/lib/editor-focus.test.ts | 34 ++-- packages/app-core/src/lib/editor-focus.ts | 39 +++-- packages/app-core/src/store.test.ts | 48 ++++++ packages/app-core/src/store.ts | 55 ++++--- 19 files changed, 492 insertions(+), 108 deletions(-) create mode 100644 packages/app-core/src/components/PaletteFocusHandoff.test.ts diff --git a/packages/app-core/README.md b/packages/app-core/README.md index d7fc716ce..3f3e40dd2 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -343,11 +343,15 @@ keyboard decides which features to provide. It does not install keyboard plugins or change the host's spelling capabilities. `openNotesToRead` is asked each time a pick in one of core's searches lands -(the note search, Search in all notes, the outline, the buffer switcher). When -it returns true the note opens at the pick without taking the keyboard focus, -so a soft keyboard stays down until the user taps into the text, and closing -such a search leaves the focus where it was. Absent, false or throwing, the -editor takes the focus as on desktop. Core's other openers are unaffected. +(the note search, Search in all notes, the outline, the buffer switcher), and +each time a command-palette command that only goes to a note already there +finishes (Go Back, Go Forward, Switch to Previous Note, the tab commands, and +today's daily, weekly or monthly note when it exists). When it returns true the +note opens without taking the keyboard focus, so a soft keyboard stays down +until the user taps into the text. Closing any of those palettes, or the +template or drawing palette, without a pick leaves the focus where it was. +Commands that create a note to write in keep focusing the editor. Absent, +false or throwing, the editor takes the focus as on desktop. Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) only on lines the user is editing: once an editor has taken the focus since its diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 687be7b5d..8a6ace8fd 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -51,7 +51,11 @@ import { } from './lib/panel-rows' import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' -import { focusEditorAfterSearchClose, focusEditorNormalMode } from './lib/editor-focus' +import { + focusEditorAfterPaletteClose, + focusEditorAfterReadOpen, + focusEditorNormalMode +} from './lib/editor-focus' import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' import { @@ -482,7 +486,15 @@ function App(): JSX.Element { // pane jump or mouse click. Routed through a DOM event to keep the store free // of an editor-focus import cycle. (#353) useEffect(() => { - const handler = (): void => focusEditorNormalMode({ attempts: 10, delayMs: 24 }) + // A request marked `read` (a periodic note that already existed, opened + // from the command palette) lands the way a search pick does: focused on + // desktop, left to read on a phone. + const handler = (event: Event): void => { + const options = { attempts: 10, delayMs: 24 } + const detail = (event as CustomEvent<{ read?: boolean } | null>).detail + if (detail?.read) focusEditorAfterReadOpen(options) + else focusEditorNormalMode(options) + } window.addEventListener('zen:focus-editor', handler) return () => window.removeEventListener('zen:focus-editor', handler) }, []) @@ -955,41 +967,41 @@ function App(): JSX.Element { void state.reopenLastClosedTab() return } - // A search closed here hands back the focus the way its own Escape and + // A palette closed here hands back the focus the way its own Escape and // Cancel do: on a phone, that leaves the keyboard where it was. if (e.key === 'Escape' && state.searchOpen) { setSearchOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.vaultTextSearchOpen) { setVaultTextSearchOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.commandPaletteOpen) { setCommandPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.bufferPaletteOpen) { setBufferPaletteOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.templatePaletteOpen) { setTemplatePaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.embedDrawingPaletteOpen) { setEmbedDrawingPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.outlinePaletteOpen) { setOutlinePaletteOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } // ⌘1 — toggle sidebar diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 7d22c848a..668302141 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -28,7 +28,7 @@ import { isArchiveTabPath } from '@shared/archive' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/system-folder-labels' -import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' +import { focusEditorAfterReadOpen, focusEditorAfterPaletteClose } from '../lib/editor-focus' import { noteFolderLabel } from '../lib/vault-layout' import { Modal } from './ui/Modal' @@ -323,12 +323,12 @@ export function BufferPalette(): JSX.Element { setActivePane(targetPaneId) } - focusEditorAfterPick() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } const closeSelected = async (): Promise => { diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index a01ecde16..daa250d25 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -27,7 +27,11 @@ import { } from '../lib/vault-switcher' import { runWorkflowById } from '../lib/workflow-trigger' import type { WorkflowIndexEntry } from '../lib/workflow-index' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { + focusEditorAfterPaletteClose, + focusEditorAfterReadOpen, + focusEditorNormalMode +} from '../lib/editor-focus' import { useCloudSyncStatusStore } from '../lib/cloud-auto-sync' import { getPublishNoteRequest } from '../lib/publish-note-requests' import { Modal } from './ui/Modal' @@ -280,7 +284,7 @@ export function CommandPalette(): JSX.Element { revertTheme() } setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } /* -------- Actions -------- */ @@ -304,6 +308,8 @@ export function CommandPalette(): JSX.Element { return } setOpen(false) + // Asked before the run: afterwards today's note exists either way. + const toRead = cmd.opensToRead?.() === true try { await cmd.run() // A command that opens a note (or otherwise lands on the editor) should @@ -316,6 +322,8 @@ export function CommandPalette(): JSX.Element { // outline, …) so we don't pull focus behind it, and likewise for the Cloud // conflict queue, the Cloud settings prompt and the Publish Note dialog, // which claim focus themselves and are tracked outside the store. + // A command that only went to a note that was already there hands over + // like a search pick: on a phone the note is left to read. if ( shouldRefocusEditorAfterCommand( useStore.getState(), @@ -323,8 +331,10 @@ export function CommandPalette(): JSX.Element { useCloudSyncStatusStore.getState().settingsConflictPromptOpen || getPublishNoteRequest() !== null ) - ) - focusEditorNormalMode() + ) { + if (toRead) focusEditorAfterReadOpen() + else focusEditorNormalMode() + } } catch (err) { console.error('command failed', cmd.id, err) } diff --git a/packages/app-core/src/components/EmbedDrawingPalette.tsx b/packages/app-core/src/components/EmbedDrawingPalette.tsx index 8ae58268d..3c10d4db7 100644 --- a/packages/app-core/src/components/EmbedDrawingPalette.tsx +++ b/packages/app-core/src/components/EmbedDrawingPalette.tsx @@ -5,7 +5,7 @@ import { isExcalidrawPath, isObsidianExcalidrawPath } from '@shared/excalidraw' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPaletteClose, focusEditorNormalMode } from '../lib/editor-focus' import { Modal } from './ui/Modal' /** @@ -55,7 +55,7 @@ export function EmbedDrawingPalette(): JSX.Element { const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index ade50d33f..49af09860 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -20,7 +20,7 @@ import { isWorkflowsTabPath } from '@shared/workflows-view' import { isAtlasTabPath } from '@shared/atlas-view' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' -import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' +import { focusEditorAfterReadOpen, focusEditorAfterPaletteClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' function isVirtualPath(path: string | null): boolean { @@ -75,12 +75,12 @@ export function OutlinePalette(): JSX.Element { detail: { line: item.line } }) ) - focusEditorAfterPick() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/components/PaletteFocusHandoff.test.ts b/packages/app-core/src/components/PaletteFocusHandoff.test.ts new file mode 100644 index 000000000..c762cf51a --- /dev/null +++ b/packages/app-core/src/components/PaletteFocusHandoff.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment jsdom + +import { act, createElement, type FunctionComponent } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// Which hand-off each palette asks for; the hand-offs themselves (desktop +// focuses, a reading host keeps the keyboard down) are covered in +// lib/editor-focus.test.ts. +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +const focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose +})) + +// The command palette lists whatever buildCommands gives it: two stand-ins, +// one that goes to a note already there and one that writes. +const goBack = vi.hoisted(() => vi.fn()) +const newNote = vi.hoisted(() => vi.fn()) +vi.mock('../lib/commands', () => ({ + buildCommands: () => [ + { + id: 'test.read', + title: 'Read Something', + category: 'Go', + run: goBack, + opensToRead: () => true + }, + { id: 'test.write', title: 'Write Something', category: 'Note', run: newNote } + ] +})) + +import { CommandPalette } from './CommandPalette' +import { TemplatePalette } from './TemplatePalette' +import { EmbedDrawingPalette } from './EmbedDrawingPalette' + +let host: HTMLDivElement +let root: Root +let originalScrollIntoView: PropertyDescriptor | undefined + +beforeEach(() => { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getAppInfo: () => ({ runtime: 'desktop' }), + getCapabilities: () => ({ supportsRemoteWorkspace: false }) + } + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + for (const fn of [focusEditorNormalMode, focusEditorAfterReadOpen, focusEditorAfterPaletteClose]) { + fn.mockClear() + } + goBack.mockClear() + newNote.mockClear() + // The refocus after a command only happens when the editor was the panel + // and nothing else is open over it. + useStore.setState({ + focusedPanel: 'editor', + commandPaletteOpen: false, + templatePaletteOpen: false, + embedDrawingPaletteOpen: false + }) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + } +}) + +const render = (component: FunctionComponent): void => { + act(() => root.render(createElement(component))) +} +const input = (): HTMLInputElement => document.querySelector('input') as HTMLInputElement +const type = (text: string): void => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + act(() => { + setValue.call(input(), text) + input().dispatchEvent(new Event('input', { bubbles: true })) + }) +} +const key = async (name: string): Promise => { + await act(async () => { + input().dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true })) + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('palette hand-offs', () => { + it('the command palette closes through the palette close hand-off', async () => { + useStore.setState({ commandPaletteOpen: true }) + render(CommandPalette) + await key('Escape') + expect(useStore.getState().commandPaletteOpen).toBe(false) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('a command that goes to a note already there lands like a search pick', async () => { + render(CommandPalette) + type('Read Something') + await key('Enter') + expect(goBack).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('a writing command still focuses the editor', async () => { + render(CommandPalette) + type('Write Something') + await key('Enter') + expect(newNote).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() + }) + + it('the template palette closes through the palette close hand-off', async () => { + useStore.setState({ templatePaletteOpen: true }) + render(TemplatePalette) + await key('Escape') + expect(useStore.getState().templatePaletteOpen).toBe(false) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('the drawing palette closes through the palette close hand-off', async () => { + useStore.setState({ embedDrawingPaletteOpen: true }) + render(EmbedDrawingPalette) + await key('Escape') + expect(useStore.getState().embedDrawingPaletteOpen).toBe(false) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index dd10f017a..b0c2de49c 100644 --- a/packages/app-core/src/components/SearchPalette.test.ts +++ b/packages/app-core/src/components/SearchPalette.test.ts @@ -15,12 +15,12 @@ vi.mock('../lib/confirm-requests', () => ({ confirmApp })) // Closing the palette hands focus back to the editor with a few timed retries; // there is no editor here and the retries would outlive the jsdom window. const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -const focusEditorAfterPick = vi.hoisted(() => vi.fn()) -const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +const focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode, - focusEditorAfterPick, - focusEditorAfterSearchClose + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose })) function note(title: string): NoteMeta { @@ -184,8 +184,8 @@ describe('SearchPalette: the create row opens a New note form', () => { createAndOpen = vi.fn(async () => {}) selectNote = vi.fn(async () => {}) focusEditorNormalMode.mockClear() - focusEditorAfterPick.mockClear() - focusEditorAfterSearchClose.mockClear() + focusEditorAfterReadOpen.mockClear() + focusEditorAfterPaletteClose.mockClear() useStore.setState({ notes: [ { ...note('Alpha'), tags: ['ops', 'prod'] }, @@ -325,7 +325,7 @@ describe('SearchPalette: the create row opens a New note form', () => { expect(form()).toBeNull() // A pick hands over through the pick's own path, which decides between // focusing the editor (desktop) and leaving the note to be read (phones). - expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).toHaveBeenCalledTimes(1) expect(focusEditorNormalMode).not.toHaveBeenCalled() }) @@ -333,9 +333,9 @@ describe('SearchPalette: the create row opens a New note form', () => { type(search(), 'Alph') await key(search(), 'Escape') expect(useStore.getState().searchOpen).toBe(false) - expect(focusEditorAfterSearchClose).toHaveBeenCalledTimes(1) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) expect(focusEditorNormalMode).not.toHaveBeenCalled() - expect(focusEditorAfterPick).not.toHaveBeenCalled() + expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() }) it('a typed path fills the Folder field, and the Folder picker filters, picks and moves on to Tags', async () => { diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 169fce3fd..1ac93ad74 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -9,8 +9,8 @@ import { searchNoteIndex } from '../lib/note-search' import { - focusEditorAfterPick, - focusEditorAfterSearchClose, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, focusEditorNormalMode } from '../lib/editor-focus' import { @@ -148,7 +148,7 @@ export function SearchPalette(): JSX.Element { const open = async (note: NoteMeta): Promise => { setSearchOpen(false) await selectNote(note.path) - focusEditorAfterPick() + focusEditorAfterReadOpen() } // A block opens its note at the block's line, highlighted, and records the @@ -159,7 +159,7 @@ export function SearchPalette(): JSX.Element { setSearchOpen(false) if (row.kind === 'block') { await openTaggedBlock(row.note.path, row.block.startLine) - focusEditorAfterPick() + focusEditorAfterReadOpen() return } await openTagViewFor(tagTokens, freeText) @@ -178,7 +178,7 @@ export function SearchPalette(): JSX.Element { const close = (): void => { setSearchOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } // Trash the highlighted note without leaving the palette (Discord ask: a diff --git a/packages/app-core/src/components/SearchPaletteBlocks.test.ts b/packages/app-core/src/components/SearchPaletteBlocks.test.ts index d1983d292..e9ab44b7f 100644 --- a/packages/app-core/src/components/SearchPaletteBlocks.test.ts +++ b/packages/app-core/src/components/SearchPaletteBlocks.test.ts @@ -12,12 +12,12 @@ import { SearchPalette } from './SearchPalette' const confirmApp = vi.hoisted(() => vi.fn(async () => true)) vi.mock('../lib/confirm-requests', () => ({ confirmApp })) const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -const focusEditorAfterPick = vi.hoisted(() => vi.fn()) -const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +const focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode, - focusEditorAfterPick, - focusEditorAfterSearchClose + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose })) function note(title: string, tags: string[], updatedAt: number): NoteMeta { @@ -62,7 +62,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { beforeEach(() => { originalState = useStore.getState() focusEditorNormalMode.mockClear() - focusEditorAfterPick.mockClear() + focusEditorAfterReadOpen.mockClear() host = document.createElement('div') document.body.append(host) root = createRoot(host) @@ -160,7 +160,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { { scrollMode: 'center', highlightLine: true } ) // A block is a pick like a note: read on a phone, edited on desktop. - expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).toHaveBeenCalledTimes(1) expect(focusEditorNormalMode).not.toHaveBeenCalled() }) diff --git a/packages/app-core/src/components/TemplatePalette.tsx b/packages/app-core/src/components/TemplatePalette.tsx index 2cd12f136..748f4f7f6 100644 --- a/packages/app-core/src/components/TemplatePalette.tsx +++ b/packages/app-core/src/components/TemplatePalette.tsx @@ -11,7 +11,7 @@ import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPaletteClose } from '../lib/editor-focus' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' import { mergeTemplates } from '@shared/template-files' import type { NoteTemplate } from '@bridge-contract/templates' @@ -70,7 +70,7 @@ export function TemplatePalette(): JSX.Element { const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index f640f9568..f9901641b 100644 --- a/packages/app-core/src/components/VaultTextSearchPalette.tsx +++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx @@ -9,7 +9,7 @@ import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' import { recordRendererPerf } from '../lib/perf' -import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' +import { focusEditorAfterReadOpen, focusEditorAfterPaletteClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' type ResolvedVaultTextSearchBackend = 'builtin' | 'ripgrep' | 'fzf' @@ -365,12 +365,12 @@ export function VaultTextSearchPalette(): JSX.Element { const openMatch = async (match: VaultTextSearchMatch): Promise => { setOpen(false) await openNoteAtOffset(match.path, match.offset, { scrollMode: 'center' }) - focusEditorAfterPick() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index 852eb11d5..a7b7e91fd 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -36,11 +36,14 @@ export interface EditorHostOptions { readonly measureBottomInsets?: (viewport: EditorViewport) => EditorBottomInsets /** * Asked as a pick in one of core's searches (the note search, Search in all - * notes, the outline, the buffer switcher) lands: true opens the note to be - * read, at the pick, without handing the editor the keyboard focus, so a - * soft keyboard stays down until the user taps into the text. Closing such - * a search then leaves the focus where it was too. Absent or false, the - * editor takes the focus, as on desktop. + * notes, the outline, the buffer switcher) lands, and as a command-palette + * command that only goes to a note already there (Go Back, Reopen Closed + * Tab, today's daily note when it exists) finishes: true opens the note to + * be read, without handing the editor the keyboard focus, so a soft + * keyboard stays down until the user taps into the text. Closing one of + * those palettes, or the template or drawing palette, without a pick then + * leaves the focus where it was too. Absent or false, the editor takes the + * focus, as on desktop. */ readonly openNotesToRead?: () => boolean } diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 1a8970384..6aca5d45d 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -502,3 +502,95 @@ describe('reference pane commands with a per-note pin', () => { expect(buildCommands().some((c) => c.id === 'ref.toggle')).toBe(false) }) }) + +describe('commands that open a note to read', () => { + const today = new Date() + const iso = [ + today.getFullYear(), + String(today.getMonth() + 1).padStart(2, '0'), + String(today.getDate()).padStart(2, '0') + ].join('-') + const daily = { + path: `inbox/Daily Notes/${iso}.md`, + title: iso, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + } as never + + async function commandsWith(notes: never[]) { + const { buildCommands, useStore } = await loadCommands() + const settings = useStore.getState().vaultSettings + useStore.setState({ + notes, + selectedPath: 'inbox/Plan.md', + activeNote: { folder: 'inbox', path: 'inbox/Plan.md', title: 'Plan', body: '' } as never, + closedTabStack: [{ path: 'inbox/Old.md' }] as never, + vaultSettings: { + ...settings, + dailyNotes: { ...settings.dailyNotes, enabled: true, directory: 'Daily Notes' }, + weeklyNotes: { ...settings.weeklyNotes, enabled: true }, + monthlyNotes: { ...settings.monthlyNotes, enabled: true } + } + }) + return { commands: buildCommands({ includeUnavailable: true }), useStore } + } + + it('marks only the commands that go to a note already there', async () => { + const { commands } = await commandsWith([]) + const marked = commands.filter((c) => c.opensToRead).map((c) => c.id).sort() + expect(marked).toEqual([ + 'nav.back', + 'nav.forward', + 'nav.toggle-recent', + 'note.daily.rollover', + 'note.daily.today', + 'note.monthly.thisMonth', + 'note.weekly.thisWeek', + 'tab.close', + 'tab.next', + 'tab.previous', + 'tab.reopen' + ]) + // History and tabs only ever land on notes that exist. + const navigation = ['nav.back', 'nav.forward', 'nav.toggle-recent', 'tab.close', 'tab.next'] + for (const id of [...navigation, 'tab.previous', 'tab.reopen']) { + expect(commands.find((c) => c.id === id)?.opensToRead?.()).toBe(true) + } + // Writing commands keep the editor focus. + const writing = ['note.new.inbox', 'note.new.quick', 'note.new.here', 'task.new', 'template.create'] + for (const id of [...writing, 'database.new', 'drawing.new', 'embed.drawing.new', 'nav.focus.editor']) { + expect(commands.find((c) => c.id === id)?.opensToRead).toBeUndefined() + } + }) + + it("reads today's daily note when it is there, and writes in one the command creates", async () => { + let { commands } = await commandsWith([]) + expect(commands.find((c) => c.id === 'note.daily.today')?.opensToRead?.()).toBe(false) + expect(commands.find((c) => c.id === 'note.daily.rollover')?.opensToRead?.()).toBe(false) + ;({ commands } = await commandsWith([daily])) + expect(commands.find((c) => c.id === 'note.daily.today')?.opensToRead?.()).toBe(true) + expect(commands.find((c) => c.id === 'note.daily.rollover')?.opensToRead?.()).toBe(true) + }) + + it('asks the periodic openers to open an existing note to read', async () => { + const { commands, useStore } = await commandsWith([daily]) + const openTodayDailyNote = vi.fn().mockResolvedValue(undefined) + const openThisWeekWeeklyNote = vi.fn().mockResolvedValue(undefined) + const openThisMonthMonthlyNote = vi.fn().mockResolvedValue(undefined) + useStore.setState({ openTodayDailyNote, openThisWeekWeeklyNote, openThisMonthMonthlyNote }) + await commands.find((c) => c.id === 'note.daily.today')?.run() + await commands.find((c) => c.id === 'note.weekly.thisWeek')?.run() + await commands.find((c) => c.id === 'note.monthly.thisMonth')?.run() + expect(openTodayDailyNote).toHaveBeenCalledWith({ readIfExisting: true }) + expect(openThisWeekWeeklyNote).toHaveBeenCalledWith({ readIfExisting: true }) + expect(openThisMonthMonthlyNote).toHaveBeenCalledWith({ readIfExisting: true }) + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 6b16eb240..454621a4c 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -58,7 +58,13 @@ import { copyLinkAtCursor } from './link-copy' import { getKeymapDisplay, getLeaderChordDisplay, type KeymapId } from './keymaps' import { dispatchKeyboardContextMenu, findTabContextMenuTarget } from './keyboard-context-menu' import { resolveSystemFolderLabels } from './system-folder-labels' -import { isCalendarToggleAvailable, noteFolderSubpath } from './vault-layout' +import { + findDailyNoteForDate, + findMonthlyNoteForDate, + findWeeklyNoteForDate, + isCalendarToggleAvailable, + noteFolderSubpath +} from './vault-layout' import { runWorkflowById } from './workflow-trigger' import { requestPublishNote } from './publish-note-requests' import { activeReferencePath } from './pinned-reference' @@ -107,6 +113,16 @@ export interface Command { when?: () => boolean /** Runs when the user picks this entry. Async is fine. */ run: () => void | Promise + /** + * Asked just before the command runs: true when all it does is take the + * user to a note that already exists, to read it (Go Back, Reopen Closed + * Tab, today's daily note when it is there already). The palette then hands + * over the way a search pick does: the editor takes the keyboard on desktop, + * and a host that opens notes to read (a phone) keeps it down. Commands that + * create a note to be written in, or act on the note in place, leave it out + * and keep focusing the editor. + */ + opensToRead?: () => boolean } export function buildCommands(options?: { includeUnavailable?: boolean }): Command[] { @@ -121,6 +137,10 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const parts = ids.map(shortcut) return parts.every(Boolean) ? parts.join(' ') : '' } + // Whether this period's daily, weekly or monthly note is already there: + // such a note opens to read, one the command creates is written in. + const periodicNoteExists = (find: typeof findDailyNoteForDate): boolean => + !!find(getState().notes, getState().vaultSettings, new Date()) const leaderShortcut = (id: KeymapId): string => getLeaderChordDisplay(getState().keymapOverrides, id) const paneShortcut = (id: KeymapId): string => chord('vim.panePrefix', id) @@ -195,7 +215,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'daily journal date today log', shortcut: leaderShortcut('vim.leaderDailyNote'), when: () => getState().vaultSettings.dailyNotes.enabled, - run: () => getState().openTodayDailyNote() + // Read when today's note is there already; one created here is written in. + opensToRead: () => periodicNoteExists(findDailyNoteForDate), + run: () => getState().openTodayDailyNote({ readIfExisting: true }) }, { id: 'note.daily.rollover', @@ -203,6 +225,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Note', keywords: 'daily tasks rollover roll over migrate unfinished carry forward today', when: () => getState().vaultSettings.dailyNotes.enabled, + // Like today's daily note: read when it is there already, focused when + // the rollover creates it. + opensToRead: () => periodicNoteExists(findDailyNoteForDate), run: () => { void getState().rolloverUnfinishedTasksIntoToday({ force: true, open: true }) } @@ -214,7 +239,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'weekly week review date log', shortcut: leaderShortcut('vim.leaderWeeklyNote'), when: () => getState().vaultSettings.weeklyNotes.enabled, - run: () => getState().openThisWeekWeeklyNote() + opensToRead: () => periodicNoteExists(findWeeklyNoteForDate), + run: () => getState().openThisWeekWeeklyNote({ readIfExisting: true }) }, { id: 'note.monthly.thisMonth', @@ -223,7 +249,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'monthly month review reflection date log', shortcut: leaderShortcut('vim.leaderMonthlyNote'), when: () => getState().vaultSettings.monthlyNotes.enabled, - run: () => getState().openThisMonthMonthlyNote() + opensToRead: () => periodicNoteExists(findMonthlyNoteForDate), + run: () => getState().openThisMonthMonthlyNote({ readIfExisting: true }) }, { id: 'template.create', @@ -694,6 +721,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // save shows `:w`. Outside Vim, the real Mod+W binding is correct. shortcut: getState().vimMode ? ':q' : shortcut('global.closeActiveTab'), when: () => !!getState().selectedPath, + opensToRead: () => true, run: () => getState().closeActiveNote() }, { @@ -703,6 +731,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'restore last undo closed reopen', shortcut: shortcut('global.reopenClosedTab'), when: () => getState().closedTabStack.length > 0, + opensToRead: () => true, run: () => getState().reopenLastClosedTab() }, { @@ -715,6 +744,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const s = getState() return getTabStepTarget(s.paneLayout, s.activePaneId, 1).kind !== 'none' }, + opensToRead: () => true, run: () => stepActiveTab(getState(), 1) }, { @@ -727,6 +757,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const s = getState() return getTabStepTarget(s.paneLayout, s.activePaneId, -1).kind !== 'none' }, + opensToRead: () => true, run: () => stepActiveTab(getState(), -1) }, { @@ -1191,6 +1222,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('vim.historyBack'), keywords: 'history previous', + opensToRead: () => true, run: () => getState().jumpToPreviousNote() }, { @@ -1199,6 +1231,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('vim.historyForward'), keywords: 'history next', + opensToRead: () => true, run: () => getState().jumpToNextNote() }, { @@ -1207,6 +1240,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('global.toggleRecentNote'), keywords: 'recent last previous alternate toggle switch note', + opensToRead: () => true, run: () => getState().toggleRecentNote() } ) diff --git a/packages/app-core/src/lib/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index 150b7b522..0aab7b6ee 100644 --- a/packages/app-core/src/lib/editor-focus.test.ts +++ b/packages/app-core/src/lib/editor-focus.test.ts @@ -26,8 +26,8 @@ vi.mock('@replit/codemirror-vim', () => ({ import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { - focusEditorAfterPick, - focusEditorAfterSearchClose, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, focusEditorNormalMode } from './editor-focus' import { installNoteEditorHost } from './editor-host' @@ -101,27 +101,39 @@ describe('search hand-offs', () => { document.body.innerHTML = '' }) - it('a pick focuses the editor where the host does not open notes to read (desktop)', async () => { - focusEditorAfterPick() + it('a read open focuses the editor where the host does not open notes to read (desktop)', async () => { + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(0) expect(view!.hasFocus).toBe(true) expect(storeState.current.setFocusedPanel).toHaveBeenCalledWith('editor') }) + it('on desktop a read open keeps the retries its caller asked for', async () => { + // The daily-note request retries 10 times, 24 ms apart: the note's editor + // can mount after the first frames. + storeState.current.editorViewRef = null + focusEditorAfterReadOpen({ attempts: 10, delayMs: 24 }) + await vi.advanceTimersByTimeAsync(24 * 5) + expect(view!.hasFocus).toBe(false) + storeState.current.editorViewRef = view as never + await vi.advanceTimersByTimeAsync(24 * 2) + expect(view!.hasFocus).toBe(true) + }) + it('a host that answers no, or throws, keeps the desktop hand-off', async () => { host = installNoteEditorHost({ openNotesToRead: () => { throw new Error('gone') } }) - focusEditorAfterPick() + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(0) expect(view!.hasFocus).toBe(true) }) - it('a pick on a host that opens notes to read leaves the keyboard down and the note read', async () => { + it('a read open on a host that opens notes to read leaves the keyboard down and the note read', async () => { host = installNoteEditorHost({ openNotesToRead: () => true }) - focusEditorAfterPick() + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(50) expect(view!.hasFocus).toBe(false) expect(storeState.current.setFocusedPanel).not.toHaveBeenCalled() @@ -133,20 +145,20 @@ describe('search hand-offs', () => { view!.focus() await vi.advanceTimersByTimeAsync(0) expect(revealedSelectionRanges(view!.state)).not.toEqual([]) - focusEditorAfterPick() + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(50) expect(view!.hasFocus).toBe(false) expect(revealedSelectionRanges(view!.state)).toEqual([]) }) - it('closing a search gives the editor the keyboard on desktop, and leaves the focus alone on a reading host', async () => { - focusEditorAfterSearchClose() + it('closing a palette gives the editor the keyboard on desktop, and leaves the focus alone on a reading host', async () => { + focusEditorAfterPaletteClose() await vi.advanceTimersByTimeAsync(0) expect(view!.hasFocus).toBe(true) view!.contentDOM.blur() host = installNoteEditorHost({ openNotesToRead: () => true }) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() await vi.advanceTimersByTimeAsync(50) expect(view!.hasFocus).toBe(false) }) diff --git a/packages/app-core/src/lib/editor-focus.ts b/packages/app-core/src/lib/editor-focus.ts index a9bd41b53..0a45f0911 100644 --- a/packages/app-core/src/lib/editor-focus.ts +++ b/packages/app-core/src/lib/editor-focus.ts @@ -50,23 +50,28 @@ export function focusEditorNormalMode( } /** - * The hand-off after a pick in one of core's searches (the note search, Search - * in all notes, the outline, the buffer switcher) put a note on screen or moved - * within one. Desktop gives the editor the keyboard, so the next keystroke - * edits or moves in the note. A host that opens notes to read (a phone; the - * editor host's `openNotesToRead`) keeps it down: there the focus is the soft - * keyboard over half the note, and the user came to read what they found. The - * note stays at the pick with every line rendered, and a tap into the text - * raises the keyboard as always. + * The hand-off after a note was opened to be read, or a place in one: a pick + * in one of core's searches (the note search, Search in all notes, the + * outline, the buffer switcher), or a command that only goes to a note that + * already exists (Go Back, Reopen Closed Tab, today's daily note when it is + * there already; see `Command.opensToRead`). Desktop gives the editor the + * keyboard, so the next keystroke edits or moves in the note, with the same + * retries the caller would have asked of `focusEditorNormalMode`. A host that + * opens notes to read (a phone; the editor host's `openNotesToRead`) keeps it + * down: there the focus is the soft keyboard over half the note, and the user + * came to read it. The note stays where it opened with every line rendered, + * and a tap into the text raises the keyboard as always. * * A palette closing hands the focus back to whatever had it when it opened, * which can be the editor itself (an iPhone keeps the focus in the text under - * a tap on a button). The pick still ends with the note read and the keyboard - * down: that hand-back is undone, a frame on, once the pick has landed. + * a tap on a button). The open still ends with the note read and the keyboard + * down: that hand-back is undone, a frame on, once the note has landed. */ -export function focusEditorAfterPick(): void { +export function focusEditorAfterReadOpen( + options: { attempts?: number; delayMs?: number } = {} +): void { if (!hostOpensNotesToRead()) { - focusEditorNormalMode() + focusEditorNormalMode(options) return } requestAnimationFrame(() => { @@ -78,11 +83,13 @@ export function focusEditorAfterPick(): void { } /** - * Closing one of those searches without a pick. Desktop gives the editor the + * Closing a palette without a pick: the searches above, the command palette, + * the template palette, the drawing palette. Desktop gives the editor the * keyboard back, as it always has; a host that opens notes to read leaves the - * focus where the palette returns it, to what had it before the search, so - * backing out of a search over a note being read keeps the keyboard down. + * focus where the palette returns it, to what had it before the palette + * opened, so backing out over a note being read keeps the keyboard down and + * backing out over a note being written keeps it up. */ -export function focusEditorAfterSearchClose(): void { +export function focusEditorAfterPaletteClose(): void { if (!hostOpensNotesToRead()) focusEditorNormalMode() } diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index d11a06226..d6ce0d25b 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -340,6 +340,54 @@ describe('daily note patterns', () => { }) }) +describe('daily note focus request', () => { + const settings = { + primaryNotesLocation: 'inbox' as const, + dailyNotes: { enabled: true, directory: 'Daily Notes' }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + monthlyNotes: { enabled: false, directory: 'Monthly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } + const date = new Date(2026, 5, 9) + const path = 'inbox/Daily Notes/2026-06-09.md' + + async function focusRequestsWhileOpening(exists: boolean, readIfExisting?: boolean) { + const note = makeNote('# 2026-06-09\n', path) + installZen({ + createNote: vi.fn().mockResolvedValue(note), + listNotes: vi.fn().mockResolvedValue([note]), + readNote: vi.fn().mockResolvedValue(note), + writeNote: vi.fn(async () => note) + }) + const { useStore } = await loadStore() + useStore.setState({ notes: exists ? [note] : [], customTemplates: [], vaultSettings: settings as never }) + const requests: unknown[] = [] + const listen = (event: Event): void => { + requests.push((event as CustomEvent).detail ?? null) + } + window.addEventListener('zen:focus-editor', listen) + try { + await useStore + .getState() + .openDailyNoteForDate(date, readIfExisting === undefined ? undefined : { readIfExisting }) + } finally { + window.removeEventListener('zen:focus-editor', listen) + } + return requests + } + + it('asks for a read landing when the caller opens an existing note to read', async () => { + expect(await focusRequestsWhileOpening(true, true)).toEqual([{ read: true }]) + }) + + it('asks for the plain editor focus otherwise: no option, or a note it had to create', async () => { + expect(await focusRequestsWhileOpening(true)).toEqual([null]) + expect(await focusRequestsWhileOpening(false, true)).toEqual([null]) + }) +}) + describe('daily task rollover', () => { it.each([false, true])('replaces empty template tasks and preserves real work (open: %s)', async (open) => { const iso = (date: Date) => [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 8cfea7c29..f97fe45ae 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -308,9 +308,15 @@ const WORKSPACE_KEY = 'zen:workspace:v1' * event (handled in App.tsx via `focusEditorNormalMode`) so the store doesn't * have to import editor-focus, which imports the store. Used when a focused * panel (Tasks/Tags) closes so typing lands in the editor again. (#353) */ -function requestEditorFocus(): void { +function requestEditorFocus(options: { read?: boolean } = {}): void { if (typeof window === 'undefined') return - window.dispatchEvent(new Event('zen:focus-editor')) + // `read`: the note already existed and was opened to be read, which App + // hands over like a search pick (left unfocused on a phone). + window.dispatchEvent( + options.read + ? new CustomEvent('zen:focus-editor', { detail: { read: true } }) + : new Event('zen:focus-editor') + ) } /** Debounce for mirroring the workspace snapshot to the synced vault file — * localStorage updates immediately; the file lags to bound sync churn. (#292) */ @@ -505,6 +511,14 @@ function isDeletedAssetRecord(value: unknown): value is DeletedAsset { /** Which weekday the calendar grid starts on. `locale` derives it from the * user's locale (falling back to Monday). */ export type CalendarWeekStart = 'monday' | 'sunday' | 'locale' + +/** How a daily, weekly or monthly note opener lands. `readIfExisting`: a note + * that is already there opens to be read (the command palette's Open Today's + * Daily Note and friends), so a phone keeps its keyboard down; one the opener + * has to create still takes the focus, to be written in. */ +export interface PeriodicNoteOpenOptions { + readIfExisting?: boolean +} const VALID_CALENDAR_WEEK_STARTS: CalendarWeekStart[] = ['monday', 'sunday', 'locale'] /** The editor-pane right-side panels whose width the user can drag-resize. */ @@ -3885,9 +3899,9 @@ interface Store { setQuickNoteDateTitle: (on: boolean) => void setQuickNoteTitlePrefix: (prefix: string | null) => void - openTodayDailyNote: () => Promise - openThisWeekWeeklyNote: () => Promise - openThisMonthMonthlyNote: () => Promise + openTodayDailyNote: (options?: PeriodicNoteOpenOptions) => Promise + openThisWeekWeeklyNote: (options?: PeriodicNoteOpenOptions) => Promise + openThisMonthMonthlyNote: (options?: PeriodicNoteOpenOptions) => Promise setTemplatePaletteOpen: (open: boolean) => void setEmbedDrawingPaletteOpen: (open: boolean) => void setNoteCoverPickerPath: ( @@ -3944,9 +3958,9 @@ interface Store { setAutoCalendarPanel: (enabled: boolean) => void setCalendarWeekStart: (start: CalendarWeekStart) => void setCalendarShowWeekNumbers: (show: boolean) => void - openDailyNoteForDate: (date: Date) => Promise - openWeeklyNoteForDate: (date: Date) => Promise - openMonthlyNoteForDate: (date: Date) => Promise + openDailyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise + openWeeklyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise + openMonthlyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise /** Find the daily note for `date`, creating it on disk (template-aware) * WITHOUT navigating to it. Returns its meta, or null if daily notes are * disabled or creation failed. */ @@ -9934,7 +9948,7 @@ export const useStore = create((set, get) => { savePrefs(collectPrefs(get())) }, - openDailyNoteForDate: async (date) => { + openDailyNoteForDate: async (date, options) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.dailyNotes.enabled) return @@ -9955,7 +9969,8 @@ export const useStore = create((set, get) => { // instead of leaving focus on the sidebar item that just got selected. The // command is a jump-and-type flow and is often fired from outside the // editor (leader key, palette), where focus would otherwise stay put. (#353) - requestEditorFocus() + // A caller that opens it to read asks for that when the note was there. + requestEditorFocus({ read: !!existing && options?.readIfExisting === true }) // Opening *today's* note rolls unfinished tasks forward from past daily // notes (Obsidian-style) when enabled. Fire-and-forget so the note shows // right away; the rollover appends into the now-open buffer. @@ -9964,8 +9979,8 @@ export const useStore = create((set, get) => { } }, - openTodayDailyNote: async () => { - await get().openDailyNoteForDate(new Date()) + openTodayDailyNote: async (options) => { + await get().openDailyNoteForDate(new Date(), options) }, ensureDailyNoteForDate: trackNoteWrite(null, async (date) => { @@ -10149,7 +10164,7 @@ export const useStore = create((set, get) => { return movedLines.length }), - openWeeklyNoteForDate: async (date) => { + openWeeklyNoteForDate: async (date, options) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.weeklyNotes.enabled) return @@ -10167,14 +10182,14 @@ export const useStore = create((set, get) => { } } // Focus the editor so `i` starts insert immediately (see openDailyNoteForDate). - requestEditorFocus() + requestEditorFocus({ read: !!existing && options?.readIfExisting === true }) }, - openThisWeekWeeklyNote: async () => { - await get().openWeeklyNoteForDate(new Date()) + openThisWeekWeeklyNote: async (options) => { + await get().openWeeklyNoteForDate(new Date(), options) }, - openMonthlyNoteForDate: async (date) => { + openMonthlyNoteForDate: async (date, options) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.monthlyNotes.enabled) return @@ -10192,11 +10207,11 @@ export const useStore = create((set, get) => { } } // Focus the editor so `i` starts insert immediately (see openDailyNoteForDate). - requestEditorFocus() + requestEditorFocus({ read: !!existing && options?.readIfExisting === true }) }, - openThisMonthMonthlyNote: async () => { - await get().openMonthlyNoteForDate(new Date()) + openThisMonthMonthlyNote: async (options) => { + await get().openMonthlyNoteForDate(new Date(), options) }, setTemplatePaletteOpen: (open) => From cfd3b10327c6481493d441f8c384ac62f28c2993 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 09:24:03 -0500 Subject: [PATCH 06/34] Feat(settings): a Status bar switch hides the bar along the bottom, except while Cloud needs you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piotr asked on Discord (2026-10-28), with an Android screenshot: "On android, can we hide the cloud connected / setup bar??" On the phones the bar along the bottom carries only its Cloud half, so a device that is signed in but not linked showed "ZenNotes Cloud · Cloud connected · Set up" under every note for as long as it stayed that way, and on Android the circle was lifted above that row too. There was no way to turn it off, on the phones or on desktop. Settings, Appearance, Chrome gains Status bar, on by default, next to the title bar switch, with the words people search for (cloud, cloud connected, footer, word count). Turned off, the bar is not drawn at all on any surface: desktop gets its 32 px back, and on Android the Cloud row goes and the circle drops back to the bottom, because the shell sizes that row and lifts the circle from the bar's data-cloud-sync-* hooks, which are no longer in the page. It stays off except while Cloud needs the person. cloudSyncNeedsUser (cloud-auto-sync.ts) is the same set the phones already key their Cloud row on: the attention and error phases (a stopped or failed run, including Can't reach Cloud), a Cloud vault that went away and a device Cloud signed out (both drawn as attention, now one cloudSyncShownPhase that the status row uses too), and files or the vault settings question waiting for a decision. Then the bar comes back until that is dealt with, so turning it off never hides the one place that says sync has stopped. Zen mode still clears the bar whatever it says, as before. statusBarVisible (lib/status-bar-visibility.ts) holds the rule. It is a portable preference: showStatusBar in PORTABLE_PREF_KEYS, show_status_bar under [appearance] in config.toml beside show_window_title_bar. The phones keep it with the rest of app-core's preferences (zen:prefs:v2, which both shells mirror into native storage), so it needs no shell code. Keyboard: the command palette's Toggle Status Bar (and so :view_status_bar), and `:set statusbar`, `:set nostatusbar`, `:set statusbar?` (alias `stb`) in Vim mode, the way `:set undofile` works. No single-key binding, so Vim-off gating is untouched. The option is defined without a default value: codemirror-vim applies a truthy default through the callback when the option is defined, and the first desktop relaunch over a saved show_status_bar = false came back with the bar on. Docs: the Appearance entry, the Cloud card and the ex command list in help.ts; the website follows in its own branch. What it deliberately does not do: change the iPhone, whose shell already shows the Cloud row only while Cloud needs the user, so there the switch changes nothing until the shell decides otherwise; or add a global shortcut or leader key, which none of the other chrome switches have. Tests: statusBarVisible (on by default, off until Cloud needs the user, Zen mode wins); cloudSyncNeedsUser (quiet for the everyday phases; needs the user for attention, error, a removed vault, a signed-out device, waiting files even mid-sync, and the settings question); the Settings search finds Status bar by "cloud connected" and its switch turns it off; Toggle Status Bar flips and saves it; `:set statusbar` leaves a saved choice alone when it is defined (fails with a truthy default) and sets, clears and reports it; config.toml round-trips [appearance].show_status_bar; PORTABLE_DEFAULTS still matches DEFAULT_PREFS. app-core 3,636, shared-domain 2,164 and desktop 1,198 pass; typecheck clean (8/8). In the built app over CDP with isolated stores: Toggle Status Bar hides the bar and the editor gains 32 px, config.toml says show_status_bar = false, `:set statusbar` and `:set nostatusbar` bring it back and hide it; with the main process's Cloud handlers swapped for a linked account whose sync fails, the hidden bar comes back as "Sync failed", and goes again once a sync succeeds; a relaunch with a fresh userData and the same config.toml opens with the bar hidden. --- apps/desktop/src/main/app-config.test.ts | 4 ++ apps/desktop/src/main/app-config.ts | 5 +++ packages/app-core/src/components/Editor.tsx | 18 +++++++-- .../src/components/SettingsModal.test.ts | 12 ++++++ .../app-core/src/components/SettingsModal.tsx | 28 +++++++++++++ .../app-core/src/components/StatusBar.tsx | 9 +++-- .../app-core/src/lib/cloud-auto-sync.test.ts | 39 +++++++++++++++++++ packages/app-core/src/lib/cloud-auto-sync.ts | 36 +++++++++++++++++ packages/app-core/src/lib/commands.test.ts | 14 +++++++ packages/app-core/src/lib/commands.ts | 9 +++++ packages/app-core/src/lib/help.ts | 8 +++- .../src/lib/status-bar-visibility.test.ts | 35 +++++++++++++++++ .../app-core/src/lib/status-bar-visibility.ts | 26 +++++++++++++ .../src/lib/vim-status-bar-option.test.ts | 28 +++++++++++++ .../app-core/src/lib/vim-status-bar-option.ts | 20 ++++++++++ packages/app-core/src/store.ts | 15 +++++++ packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + 18 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 packages/app-core/src/lib/status-bar-visibility.test.ts create mode 100644 packages/app-core/src/lib/status-bar-visibility.ts create mode 100644 packages/app-core/src/lib/vim-status-bar-option.test.ts create mode 100644 packages/app-core/src/lib/vim-status-bar-option.ts diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index d77de0fb8..c1f4b5bb7 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -116,6 +116,7 @@ describe('TOML serialization', () => { themeFamily: 'nord', themeMode: 'dark', showWindowTitleBar: false, + showStatusBar: false, autoPairs: false, autoPairQuotesInProse: true, showHeadingLevelLabels: true, @@ -151,6 +152,9 @@ describe('TOML serialization', () => { expect(round.themeFamily).toBe('nord') expect(text).toContain('show_window_title_bar = false') expect(round.showWindowTitleBar).toBe(false) + // Piotr's status bar switch travels as [appearance].show_status_bar. + expect(text).toMatch(/\[appearance\][^[]*show_status_bar = false/) + expect(round.showStatusBar).toBe(false) expect(round.autoPairs).toBe(false) expect(round.autoPairQuotesInProse).toBe(true) expect(round.showHeadingLevelLabels).toBe(true) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index c15494479..dc61b2e28 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -307,6 +307,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'show_window_title_bar', comment: 'show the main window title bar and window controls' }, + showStatusBar: { + section: 'appearance', + tomlKey: 'show_status_bar', + comment: 'show the bar along the bottom; while hidden it still appears when Cloud needs you' + }, showSidebarChevrons: { section: 'appearance', tomlKey: 'show_sidebar_chevrons', diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 44eea54fc..72e8b9cb9 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -82,7 +82,9 @@ import { openExternalFileLink, } from "../lib/external-file-link"; import { StatusBar } from "./StatusBar"; -import { useCloudSyncStatusStore } from "../lib/cloud-auto-sync"; +import { cloudSyncNeedsUser, useCloudSyncStatusStore } from "../lib/cloud-auto-sync"; +import { statusBarVisible } from "../lib/status-bar-visibility"; +import { defineStatusBarOption } from "../lib/vim-status-bar-option"; import { EditorPane } from "./EditorPane"; import { focusPaneInDirection, focusPaneOrEdgePanel } from "../lib/pane-nav"; import { requestPaneMode } from "../lib/pane-mode"; @@ -650,6 +652,7 @@ function registerVimCommands(): void { return undefined; }, ); + defineStatusBarOption(); // `:harper on|off` (or bare `:harper` to toggle) is the ex twin of the // Settings toggle "Grammar and spelling with Harper". Vim.defineEx( @@ -1825,6 +1828,15 @@ export function Editor(): JSX.Element { const cloudSyncVisible = useCloudSyncStatusStore( (state) => state.phase !== "hidden", ); + const showStatusBar = useStore((s) => s.showStatusBar); + const cloudNeedsUser = useCloudSyncStatusStore(cloudSyncNeedsUser); + const statusBarShown = statusBarVisible({ + zenMode, + hasNote: activeNote !== null, + cloudShown: cloudSyncVisible, + showStatusBar, + cloudNeedsUser, + }); useEffect(() => { registerVimCommands(); @@ -1844,9 +1856,7 @@ export function Editor(): JSX.Element {
- {!zenMode && (activeNote || cloudSyncVisible) && ( - - )} + {statusBarShown && } ); } diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 795e849b3..21c69011d 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -41,6 +41,8 @@ const mocks = vi.hoisted(() => { darkSidebar: false, showWindowTitleBar: true, setShowWindowTitleBar: vi.fn(), + showStatusBar: true, + setShowStatusBar: vi.fn(), editorFontSize: 16, editorLineHeight: 1.6, editorScrollOff: 0, @@ -290,6 +292,16 @@ describe("SettingsModal date note directories", () => { expect(mocks.state.setShowWindowTitleBar).toHaveBeenCalledWith(false); }); + it("finds the status bar by the Cloud row it carries and turns it off", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector('input[placeholder="Search settings…"]'); + await act(async () => changeInput(search!, "cloud connected")); + const toggle = host.querySelector('[data-settings-search-id="status-bar"] [role="switch"]'); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + await act(async () => toggle!.click()); + expect(mocks.state.setShowStatusBar).toHaveBeenCalledWith(false); + }); + it("finds the new-file location rows by the words people search for", async () => { await act(async () => root.render(createElement(SettingsModal))); const search = host.querySelector('input[placeholder="Search settings…"]')!; diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 98b1833d9..9c3b42c71 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -956,6 +956,8 @@ export function SettingsModal(): JSX.Element { const showWindowTitleBar = useStore((s) => s.showWindowTitleBar); const keepRunningInTray = useTrayStore((s) => s.keepRunningInTray); const setShowWindowTitleBar = useStore((s) => s.setShowWindowTitleBar); + const showStatusBar = useStore((s) => s.showStatusBar); + const setShowStatusBar = useStore((s) => s.setShowStatusBar); const setDarkSidebar = useStore((s) => s.setDarkSidebar); const showSidebarChevrons = useStore((s) => s.showSidebarChevrons); const setShowSidebarChevrons = useStore((s) => s.setShowSidebarChevrons); @@ -1601,6 +1603,25 @@ export function SettingsModal(): JSX.Element { : "Show the bar with the open note's title, or hide it to reclaim vertical space.", keywords: ["decorations", "frameless", "traffic lights", "tiling", "hyprland", "linux", "header"], }, + { + id: "status-bar", + title: "Status bar", + description: + "Show the bar along the bottom with Cloud sync status and the note's backlinks, words and position. Turned off, it still appears while Cloud needs you.", + keywords: [ + "status bar", + "footer", + "bottom bar", + "cloud", + "sync", + "cloud connected", + "set up", + "word count", + "backlinks", + "cursor position", + "hide", + ], + }, { id: "keep-running-in-tray", title: "Keep running in the tray", @@ -2046,6 +2067,13 @@ export function SettingsModal(): JSX.Element { onChange={setShowWindowTitleBar} /> )} + ; const label = removal ? cloudVaultRemovalLabel(removal) diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index ef2fa569d..e7e2ad8ab 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -18,6 +18,7 @@ import { cloudSyncAttentionItems, cloudSyncAttentionLabel, cloudSyncAttentionMessage, + cloudSyncNeedsUser, cloudVaultGoneReason, cloudVaultRemovalLabel, cloudVaultRemovalMessage, @@ -1405,3 +1406,41 @@ describe("a Cloud vault that went away", () => { expect(values.size).toBe(0); }); }); + +describe("cloudSyncNeedsUser (the status bar shows for it even when turned off)", () => { + const quiet = { + phase: "ready" as const, + removedVault: null, + errorKind: null, + lastSummary: null, + settingsConflict: null, + }; + + it("is quiet for the everyday phases", () => { + for (const phase of ["hidden", "disconnected", "connecting", "unlinked", "ready", "syncing"] as const) { + expect(cloudSyncNeedsUser({ ...quiet, phase })).toBe(false); + } + }); + + it("needs the user for a stopped or failed run", () => { + expect(cloudSyncNeedsUser({ ...quiet, phase: "attention" })).toBe(true); + expect(cloudSyncNeedsUser({ ...quiet, phase: "error", errorKind: "offline" })).toBe(true); + }); + + it("needs the user for a vault that went away and a device Cloud signed out", () => { + expect( + cloudSyncNeedsUser({ + ...quiet, + phase: "unlinked", + removedVault: { vaultName: "Notes", reason: "deleted" }, + }), + ).toBe(true); + expect(cloudSyncNeedsUser({ ...quiet, phase: "disconnected", errorKind: "signed-out" })).toBe(true); + }); + + it("needs the user while files or the settings question wait, even mid-sync", () => { + const summary = { pending_conflicts: [{ id: "c1" }] } as never; + expect(cloudSyncNeedsUser({ ...quiet, phase: "syncing", lastSummary: summary })).toBe(true); + expect(cloudSyncNeedsUser({ ...quiet, phase: "ready", settingsConflict: {} as never })).toBe(true); + }); +}); diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 8bd792848..267a811d4 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -788,6 +788,42 @@ function announceNewCloudConflicts( ); } +/** + * The phase the status row draws. A device whose Cloud vault went away, or + * that Cloud signed out, stopped syncing without the person choosing to, so + * it reads as attention rather than as a quiet unlinked or disconnected row. + */ +export function cloudSyncShownPhase( + state: Pick, +): CloudSyncPhase { + if (state.phase === "unlinked" && state.removedVault !== null) return "attention"; + if (state.phase === "disconnected" && state.errorKind === "signed-out") return "attention"; + return state.phase; +} + +/** + * True while Cloud needs the person: a run that stopped for them or failed + * (including a Cloud vault that went away and a device Cloud signed out), or + * files or the vault settings question waiting for a decision. The status + * bar shows for these even when it is turned off, so nothing that needs an + * answer goes silent; the phone shells key their Cloud row on the same set + * (the attention and error phases and the review hook). + */ +export function cloudSyncNeedsUser( + state: Pick< + CloudSyncStatusStore, + "phase" | "removedVault" | "errorKind" | "lastSummary" | "settingsConflict" + > = useCloudSyncStatusStore.getState(), +): boolean { + const shown = cloudSyncShownPhase(state); + return ( + shown === "attention" || + shown === "error" || + resolvableCloudConflictCount(state.lastSummary) > 0 || + state.settingsConflict !== null + ); +} + /** Conflicts the queue can actually resolve. Bootstrap conflicts are no longer * emitted by the coordinator, so only the durable pending queue counts. */ export function resolvableCloudConflictCount( diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 6aca5d45d..7b909846b 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -594,3 +594,17 @@ describe('commands that open a note to read', () => { expect(openThisMonthMonthlyNote).toHaveBeenCalledWith({ readIfExisting: true }) }) }) + +describe('Toggle Status Bar (Piotr on Discord)', () => { + it('flips the setting and saves it with the other preferences', async () => { + const { buildCommands, useStore } = await loadCommands() + expect(useStore.getState().showStatusBar).toBe(true) + const toggle = buildCommands().find((c) => c.id === 'view.status-bar') + expect(toggle?.title).toBe('Toggle Status Bar') + await toggle?.run() + expect(useStore.getState().showStatusBar).toBe(false) + expect(JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}').showStatusBar).toBe(false) + await buildCommands().find((c) => c.id === 'view.status-bar')?.run() + expect(useStore.getState().showStatusBar).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 454621a4c..6deeb806b 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1584,6 +1584,15 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'View', run: () => getState().setDarkSidebar(!getState().darkSidebar) }, + { + // Settings, Appearance, Chrome, Status bar. Hidden, the bar still shows + // while Cloud needs the user (lib/status-bar-visibility.ts). + id: 'view.status-bar', + title: 'Toggle Status Bar', + category: 'View', + keywords: 'status bar footer bottom bar hide show cloud sync word count backlinks chrome', + run: () => getState().setShowStatusBar(!getState().showStatusBar) + }, { id: 'view.line-numbers.off', title: 'Line Numbers: Off', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 81fd3974a..445b0847a 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -229,7 +229,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'When Cloud reports a problem', body: - 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report.' + 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.' }, { title: 'Customize the look: themes vs. overrides', @@ -820,6 +820,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Save the active note', detail: 'Flush the current buffer to disk immediately.' }, + { + command: ':set [no]statusbar', + summary: 'Show or hide the status bar', + detail: 'The same switch as Settings, Appearance, Chrome, Status bar: `:set nostatusbar` hides the bar along the bottom, `:set statusbar` brings it back and `:set statusbar?` says which it is (`stb` for short). Hidden, the bar still shows while ZenNotes Cloud needs you.' + }, { command: ':harper [on|off]', summary: 'Grammar and spelling with Harper', @@ -1178,6 +1183,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ items: [ { label: 'Theme, mode, and variant', detail: 'Pick a theme family: Apple, Gruvbox, Catppuccin, GitHub, Solarized, One, Nord, Tokyo Night, Kanagawa (Wave / Dragon / Paper Ink (Custom) / Lotus), Rosé Pine (Rosé Pine / Moon / Dawn), or the monochrome, true-black (OLED-friendly) Black Metal, plus light or dark mode and the active flavor or contrast where the theme supports it.' }, { label: 'Window title bar', detail: 'Under Settings → Appearance → Chrome, turn off Window title bar to remove the main window title and controls on macOS, Windows, and Linux, including tiling desktops such as Hyprland. The sidebar and editor stay visible, and the change applies to open vault windows without a restart. Drag blank space in the tab strip, the sidebar header or the note list header to move the window, or use your window manager shortcuts. Press Cmd/Ctrl+, to restore the setting. The preference is saved as [appearance].show_window_title_bar in config.toml. In the browser app the same switch is called Title bar: it hides the bar with the open note’s title, in that browser.' }, + { label: 'Status bar', detail: 'Under Settings → Appearance → Chrome, Status bar shows or hides the bar along the bottom of the window: the ZenNotes Cloud status (Synced, Sync now, Set up) and, on desktop, the open note’s backlinks, words, characters, reading time and cursor position. It is on by default. Turned off, the bar stays hidden except while Cloud needs you: a sync that stopped or failed, files or vault settings waiting for review, a Cloud vault that went away, or a device that was signed out. Then it comes back until that is dealt with, so nothing that needs an answer goes silent. On Android the bar is the Cloud row at the bottom of the screen, and turning it off gives that row back to the note; the iPhone already shows that row only while Cloud needs you. Toggle it from the command palette (Toggle Status Bar) or, in Vim mode, with `:set nostatusbar` and `:set statusbar`. The preference is saved as [appearance].show_status_bar in config.toml.' }, { label: 'Keep running in the tray', detail: 'Off by default. Under Settings → Appearance → Window, turn it on and closing the last window hides it instead of ending the session: a ZenNotes icon in the system tray (the menu bar on macOS) stays, Cloud sync and the Quick capture hotkey keep working, and the icon\'s tooltip and menu show the sync state while a Cloud vault is linked. Click the icon, or choose Open ZenNotes in its menu, to bring the window back; on macOS the Dock icon does too. Quit from the icon\'s menu, with Quit ZenNotes in the command palette, or with Cmd+Q on macOS. On a desktop with no tray host the icon does not appear: open ZenNotes again and the window comes back. `Space b` (Hide Window to Tray in the palette) hides the window on purpose, which a tiling window manager cannot do with minimise. The setting belongs to this computer and is not part of config.toml. While ZenNotes runs it also checks for updates every six hours, not only at launch.' }, { label: 'Dark sidebar', detail: 'Tint the sidebar slightly darker than the canvas so the chrome reads as a distinct surface.' }, { label: 'Sidebar arrows', detail: 'Show or hide disclosure arrows for collapsible sidebar folders and sections.' }, diff --git a/packages/app-core/src/lib/status-bar-visibility.test.ts b/packages/app-core/src/lib/status-bar-visibility.test.ts new file mode 100644 index 000000000..507364436 --- /dev/null +++ b/packages/app-core/src/lib/status-bar-visibility.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { statusBarVisible } from './status-bar-visibility' + +const base = { + zenMode: false, + hasNote: true, + cloudShown: true, + showStatusBar: true, + cloudNeedsUser: false +} + +describe('statusBarVisible', () => { + it('shows by default over a note or a Cloud status', () => { + expect(statusBarVisible(base)).toBe(true) + expect(statusBarVisible({ ...base, hasNote: false })).toBe(true) + expect(statusBarVisible({ ...base, cloudShown: false })).toBe(true) + }) + + it('has nothing to say with no note and no Cloud', () => { + expect(statusBarVisible({ ...base, hasNote: false, cloudShown: false })).toBe(false) + }) + + it('turned off, it stays away until Cloud needs the user', () => { + expect(statusBarVisible({ ...base, showStatusBar: false })).toBe(false) + expect(statusBarVisible({ ...base, showStatusBar: false, cloudNeedsUser: true })).toBe(true) + expect( + statusBarVisible({ ...base, showStatusBar: false, cloudNeedsUser: true, hasNote: false }) + ).toBe(true) + }) + + it('Zen mode clears it whatever the setting or Cloud says', () => { + expect(statusBarVisible({ ...base, zenMode: true })).toBe(false) + expect(statusBarVisible({ ...base, zenMode: true, cloudNeedsUser: true })).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/status-bar-visibility.ts b/packages/app-core/src/lib/status-bar-visibility.ts new file mode 100644 index 000000000..e71c0be74 --- /dev/null +++ b/packages/app-core/src/lib/status-bar-visibility.ts @@ -0,0 +1,26 @@ +/** + * Whether the status bar along the bottom of the workspace is drawn. + * + * Zen mode always clears it. Otherwise it needs something to say: a note's + * stats, or a Cloud status. Turned off (Settings, Appearance, Chrome, Status + * bar; `show_status_bar` in config.toml), it stays away except while Cloud + * needs the person (`cloudSyncNeedsUser`): a failed or stopped run, files or + * the settings question waiting, a vault that went away, a device signed out. + * Those come back until they are dealt with, so turning the bar off never + * hides the one place that says sync has stopped. + * + * Phones (Piotr, on Discord) mostly see the Cloud half of the bar, and the + * bar not being drawn is what gives its row back: the shells size their Cloud + * row and lift the circle from the bar's `data-cloud-sync-*` hooks. + */ +export function statusBarVisible(input: { + zenMode: boolean + hasNote: boolean + cloudShown: boolean + showStatusBar: boolean + cloudNeedsUser: boolean +}): boolean { + if (input.zenMode) return false + if (!input.hasNote && !input.cloudShown) return false + return input.showStatusBar || input.cloudNeedsUser +} diff --git a/packages/app-core/src/lib/vim-status-bar-option.test.ts b/packages/app-core/src/lib/vim-status-bar-option.test.ts new file mode 100644 index 000000000..0b40d6990 --- /dev/null +++ b/packages/app-core/src/lib/vim-status-bar-option.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment jsdom + +import { Vim } from '@replit/codemirror-vim' +import { beforeEach, describe, expect, it } from 'vitest' +import { useStore } from '../store' +import { defineStatusBarOption } from './vim-status-bar-option' + +beforeEach(() => { + useStore.setState({ showStatusBar: true }) +}) + +describe(':set statusbar', () => { + it('leaves a saved choice alone when the option is defined at launch', () => { + useStore.setState({ showStatusBar: false }) + defineStatusBarOption() + expect(useStore.getState().showStatusBar).toBe(false) + }) + + it('sets, clears and reports the setting', () => { + defineStatusBarOption() + Vim.setOption('statusbar', false) + expect(useStore.getState().showStatusBar).toBe(false) + expect(Vim.getOption('statusbar')).toBe(false) + Vim.setOption('stb', true) + expect(useStore.getState().showStatusBar).toBe(true) + expect(Vim.getOption('statusbar')).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/vim-status-bar-option.ts b/packages/app-core/src/lib/vim-status-bar-option.ts new file mode 100644 index 000000000..0cfae0815 --- /dev/null +++ b/packages/app-core/src/lib/vim-status-bar-option.ts @@ -0,0 +1,20 @@ +import { Vim } from '@replit/codemirror-vim' +import { useStore } from '../store' + +/** + * `:set statusbar` / `:set nostatusbar` / `:set statusbar?` (alias `stb`), the + * ex twin of Settings, Appearance, Chrome, Status bar, under the `:set` a Vim + * user reaches for. The palette command (`:view_status_bar`) toggles it too. + * + * No default value: codemirror-vim applies a truthy default through the + * callback the moment the option is defined, which turned the bar back on at + * every launch over a saved `show_status_bar = false`. + */ +export function defineStatusBarOption(): void { + Vim.defineOption('statusbar', undefined, 'boolean', ['stb'], (value?: boolean) => { + const state = useStore.getState() + if (value === undefined) return state.showStatusBar + if (state.showStatusBar !== !!value) state.setShowStatusBar(!!value) + return undefined + }) +} diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index f97fe45ae..e174c28e0 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -698,6 +698,9 @@ interface Prefs { /** Tint the sidebar surface a step darker than the main canvas. */ darkSidebar: boolean showWindowTitleBar: boolean + /** Show the bar along the bottom of the workspace (Cloud sync status and the + * note's stats). Off, it still shows while Cloud needs the user. */ + showStatusBar: boolean /** Show disclosure arrows for collapsible sidebar folders and sections. */ showSidebarChevrons: boolean /** Keys of collapsed folders in the sidebar tree. */ @@ -1193,6 +1196,7 @@ export const DEFAULT_PREFS: Prefs = { unifiedSidebar: true, darkSidebar: true, showWindowTitleBar: true, + showStatusBar: true, showSidebarChevrons: true, collapsedFolders: [], pinnedRefPath: null, @@ -1475,6 +1479,8 @@ function normalizePrefs(p: Partial): Prefs { typeof p.showWindowTitleBar === 'boolean' ? p.showWindowTitleBar : DEFAULT_PREFS.showWindowTitleBar, + showStatusBar: + typeof p.showStatusBar === 'boolean' ? p.showStatusBar : DEFAULT_PREFS.showStatusBar, darkSidebar: typeof p.darkSidebar === 'boolean' ? p.darkSidebar @@ -2521,6 +2527,7 @@ function collectPrefs(s: { unifiedSidebar: boolean darkSidebar: boolean showWindowTitleBar: boolean + showStatusBar: boolean showSidebarChevrons: boolean collapsedFolders: string[] pinnedRefPath: string | null @@ -2639,6 +2646,7 @@ function collectPrefs(s: { unifiedSidebar: s.unifiedSidebar, darkSidebar: s.darkSidebar, showWindowTitleBar: s.showWindowTitleBar, + showStatusBar: s.showStatusBar, showSidebarChevrons: s.showSidebarChevrons, collapsedFolders: s.collapsedFolders, pinnedRefPath: s.pinnedRefPath, @@ -3222,6 +3230,7 @@ interface Store { unifiedSidebar: boolean darkSidebar: boolean showWindowTitleBar: boolean + showStatusBar: boolean showSidebarChevrons: boolean /** Manual (drag-to-reorder) note order for `noteSortOrder: 'manual'`, keyed * by parent directory → ordered note paths. Persisted per vault (#224). */ @@ -3874,6 +3883,7 @@ interface Store { setUnifiedSidebar: (on: boolean) => void setDarkSidebar: (on: boolean) => void setShowWindowTitleBar: (on: boolean) => void + setShowStatusBar: (on: boolean) => void setShowSidebarChevrons: (on: boolean) => void toggleCollapseFolder: (key: string) => void setCollapsedFolders: (keys: string[]) => void @@ -6148,6 +6158,7 @@ export const useStore = create((set, get) => { unifiedSidebar: loadPrefs().unifiedSidebar, darkSidebar: loadPrefs().darkSidebar, showWindowTitleBar: loadPrefs().showWindowTitleBar, + showStatusBar: loadPrefs().showStatusBar, showSidebarChevrons: loadPrefs().showSidebarChevrons, collapsedFolders: DEFAULT_PREFS.collapsedFolders, pinnedRefPath: loadPrefs().pinnedRefPath, @@ -9780,6 +9791,10 @@ export const useStore = create((set, get) => { set({ showWindowTitleBar: on }) savePrefs(collectPrefs(get())) }, + setShowStatusBar: (on) => { + set({ showStatusBar: on }) + savePrefs(collectPrefs(get())) + }, setDarkSidebar: (on) => { set({ darkSidebar: on }) savePrefs(collectPrefs(get())) diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 01845a0bd..e73ee1379 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -73,6 +73,7 @@ export const PORTABLE_PREF_KEYS = [ 'themeTweaks', 'darkSidebar', 'showWindowTitleBar', + 'showStatusBar', 'showSidebarChevrons', 'contentAlign', 'noteCoverPlacement', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index c7963a24a..c61502451 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -142,6 +142,7 @@ export const PORTABLE_DEFAULTS: Record = { themeTweaks: {}, darkSidebar: true, showWindowTitleBar: true, + showStatusBar: true, showSidebarChevrons: true, contentAlign: 'center', noteCoverPlacement: 'scroll', From 00cf16212efad886b6b534ccec9b5edf10cb99c6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 09:44:26 -0500 Subject: [PATCH 07/34] Fix(palette): a toggle from the command palette leaves a phone's keyboard down Found while verifying 42865ca7: on a phone, Toggle Status Bar from the command palette raised the keyboard. Every command that lands on the editor is followed by focusEditorNormalMode, so any setting or view toggle run from the palette (Light Sidebar, Line Numbers, Word Wrap, Live Preview, a theme) put the soft keyboard over half the note, with nothing to type. Two commands also focused the editor themselves: the four fold commands (runFoldCommand) and Copy Link Under Cursor. The palette now asks where the keyboard should go (lib/command-landing.ts), with the editor host's `openNotesToRead`, the same question the Search picks and palette closes ask. The rule defaults to leaving the focus where it was before the palette opened, and names the commands that should give the editor the keyboard, so a toggle added later cannot raise a phone's keyboard by being forgotten. On desktop every answer focuses the editor, exactly as before. On a phone, after a command run from the palette: - read, the note is left to read, keyboard down (e443b86f, unchanged): nav.back, nav.forward, nav.toggle-recent, tab.reopen, tab.next, tab.previous, tab.close, and note.daily.today, note.weekly.thisWeek, note.monthly.thisMonth and note.daily.rollover when that note already exists. - edit, the editor takes the keyboard, because typing or the focus is the point: note.new.quick, note.new.inbox, note.new.here, task.new, task.new.folder, database.new, drawing.new, embed.drawing.new; the four periodic-note commands when they create the note; template.create and template.insert; note.format (Format Markdown), editor.reflow-paragraph and editor.add-comment; nav.focus.editor and ref.focus; split.right, split.down and the four pane.focus commands. - stay, the focus goes back where it was: everything else. That covers every View, UI and App command (sidebar, status bar, line numbers, sort and group, panels, Tasks, Tags, Atlas, zoom, themes, Settings); the Editor toggles (word wrap, live preview, completed-task style, which-key hints, tabs, keep panels, undo history, auto reveal, quick note dates, PDF embeds); task state, folds, copies and exports; archive, trash, rename, favorite and cover; vault, Cloud, CLI and workflow commands. Enable and Disable Vim Mode stay too: the mode applies the next time the editor has the focus, and on a phone that is a tap into the text, with a hardware keyboard or without. The fold commands and Copy Link Under Cursor no longer focus the editor themselves on such a host. What it deliberately does not do: change desktop, the web app, or the commands a phone's own menu runs through runAppCommand (those never handed the focus over). Tests: commandLanding (an existing note is read even for a writing id, the edit list takes the keyboard, toggles and an unknown future command stay); every id on the edit list is a real command; the palette hands a toggle back through the close hand-off and still focuses for a writing command. app-core 3,641, shared-domain 2,164 and desktop 1,198 pass; typecheck clean (8/8). In the built app over CDP with isolated stores, on 2.78.0 and on this build, Light Sidebar, Line Numbers: Relative, Disable Word Wrap, Completed Tasks: Strikethrough, Fold All Headings and Lists, Disable Live Preview and Format Markdown each leave the editor focused on both (Toggle Status Bar too, on this build). --- packages/app-core/README.md | 9 +-- .../src/components/CommandPalette.tsx | 15 +++-- .../components/PaletteFocusHandoff.test.ts | 19 +++++- packages/app-core/src/editor.ts | 16 ++--- .../app-core/src/lib/command-landing.test.ts | 35 +++++++++++ packages/app-core/src/lib/command-landing.ts | 61 +++++++++++++++++++ packages/app-core/src/lib/commands.test.ts | 9 +++ packages/app-core/src/lib/commands.ts | 7 ++- 8 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 packages/app-core/src/lib/command-landing.test.ts create mode 100644 packages/app-core/src/lib/command-landing.ts diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 3f3e40dd2..4e9fa55ed 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -348,10 +348,11 @@ each time a command-palette command that only goes to a note already there finishes (Go Back, Go Forward, Switch to Previous Note, the tab commands, and today's daily, weekly or monthly note when it exists). When it returns true the note opens without taking the keyboard focus, so a soft keyboard stays down -until the user taps into the text. Closing any of those palettes, or the -template or drawing palette, without a pick leaves the focus where it was. -Commands that create a note to write in keep focusing the editor. Absent, -false or throwing, the editor takes the focus as on desktop. +until the user taps into the text. Commands whose point is typing (a new note, +Format Markdown, Reflow Paragraph) keep focusing the editor; every other +command (a toggle, a view, a theme), and closing any of those palettes or the +template or drawing palette without a pick, leaves the focus where it was. +Absent, false or throwing, the editor takes the focus as on desktop. Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) only on lines the user is editing: once an editor has taken the focus since its diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index daa250d25..066393582 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -6,6 +6,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { buildCommands, type Command } from '../lib/commands' +import { commandLanding } from '../lib/command-landing' import { loadRecentCommandIds, recordCommandUse, @@ -309,7 +310,7 @@ export function CommandPalette(): JSX.Element { } setOpen(false) // Asked before the run: afterwards today's note exists either way. - const toRead = cmd.opensToRead?.() === true + const landing = commandLanding(cmd) try { await cmd.run() // A command that opens a note (or otherwise lands on the editor) should @@ -322,8 +323,11 @@ export function CommandPalette(): JSX.Element { // outline, …) so we don't pull focus behind it, and likewise for the Cloud // conflict queue, the Cloud settings prompt and the Publish Note dialog, // which claim focus themselves and are tracked outside the store. - // A command that only went to a note that was already there hands over - // like a search pick: on a phone the note is left to read. + // On a phone the command decides where the keyboard goes + // (lib/command-landing.ts): a note that was already there is left to + // read, a command whose point is typing takes the editor, and anything + // else (a toggle, a view) leaves the focus where it was. On desktop all + // three focus the editor. if ( shouldRefocusEditorAfterCommand( useStore.getState(), @@ -332,8 +336,9 @@ export function CommandPalette(): JSX.Element { getPublishNoteRequest() !== null ) ) { - if (toRead) focusEditorAfterReadOpen() - else focusEditorNormalMode() + if (landing === 'read') focusEditorAfterReadOpen() + else if (landing === 'edit') focusEditorNormalMode() + else focusEditorAfterPaletteClose() } } catch (err) { console.error('command failed', cmd.id, err) diff --git a/packages/app-core/src/components/PaletteFocusHandoff.test.ts b/packages/app-core/src/components/PaletteFocusHandoff.test.ts index c762cf51a..8aedc3cf4 100644 --- a/packages/app-core/src/components/PaletteFocusHandoff.test.ts +++ b/packages/app-core/src/components/PaletteFocusHandoff.test.ts @@ -19,10 +19,11 @@ vi.mock('../lib/editor-focus', () => ({ focusEditorAfterPaletteClose })) -// The command palette lists whatever buildCommands gives it: two stand-ins, -// one that goes to a note already there and one that writes. +// The command palette lists whatever buildCommands gives it: stand-ins for a +// command that goes to a note already there, one that writes, and a toggle. const goBack = vi.hoisted(() => vi.fn()) const newNote = vi.hoisted(() => vi.fn()) +const toggle = vi.hoisted(() => vi.fn()) vi.mock('../lib/commands', () => ({ buildCommands: () => [ { @@ -32,7 +33,8 @@ vi.mock('../lib/commands', () => ({ run: goBack, opensToRead: () => true }, - { id: 'test.write', title: 'Write Something', category: 'Note', run: newNote } + { id: 'note.new.inbox', title: 'Write Something', category: 'Note', run: newNote }, + { id: 'view.status-bar', title: 'Toggle Something', category: 'View', run: toggle } ] })) @@ -62,6 +64,7 @@ beforeEach(() => { } goBack.mockClear() newNote.mockClear() + toggle.mockClear() // The refocus after a command only happens when the editor was the panel // and nothing else is open over it. useStore.setState({ @@ -127,6 +130,16 @@ describe('palette hand-offs', () => { expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() }) + it('a toggle hands back like a close: focused on desktop, left where it was on a phone', async () => { + render(CommandPalette) + type('Toggle Something') + await key('Enter') + expect(toggle).toHaveBeenCalledTimes(1) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() + }) + it('the template palette closes through the palette close hand-off', async () => { useStore.setState({ templatePaletteOpen: true }) render(TemplatePalette) diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index a7b7e91fd..cc9bec92b 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -37,13 +37,15 @@ export interface EditorHostOptions { /** * Asked as a pick in one of core's searches (the note search, Search in all * notes, the outline, the buffer switcher) lands, and as a command-palette - * command that only goes to a note already there (Go Back, Reopen Closed - * Tab, today's daily note when it exists) finishes: true opens the note to - * be read, without handing the editor the keyboard focus, so a soft - * keyboard stays down until the user taps into the text. Closing one of - * those palettes, or the template or drawing palette, without a pick then - * leaves the focus where it was too. Absent or false, the editor takes the - * focus, as on desktop. + * command finishes. True opens a note to be read without handing the editor + * the keyboard focus, so a soft keyboard stays down until the user taps + * into the text: a search pick, or a command that only goes to a note + * already there (Go Back, Reopen Closed Tab, today's daily note when it + * exists). A command whose point is typing (a new note, Format Markdown) + * still focuses the editor; any other command (a toggle, a view, a theme), + * and closing one of those palettes or the template or drawing palette + * without a pick, leaves the focus where it was. Absent or false, the + * editor takes the focus after all of them, as on desktop. */ readonly openNotesToRead?: () => boolean } diff --git a/packages/app-core/src/lib/command-landing.test.ts b/packages/app-core/src/lib/command-landing.test.ts new file mode 100644 index 000000000..95582b2fa --- /dev/null +++ b/packages/app-core/src/lib/command-landing.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { commandLanding } from './command-landing' + +describe('commandLanding (where a phone keyboard goes after a palette command)', () => { + it('reads a note the command only put on screen, before anything else', () => { + expect(commandLanding({ id: 'nav.back', opensToRead: () => true })).toBe('read') + // An existing daily note is read even though the id is a writing one. + expect(commandLanding({ id: 'note.daily.today', opensToRead: () => true })).toBe('read') + }) + + it('gives the editor the keyboard where typing or the focus is the point', () => { + expect(commandLanding({ id: 'note.daily.today', opensToRead: () => false })).toBe('edit') + const writing = ['note.new.inbox', 'task.new', 'note.format', 'editor.reflow-paragraph'] + for (const id of [...writing, 'nav.focus.editor', 'ref.focus']) { + expect(commandLanding({ id })).toBe('edit') + } + }) + + it('leaves the focus where it was for everything else, a new toggle included', () => { + const toggles = [ + 'view.status-bar', + 'view.dark-sidebar', + 'view.line-numbers.relative', + 'editor.word-wrap.toggle', + 'editor.vim.toggle', + 'editor.live-preview.toggle', + 'fold.all', + 'task.toggle-checkbox', + 'note.copy-wikilink', + 'ui.themes', + 'some.future-toggle' + ] + for (const id of toggles) expect(commandLanding({ id })).toBe('stay') + }) +}) diff --git a/packages/app-core/src/lib/command-landing.ts b/packages/app-core/src/lib/command-landing.ts new file mode 100644 index 000000000..d9478f35a --- /dev/null +++ b/packages/app-core/src/lib/command-landing.ts @@ -0,0 +1,61 @@ +/** + * Where the keyboard goes after a command-palette command, on a host that + * opens notes to read (a phone; the editor host's `openNotesToRead`). + * + * - `read`: the command only put a note that already existed on screen + * (`Command.opensToRead`): it is left to read, keyboard down. + * - `edit`: typing, or the editor's own focus, is the point of the command: + * the editor takes the focus and the keyboard comes up, as on desktop. + * - `stay`: everything else (a toggle, a view, a sort, a theme, a panel, a + * fold, task state, copy and export): the focus goes back where it was + * before the palette opened, so a keyboard that was down stays down. + * + * The rule defaults to `stay` and names the `edit` commands, so a new toggle + * cannot raise a phone's keyboard by being forgotten. Desktop ignores all of + * it: every command that lands on the editor focuses it, as it always has + * (the three hand-offs in lib/editor-focus.ts all focus the editor there). + */ +export type CommandLanding = 'read' | 'edit' | 'stay' + +/** The commands whose point is typing or the editor's focus. */ +export const EDITOR_FOCUS_COMMAND_IDS: ReadonlySet = new Set([ + // A new note, task, database or drawing is made to be written in. + 'note.new.quick', + 'note.new.inbox', + 'note.new.here', + 'task.new', + 'task.new.folder', + 'database.new', + 'drawing.new', + 'embed.drawing.new', + // Daily, weekly and monthly notes the command creates (one that already + // exists answers `opensToRead` first and is read). + 'note.daily.today', + 'note.daily.rollover', + 'note.weekly.thisWeek', + 'note.monthly.thisMonth', + // Writing into the open note: templates, formatting, a comment. + 'template.create', + 'template.insert', + 'note.format', + 'editor.reflow-paragraph', + 'editor.add-comment', + // Commands whose point is where the keyboard focus goes. + 'nav.focus.editor', + 'ref.focus', + 'split.right', + 'split.down', + 'pane.focus.left', + 'pane.focus.down', + 'pane.focus.up', + 'pane.focus.right' +]) + +/** Asked just before the command runs (afterwards a daily note exists either way). */ +export function commandLanding(command: { + id: string + opensToRead?: () => boolean +}): CommandLanding { + if (command.opensToRead?.() === true) return 'read' + return EDITOR_FOCUS_COMMAND_IDS.has(command.id) ? 'edit' : 'stay' +} diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 7b909846b..137bb6843 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -608,3 +608,12 @@ describe('Toggle Status Bar (Piotr on Discord)', () => { expect(useStore.getState().showStatusBar).toBe(true) }) }) + +describe('commands that give a phone editor the keyboard', () => { + it('names only commands that exist', async () => { + const { buildCommands } = await loadCommands() + const { EDITOR_FOCUS_COMMAND_IDS } = await import('./command-landing') + const ids = new Set(buildCommands({ includeUnavailable: true }).map((c) => c.id)) + for (const id of EDITOR_FOCUS_COMMAND_IDS) expect(ids.has(id), id).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 6deeb806b..1c5ebf654 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -18,6 +18,7 @@ import { openLinkPopoverAtCursor } from './link-popover-open' import { confirmApp } from './confirm-requests' import { requestSettingsTarget } from './settings-navigation' import { hideWindowToTray, useTrayStore } from './tray-runtime' +import { hostOpensNotesToRead } from './editor-host' import { promptApp } from './prompt-requests' import { captureNavigationContext } from './navigation-context' import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from './move-note' @@ -95,7 +96,8 @@ async function runFoldCommand(command: FoldCommand): Promise { const folding = await import('./cm-heading-fold') folding[command](view) } - view.focus() + // A fold is a view of the note, not typing: a phone keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() } export interface Command { @@ -1201,7 +1203,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const view = getState().editorViewRef if (!view) return copyLinkAtCursor(view) - view.focus() + // Copying is not typing: a phone keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() } }, { From 5d0d78e60b4b46aca547006aec70110e98e17e78 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 10:13:26 -0500 Subject: [PATCH 08/34] Fix(quick notes): a cleared prefix gives the bare timestamp Settings promises (#1008) davi-jorge-art cleared the Quick Note prefix in Settings, on Windows and on Android, and new quick notes still came out as "Quick Note 2026-10-24 1658". The field promises "Leave blank for a bare timestamp or date". The blank value was saved all along. The store keeps a cleared field as null, config.toml writes it as quick_note_title_prefix = "" and reads it back as null, and the field is still empty after a restart. The title builder lost it: resolveQuickNoteTitle took the prefix as an optional argument that defaulted to "Quick Note", and all four callers (the sidebar +, Shift+Cmd+N, New Quick Note in the palette, n in the Quick Notes view) passed `prefix ?? undefined`. null became undefined and the default came back. The prefix argument is now required and typed string | null, null meaning no prefix, so a caller can no longer leave it out and pick up the old default. The field also looked as if the default were still set. Once it was empty its placeholder showed "Quick Note" in grey, which is what the reporter read as the field going back to "Quick Notes". It now reads "No prefix". And the field could not take a two-word prefix typed key by key, including the default "Quick Note": live Settings rows commit the trimmed text on every keystroke and drew the stored copy back into the field, so a typed space was gone at once. TextInputRow now shows what was typed while it has focus and the stored value otherwise. Rows still commit on every keystroke, so a phone that leaves Settings without a blur keeps the edit. The same fix lets the Inbox, Quick Notes, Archive and Trash label rows take a space. The phones and the web app build quick-note titles in this core code, so they pick the fix up with it. Quick Capture and the Share menu title a note from its first line and are left alone, as are the MCP server and zn, which never use the prefix. The help text already promised a bare timestamp or date and is now true. Tests: resolveQuickNoteTitle (null, empty and blank prefixes give the bare timestamp or date, date titles still number " (2)"); New Quick Note after the setter clears the prefix (fails before this change); the Settings field keeps a typed space, says "No prefix" and saves an empty field as null; config.toml round-trips a null prefix as "". In the built app over CDP with both stores isolated: before the fix, clearing the field and pressing the Quick Notes + wrote "Quick Note 2026-10-28 1005.md", and Shift+Cmd+N after a relaunch wrote "Quick Note 2026-10-28 1005 2.md". After it, the same steps wrote "2026-10-28 1011.md" and "2026-10-28 1011 2.md" with matching headings, the field stayed empty with "No prefix" across the relaunch, config.toml kept quick_note_title_prefix = "", and "My Notes" typed key by key kept its space. --- apps/desktop/src/main/app-config.test.ts | 10 ++++- packages/app-core/src/App.tsx | 2 +- .../src/components/QuickNotesView.tsx | 2 +- .../src/components/SettingsModal.test.ts | 35 ++++++++++++++++- .../app-core/src/components/SettingsModal.tsx | 26 +++++++------ packages/app-core/src/components/Sidebar.tsx | 2 +- packages/app-core/src/lib/commands.test.ts | 24 ++++++++++++ packages/app-core/src/lib/commands.ts | 2 +- .../app-core/src/lib/quick-note-title.test.ts | 39 +++++++++++++++++++ packages/app-core/src/lib/quick-note-title.ts | 17 ++++---- packages/app-core/src/store.ts | 3 +- 11 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 packages/app-core/src/lib/quick-note-title.test.ts diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index d77de0fb8..e34e0be55 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -181,11 +181,19 @@ describe('TOML serialization', () => { }) it('persists null as empty string and reads it back as null', () => { - const text = serializeConfig({ ripgrepBinaryPath: null, fzfBinaryPath: null, monoFont: null }) + const text = serializeConfig({ + ripgrepBinaryPath: null, + fzfBinaryPath: null, + monoFont: null, + quickNoteTitlePrefix: null + }) + expect(text).toContain('quick_note_title_prefix = ""') const { portable } = deserializeConfig(text) expect(portable.ripgrepBinaryPath).toBeNull() expect(portable.fzfBinaryPath).toBeNull() expect(portable.monoFont).toBeNull() + // A cleared Quick Note prefix stays cleared across machines (#1008). + expect(portable.quickNoteTitlePrefix).toBeNull() }) it('always lists every option with allowed-value comments, even from empty input', () => { diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 9f9ad5fa4..6b881ec3a 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -825,7 +825,7 @@ function App(): JSX.Element { const title = resolveQuickNoteTitle( state.notes, state.quickNoteDateTitle, - state.quickNoteTitlePrefix ?? undefined + state.quickNoteTitlePrefix ) void state.createAndOpen('quick', '', { title, focusTitle: true }) return diff --git a/packages/app-core/src/components/QuickNotesView.tsx b/packages/app-core/src/components/QuickNotesView.tsx index 221e026be..4ebf09757 100644 --- a/packages/app-core/src/components/QuickNotesView.tsx +++ b/packages/app-core/src/components/QuickNotesView.tsx @@ -91,7 +91,7 @@ export function QuickNotesView(): JSX.Element { const title = resolveQuickNoteTitle( useStore.getState().notes, quickNoteDateTitle, - quickNoteTitlePrefix ?? undefined + quickNoteTitlePrefix ) await createAndOpen('quick', '', { title, focusTitle: true }) }, [createAndOpen, quickNoteDateTitle, quickNoteTitlePrefix]) diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 795e849b3..678055686 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -57,7 +57,8 @@ const mocks = vi.hoisted(() => { mathRenderer: "katex" as "katex" | "typst", monoFont: null, previewMaxWidth: 760, - quickNoteTitlePrefix: null, + quickNoteTitlePrefix: null as string | null, + setQuickNoteTitlePrefix: vi.fn(), remoteWorkspaceInfo: null as null | { mode: string; baseUrl: string | null; authConfigured: boolean; capabilities: Record | null; profileId: string | null; bootError: string | null }, remoteWorkspaceProfiles: [], ripgrepBinaryPath: null, @@ -571,6 +572,38 @@ describe("SettingsModal date note directories", () => { }); }); + it("keeps a typed space in the Quick Note prefix and saves a cleared field as no prefix (#1008)", async () => { + mocks.state.quickNoteTitlePrefix = "Quick Note"; + await act(async () => root.render(createElement(SettingsModal))); + expect(await openSearchHit("quick note prefix", "Quick Note prefix")).toBe("quick-note-prefix"); + const input = host.querySelector( + '[data-settings-search-id="quick-note-prefix"] input', + )!; + expect(input.value).toBe("Quick Note"); + // A blank field shows what blank means, not the old default in grey. + expect(input.placeholder).toBe("No prefix"); + + await act(async () => { + input.focus(); + input.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + }); + await act(async () => changeInput(input, "My ")); + expect(mocks.state.setQuickNoteTitlePrefix).toHaveBeenLastCalledWith("My"); + expect(input.value).toBe("My "); + await act(async () => changeInput(input, "My Notes")); + expect(mocks.state.setQuickNoteTitlePrefix).toHaveBeenLastCalledWith("My Notes"); + + await act(async () => changeInput(input, "")); + expect(mocks.state.setQuickNoteTitlePrefix).toHaveBeenLastCalledWith(null); + expect(input.value).toBe(""); + + // Leaving the field shows the stored value again (this mock store never + // takes the writes, so it still holds "Quick Note"). + await act(async () => blurInput(input)); + expect(input.value).toBe("Quick Note"); + mocks.state.quickNoteTitlePrefix = null; + }); + it("opens the text replacements tab and saves edited rules", async () => { await act(async () => { root.render(createElement(SettingsModal)); diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 98b1833d9..455b746ef 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -3228,11 +3228,14 @@ export function SettingsModal(): JSX.Element { settingId="date-titled-quick-notes" onChange={setQuickNoteDateTitle} /> + {/* A cleared field means no prefix, so the placeholder says so + rather than showing the old default in grey, which read as + if "Quick Note" were still set (#1008). */} @@ -7337,9 +7340,14 @@ function TextInputRow({ const [draft, setDraft] = useState(value); const [focused, setFocused] = useState(false); + // The field shows what was typed while it has focus, and the stored value + // otherwise. A live row commits the trimmed text on every keystroke; drawing + // the stored copy back into the field dropped a space the moment it was + // typed, so a two-word value such as "Quick Note" could not be typed key by + // key (#1008). useEffect(() => { - if (!commitOnBlur || !focused) setDraft(value); - }, [commitOnBlur, focused, value]); + if (!focused) setDraft(value); + }, [focused, value]); const commit = (raw: string): void => { const next = raw.trim(); @@ -7373,19 +7381,15 @@ function TextInputRow({ )}
setFocused(true)} onChange={(e) => { - if (commitOnBlur) { - setDraft(e.target.value); - return; - } - commit(e.target.value); + setDraft(e.target.value); + if (!commitOnBlur) commit(e.target.value); }} onBlur={(e) => { - if (!commitOnBlur) return; setFocused(false); - commit(e.target.value); + if (commitOnBlur) commit(e.target.value); }} onKeyDown={(e) => { if (!commitOnBlur) return; diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 15bc37c37..628f0a5d4 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -3441,7 +3441,7 @@ export function Sidebar(): JSX.Element { const title = resolveQuickNoteTitle( notes, quickNoteDateTitle, - quickNoteTitlePrefix ?? undefined, + quickNoteTitlePrefix, ); void createAndOpen("quick", "", { title, focusTitle: true }); }} diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 1a8970384..4024eb032 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -374,6 +374,30 @@ describe('New Note in Current Folder (#403)', () => { }) }) +describe('New Quick Note title (#1008)', () => { + it('titles with the bare timestamp after the prefix field is cleared', async () => { + const { buildCommands, useStore } = await loadCommands() + const createAndOpen = vi.fn() + useStore.setState({ notes: [], quickNoteDateTitle: false, createAndOpen }) + // The Settings field calls the setter with null once it is empty. + useStore.getState().setQuickNoteTitlePrefix(null) + expect(useStore.getState().quickNoteTitlePrefix).toBeNull() + buildCommands().find((c) => c.id === 'note.new.quick')?.run() + const title = createAndOpen.mock.calls[0]?.[2]?.title as string + expect(title).toMatch(/^\d{4}-\d{2}-\d{2} \d{4}$/) + }) + + it('keeps the prefix the field holds', async () => { + const { buildCommands, useStore } = await loadCommands() + const createAndOpen = vi.fn() + useStore.setState({ notes: [], quickNoteDateTitle: true, createAndOpen }) + useStore.getState().setQuickNoteTitlePrefix(' Scratch ') + buildCommands().find((c) => c.id === 'note.new.quick')?.run() + const title = createAndOpen.mock.calls[0]?.[2]?.title as string + expect(title).toMatch(/^Scratch \d{4}-\d{2}-\d{2}$/) + }) +}) + describe('note commands for a trashed note (#712)', () => { const note = { path: 'trash/Old idea.md', diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 6b16eb240..11c67349a 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -152,7 +152,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const title = resolveQuickNoteTitle( s.notes, s.quickNoteDateTitle, - s.quickNoteTitlePrefix ?? undefined + s.quickNoteTitlePrefix ) return s.createAndOpen('quick', '', { title, focusTitle: true }) } diff --git a/packages/app-core/src/lib/quick-note-title.test.ts b/packages/app-core/src/lib/quick-note-title.test.ts new file mode 100644 index 000000000..41ecf3b3b --- /dev/null +++ b/packages/app-core/src/lib/quick-note-title.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { resolveQuickNoteTitle } from './quick-note-title' + +const at = new Date(2026, 9, 28, 9, 5) + +function quick(title: string): NoteMeta { + return { path: `quick/${title}.md`, title, folder: 'quick' } as NoteMeta +} + +describe('resolveQuickNoteTitle', () => { + it('puts the prefix in front of the timestamp', () => { + expect(resolveQuickNoteTitle([], false, 'Quick Note', at)).toBe('Quick Note 2026-10-28 0905') + expect(resolveQuickNoteTitle([], false, 'Scratch pad', at)).toBe('Scratch pad 2026-10-28 0905') + }) + + // A cleared Settings field is stored as null; it must not come back as the + // old "Quick Note" default (#1008). + it('titles with the bare timestamp when the prefix was cleared', () => { + expect(resolveQuickNoteTitle([], false, null, at)).toBe('2026-10-28 0905') + expect(resolveQuickNoteTitle([], false, '', at)).toBe('2026-10-28 0905') + expect(resolveQuickNoteTitle([], false, ' ', at)).toBe('2026-10-28 0905') + }) + + it('titles with the bare date when date titles are on and the prefix was cleared', () => { + expect(resolveQuickNoteTitle([], true, null, at)).toBe('2026-10-28') + expect(resolveQuickNoteTitle([quick('2026-10-28')], true, null, at)).toBe('2026-10-28 (2)') + expect( + resolveQuickNoteTitle([quick('2026-10-28'), quick('2026-10-28 (2)')], true, null, at) + ).toBe('2026-10-28 (3)') + }) + + it('numbers a prefixed date title the same way', () => { + expect(resolveQuickNoteTitle([], true, 'Quick Note', at)).toBe('Quick Note 2026-10-28') + expect(resolveQuickNoteTitle([quick('Quick Note 2026-10-28')], true, 'Quick Note', at)).toBe( + 'Quick Note 2026-10-28 (2)' + ) + }) +}) diff --git a/packages/app-core/src/lib/quick-note-title.ts b/packages/app-core/src/lib/quick-note-title.ts index f0ba69d27..635dc7277 100644 --- a/packages/app-core/src/lib/quick-note-title.ts +++ b/packages/app-core/src/lib/quick-note-title.ts @@ -8,6 +8,12 @@ * with " (2)", " (3)", … appended for additional notes the same day. * * When the prefix is blank, titles fall back to the raw timestamp/date. + * + * The prefix has no default, and `null` means no prefix: it is how the store + * keeps a field the user cleared. It used to default to "Quick Note" while + * every caller passed `prefix ?? undefined`, so a cleared field became the + * default again on its way in and the bare timestamp Settings promised never + * appeared (#1008). */ import type { NoteMeta } from '@shared/ipc' @@ -15,7 +21,7 @@ function pad(n: number): string { return n.toString().padStart(2, '0') } -function normalizePrefix(prefix: string | null | undefined): string { +function normalizePrefix(prefix: string | null): string { return (prefix ?? '').trim() } @@ -23,10 +29,7 @@ function joinPrefix(prefix: string, suffix: string): string { return prefix ? `${prefix} ${suffix}` : suffix } -export function nowTimestamped( - prefix = 'Quick Note', - date = new Date() -): string { +export function nowTimestamped(prefix: string | null, date = new Date()): string { const stem = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + ` ${pad(date.getHours())}${pad(date.getMinutes())}` @@ -35,7 +38,7 @@ export function nowTimestamped( export function dateTitleForToday( notes: NoteMeta[], - prefix = '', + prefix: string | null, date = new Date() ): string { const today = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` @@ -52,7 +55,7 @@ export function dateTitleForToday( export function resolveQuickNoteTitle( notes: NoteMeta[], useDateTitle: boolean, - prefix = 'Quick Note', + prefix: string | null, date = new Date() ): string { return useDateTitle diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 8cfea7c29..3f4de0fde 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3230,7 +3230,8 @@ interface Store { /** Auto-title new Quick Notes to today's date instead of the * default "Quick Note " pattern. */ quickNoteDateTitle: boolean - /** Prefix used when generating new Quick Note titles. */ + /** Prefix used when generating new Quick Note titles. `null` is a + * cleared field: no prefix, a bare timestamp or date (#1008). */ quickNoteTitlePrefix: string | null /** Whether long lines wrap or scroll horizontally in the editor. */ From 63594b199680faaa195bee2762753e69de81b724 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 10:16:15 -0500 Subject: [PATCH 09/34] Fix(updates): a check while a release is still uploading says so in one sentence (#1003) A Windows 11 copy of 2.71.0 (NSIS) pressed Check for Updates at 21:14:03 UTC on October 27 and got a dialog holding electron-updater's whole error: the request, twenty response headers, a stack through app.asar, and at the end "GitHub returned a temporary network or server error". Nothing was wrong with GitHub. release.yml creates the release public and "latest" first (2.78.0 at 20:42:54) and uploads each platform's update feed after its installers, so for a while "latest" named a release without the feed a copy reads: latest-linux.yml came 12 minutes later, latest-linux-arm64.yml 15, latest-mac.yml 32, latest.yml 39 and latest-arm64.yml 50. electron-updater follows releases/latest, asks that tag for .yml, gets a 404 and throws ERR_UPDATER_CHANNEL_FILE_NOT_FOUND with the HTTP error's message and stack pasted into its own. humanizeUpdateError showed that message and picked a hint by testing the whole text. Its `5\d\d` matched the `536` in GitHub's strict-transport-security header (max-age=31536000), so every GitHub 404 read as a temporary server error, and isRetryableUpdateError, the same test, retried the 404 twice more before giving up. The notify-only check (AUR, tarball) said "GitHub answered 404 for the release feed" followed by a hint about private-repo tokens. describeUpdateError (update-error-copy.ts) now turns a failed check or download into a sentence, the original text as `detail`, a kind, and whether an instant retry makes sense. The HTTP status is read only where errors keep it: `statusCode`, the HTTP_ERROR_NNN code, "HttpError: NNN" at the head of a wrapped error, or the notify-only fetch's own status, which it now throws as ReleaseFeedHttpError with the URL GitHub redirected it to. Never from header values, request ids or line numbers. - A missing feed on the newest release, on every platform's feed, is "ZenNotes 2.79.0 is still being published. Try again in a few minutes.", the version read from the feed's URL, and is not retried. - A 5xx, a 429 or a timeout keeps the temporary-error sentence (a download gets its own wording); only the 5xx and the timeout retry. - A refused code signature on macOS, and a download that lost the network ("Check the connection and try again"), keep their hints. - Everything else is one plain sentence per action. AppUpdateState carries the original text as an optional `detail`. The About page shows it under a closed Details disclosure with Copy details and Report an Issue; that disclosure moves out of CloudErrorMessage into ErrorDetails, and Cloud errors render it as before. Main writes it to the log as `[updater] check failed ()`. The alert behind Check for Updates and the menu's native dialog show the sentence only. The in-app manual's update guide says what the new sentence means. Deliberately not here: the state stays `error`, so the badge, the background notice and the retry and recheck rules are unchanged; a new phase for "publishing" would ripple through every host. The cause, release.yml marking the release latest before its feeds exist, is a workflow change that needs its own yes (proposed: create the release with --latest=false and mark it latest once all five feeds are up). The website's docs mirror is not updated here. The 2.71.0 copy in the report keeps its old wording; this changes what copies from the next release say. Verified: desktop 1,239 tests and app-core 3,609 pass, typecheck clean. In the built app over CDP, both stores isolated, the notify-only check pointed at a local stand-in for github.com (302 from releases/latest/download, then 404, 503, and a real feed; 23 checks): the background check and Check for Updates both say "ZenNotes 2.79.0 is still being published. Try again in a few minutes." with one request and no dump, Details holds the 404 with Copy details and Report an Issue, a 503 is retried three times and gets the temporary-error sentence, and a published feed clears the details. Real electron-updater 6.8.9 (MacUpdater) against the same kind of stand-in throws ERR_UPDATER_CHANNEL_FILE_NOT_FOUND, and the classifier words it as the same sentence for Windows, macOS and Linux. --- .../src/main/update-error-copy.test.ts | 204 ++++++++++++++++ apps/desktop/src/main/update-error-copy.ts | 227 ++++++++++++++++++ apps/desktop/src/main/update-feed.ts | 13 +- apps/desktop/src/main/updater.test.ts | 171 ++++++++++++- apps/desktop/src/main/updater.ts | 68 +++--- .../src/components/CloudErrorMessage.tsx | 39 +-- .../app-core/src/components/ErrorDetails.tsx | 52 ++++ .../app-core/src/components/SettingsModal.tsx | 12 +- packages/app-core/src/lib/help.ts | 2 +- packages/bridge-contract/src/ipc.ts | 9 +- 10 files changed, 714 insertions(+), 83 deletions(-) create mode 100644 apps/desktop/src/main/update-error-copy.test.ts create mode 100644 apps/desktop/src/main/update-error-copy.ts create mode 100644 packages/app-core/src/components/ErrorDetails.tsx diff --git a/apps/desktop/src/main/update-error-copy.test.ts b/apps/desktop/src/main/update-error-copy.test.ts new file mode 100644 index 000000000..31af5855b --- /dev/null +++ b/apps/desktop/src/main/update-error-copy.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { + describeUpdateError, + ReleaseFeedHttpError, + stillPublishingMessage, + UPDATE_SIGNATURE_MESSAGE, + updateErrorStatus +} from './update-error-copy' + +/** The response headers GitHub sends with a missing release asset (HTTP/2, + * so the status line has no reason phrase). `max-age=31536000` holds the + * digit run `536`, which the old `5\d\d` test read as a server error. */ +const GITHUB_404_HEADERS = `{ + "date": "Tue, 27 Oct 2026 21:14:03 GMT", + "content-type": "text/plain; charset=utf-8", + "vary": "X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame, X-Requested-With, X-GitHub-Client-Version, Sec-Fetch-Site,Accept-Encoding, Accept, X-Requested-With", + "cache-control": "no-cache", + "strict-transport-security": "max-age=31536000; includeSubdomains; preload", + "x-frame-options": "deny", + "x-content-type-options": "nosniff", + "x-xss-protection": "0", + "referrer-policy": "no-referrer-when-downgrade", + "server": "github.com", + "content-length": "9", + "x-github-request-id": "D35F:97851:C4D42C:115D4E7:6AE20ED5" +}` + +const STACK = ` at createHttpError (C:\\Users\\me\\AppData\\Local\\Programs\\ZenNotes\\resources\\app.asar\\node_modules\\builder-util-runtime\\out\\httpExecutor.js:53:12) + at ElectronHttpExecutor.handleResponse (C:\\Users\\me\\AppData\\Local\\Programs\\ZenNotes\\resources\\app.asar\\node_modules\\builder-util-runtime\\out\\httpExecutor.js:155:20) + at ClientRequest. (C:\\Users\\me\\AppData\\Local\\Programs\\ZenNotes\\resources\\app.asar\\node_modules\\builder-util-runtime\\out\\httpExecutor.js:525:33)` + +const feedUrl = (version: string, file: string) => + `https://github.com/ZenNotes/zennotes/releases/download/v${version}/${file}` + +/** + * What electron-updater 6's GitHubProvider throws when the newest release + * has no `.yml`: its own error, with the HTTP error's stack pasted + * into the message and only a code to tell it apart (#1003's dialog). + */ +function channelFileNotFound(version: string, file: string): Error { + const url = feedUrl(version, file) + const httpError = + `HttpError: 404 \n` + + JSON.stringify( + `method: GET url: ${url}\n\nPlease double check that your authentication token is correct. Due to security reasons, actual status maybe not reported, but 404.\n` + ) + + `\nHeaders: ${GITHUB_404_HEADERS}\n${STACK}` + return Object.assign( + new Error(`Cannot find ${file} in the latest release artifacts (${url}): ${httpError}`), + { code: 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' } + ) +} + +/** builder-util-runtime's HttpError, as electron-updater rejects with it. */ +function httpError(status: number, reason = ''): Error { + return Object.assign(new Error(`${status} ${reason}\nHeaders: ${GITHUB_404_HEADERS}`), { + name: 'HttpError', + statusCode: status, + code: `HTTP_ERROR_${status}` + }) +} + +describe('describeUpdateError: a release still being published (#1003)', () => { + it('turns the reported Windows dialog into one sentence and keeps the dump for Details', () => { + const copy = describeUpdateError(channelFileNotFound('2.78.0', 'latest.yml'), 'check', 'win32') + expect(copy.kind).toBe('still-publishing') + expect(copy.message).toBe('ZenNotes 2.78.0 is still being published. Try again in a few minutes.') + expect(copy.retryable).toBe(false) + expect(copy.detail).toContain('[ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]') + expect(copy.detail).toContain('max-age=31536000') + expect(copy.detail).toContain('at createHttpError') + }) + + it('reads every platform feed the release publishes the same way', () => { + const feeds: Array<[string, NodeJS.Platform]> = [ + ['latest.yml', 'win32'], + ['latest-arm64.yml', 'win32'], + ['latest-mac.yml', 'darwin'], + ['latest-linux.yml', 'linux'], + ['latest-linux-arm64.yml', 'linux'] + ] + for (const [file, platform] of feeds) { + const copy = describeUpdateError(channelFileNotFound('2.79.0', file), 'check', platform) + expect(copy.kind, file).toBe('still-publishing') + expect(copy.message, file).toBe(stillPublishingMessage('2.79.0')) + } + }) + + it('recognizes the wording when a code was lost on the way', () => { + const { message } = channelFileNotFound('2.78.0', 'latest-mac.yml') + expect(describeUpdateError(new Error(message), 'check', 'darwin').message).toBe( + stillPublishingMessage('2.78.0') + ) + }) + + it('names the release from the notify-only check, which lands there by redirect', () => { + const missing = new ReleaseFeedHttpError(404, feedUrl('2.79.0', 'latest-linux.yml')) + const copy = describeUpdateError(missing, 'check', 'linux') + expect(copy.kind).toBe('still-publishing') + expect(copy.message).toBe('ZenNotes 2.79.0 is still being published. Try again in a few minutes.') + expect(copy.detail).toContain('GitHub answered 404 for the release feed') + }) + + it('still says so when no URL names the version', () => { + expect(describeUpdateError(new ReleaseFeedHttpError(404, null), 'check', 'linux').message).toBe( + 'A new version of ZenNotes is still being published. Try again in a few minutes.' + ) + }) +}) + +describe('describeUpdateError: everything else', () => { + it('never reads a 404 dump as a server error because of digits in its headers', () => { + const copy = describeUpdateError(httpError(404), 'check', 'win32') + expect(copy.kind).toBe('other') + expect(copy.retryable).toBe(false) + expect(copy.message).not.toMatch(/temporary/) + }) + + it('keeps the temporary-error sentence for real 5xx answers, and retries them', () => { + const copy = describeUpdateError(httpError(503, 'Service Unavailable'), 'check', 'win32') + expect(copy.kind).toBe('unavailable') + expect(copy.retryable).toBe(true) + expect(copy.message).toBe( + 'GitHub returned a temporary network or server error while checking for updates. Try again in a moment, or open the latest release directly.' + ) + // The notify-only check's own status, and a 5xx wrapped by electron-updater. + expect(describeUpdateError(new ReleaseFeedHttpError(502, null), 'check').retryable).toBe(true) + const wrapped = Object.assign( + new Error( + `Unable to find latest version on GitHub (https://github.com/ZenNotes/zennotes/releases/latest), please ensure a production release exists: HttpError: 502 \nHeaders: ${GITHUB_404_HEADERS}\n${STACK}` + ), + { code: 'ERR_UPDATER_LATEST_VERSION_NOT_FOUND' } + ) + expect(describeUpdateError(wrapped, 'check').kind).toBe('unavailable') + }) + + it('treats a timeout as temporary, and a rate limit as temporary but not worth an instant retry', () => { + expect(describeUpdateError(new Error('Request timed out'), 'check')).toMatchObject({ + kind: 'unavailable', + retryable: true + }) + expect(describeUpdateError(httpError(429, 'Too Many Requests'), 'check')).toMatchObject({ + kind: 'unavailable', + retryable: false + }) + }) + + it('words a failed download as a download', () => { + expect(describeUpdateError(httpError(503), 'download').message).toBe( + 'GitHub returned a temporary network or server error while downloading the update. Try again in a moment, or open the latest release directly.' + ) + // A missing installer is not a release in progress: the feeds go up last. + expect(describeUpdateError(httpError(404), 'download').message).toBe( + "ZenNotes couldn't download the update. Try again later, or open the latest release directly." + ) + }) + + it('names a refused code signature only on macOS', () => { + const signature = new Error( + 'Code signature at URL file:///Users/me/Library/Caches/zennotes-updater/pending/ZenNotes.app/ did not pass validation: code object is not signed at all' + ) + expect(describeUpdateError(signature, 'download', 'darwin')).toMatchObject({ + kind: 'signature', + message: UPDATE_SIGNATURE_MESSAGE + }) + expect(describeUpdateError(signature, 'download', 'win32').kind).toBe('other') + }) + + it('falls back to a plain sentence, with the original text kept for Details', () => { + const copy = describeUpdateError(new Error('The release feed carried no version.'), 'check', 'linux') + expect(copy.message).toBe( + "ZenNotes couldn't check for updates. Try again later, or open the latest release directly." + ) + expect(copy.detail).toBe('The release feed carried no version.') + expect(describeUpdateError('boom', 'check').detail).toBe('boom') + }) + + it('never puts headers, stack lines or URLs in the sentence', () => { + const errors = [ + channelFileNotFound('2.78.0', 'latest.yml'), + httpError(404), + httpError(503), + new ReleaseFeedHttpError(404, feedUrl('2.79.0', 'latest-linux.yml')) + ] + for (const error of errors) { + for (const action of ['check', 'download'] as const) { + const { message } = describeUpdateError(error, action, 'win32') + expect(message).not.toMatch(/Headers|\bat \w|https?:|HttpError|\n/) + } + } + }) +}) + +describe('updateErrorStatus', () => { + it('reads the status only where errors keep it', () => { + expect(updateErrorStatus(httpError(503))).toBe(503) + expect(updateErrorStatus(Object.assign(new Error('x'), { code: 'HTTP_ERROR_403' }))).toBe(403) + expect(updateErrorStatus(new ReleaseFeedHttpError(404, null))).toBe(404) + expect(updateErrorStatus(channelFileNotFound('2.78.0', 'latest.yml'))).toBe(404) + // Digits in headers and stack lines are not a status. + expect(updateErrorStatus(new Error(`failed\nHeaders: ${GITHUB_404_HEADERS}\n${STACK}`))).toBeNull() + expect(updateErrorStatus(undefined)).toBeNull() + }) +}) diff --git a/apps/desktop/src/main/update-error-copy.ts b/apps/desktop/src/main/update-error-copy.ts new file mode 100644 index 000000000..620d572e2 --- /dev/null +++ b/apps/desktop/src/main/update-error-copy.ts @@ -0,0 +1,227 @@ +/** + * A failed update check or download in a sentence a person can act on + * (#1003). + * + * electron-updater's errors carry everything it knows in the message: the + * request, every response header, and the stack of the HTTP error it wrapped. + * Shown as is, a Windows user who pressed Check for updates got a dialog of + * headers that ended in "GitHub returned a temporary network or server + * error", because a bare `5\d\d` matched a digit run somewhere in those + * headers. The answer had been a 404: the release workflow publishes the + * GitHub release before its update feeds, so for about fifty minutes the + * newest release has no `latest.yml` (or `latest-mac.yml`, or + * `latest-linux.yml`) for some platforms. + * + * So the HTTP status is read only from where it is structured (an error's + * `statusCode`, the `HTTP_ERROR_NNN` code, `HttpError: NNN` at the head of a + * wrapped error, the release feed's own status), never from the text at + * large, and the original text goes to `detail` for the Details disclosure + * and the log, never into the message. + */ + +/** What the updater was doing when it failed. */ +export type UpdateAction = 'check' | 'download' + +export type UpdateErrorKind = + /** The newest release has no update feed for this platform yet. */ + | 'still-publishing' + /** GitHub answered with a server error, a rate limit, or not in time. */ + | 'unavailable' + /** macOS refused the downloaded update's code signature. */ + | 'signature' + | 'other' + +export interface UpdateErrorCopy { + /** What happened and what to do, in one or two sentences. */ + message: string + /** The original error text, for Details, Copy details and the log. */ + detail: string | null + kind: UpdateErrorKind + /** True when trying again in a moment may succeed (a 5xx, a timeout). */ + retryable: boolean +} + +/** + * The release feed answered with something other than a feed. Thrown by the + * notify-only check (update-feed.ts), which reads the feed with fetch rather + * than electron-updater. `url` is where the request ended up after GitHub's + * redirect from `releases/latest`, which names the release's tag. + */ +export class ReleaseFeedHttpError extends Error { + constructor( + readonly status: number, + readonly url: string | null + ) { + super(`GitHub answered ${status} for the release feed${url ? ` (${url})` : ''}.`) + this.name = 'ReleaseFeedHttpError' + } +} + +/** electron-updater's code for a 404 on `.yml` of the newest release. */ +const CHANNEL_FILE_NOT_FOUND = 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' + +/** A release download path naming an update feed: the tag is the version. */ +const FEED_URL_VERSION = /\/releases\/download\/v?(\d+\.\d+\.\d+[^/\s)]*)\/latest[^/\s)]*\.ya?ml\b/i + +const TRANSIENT_TEXT = /gateway time-?out|timed out|econnreset|eai_again|socket hang up/i + +export function stillPublishingMessage(version: string | null): string { + return version + ? `ZenNotes ${version} is still being published. Try again in a few minutes.` + : 'A new version of ZenNotes is still being published. Try again in a few minutes.' +} + +const UNAVAILABLE: Record = { + check: + 'GitHub returned a temporary network or server error while checking for updates. Try again in a moment, or open the latest release directly.', + download: + 'GitHub returned a temporary network or server error while downloading the update. Try again in a moment, or open the latest release directly.' +} + +const OTHER: Record = { + check: "ZenNotes couldn't check for updates. Try again later, or open the latest release directly.", + download: + "ZenNotes couldn't download the update. Try again later, or open the latest release directly." +} + +/** A download that lost the network. A check that cannot reach GitHub never + * shows an error: it waits for the connection (updater.ts, #812). */ +export const UPDATE_OFFLINE_MESSAGE = + "ZenNotes couldn't reach GitHub. Check the connection and try again." + +export const UPDATE_SIGNATURE_MESSAGE = + "macOS couldn't verify the update's code signature, so ZenNotes can't install it. Open the latest release directly and install it from there." + +interface ChainLink { + message: string + code: string | null + statusCode: number | null + /** Set for the notify-only check's own feed request. */ + feedUrl: string | null + isReleaseFeed: boolean +} + +/** Each error along the `cause` chain, outermost first. */ +function errorChain(error: unknown): ChainLink[] { + const links: ChainLink[] = [] + let current: unknown = error + for (let depth = 0; depth < 5 && current != null; depth += 1) { + if (current instanceof Error) { + const fields = current as { code?: unknown; statusCode?: unknown; cause?: unknown } + const feed = current instanceof ReleaseFeedHttpError ? current : null + links.push({ + message: current.message, + code: typeof fields.code === 'string' ? fields.code : null, + statusCode: feed ? feed.status : typeof fields.statusCode === 'number' ? fields.statusCode : null, + feedUrl: feed?.url ?? null, + isReleaseFeed: feed !== null + }) + current = fields.cause + } else { + links.push({ + message: String(current), + code: null, + statusCode: null, + feedUrl: null, + isReleaseFeed: false + }) + break + } + } + return links +} + +/** + * The part of a message before the response headers and the stack, where + * the error says what it is. Digits after that point are header values, + * request ids and line numbers. + */ +function headline(message: string): string { + const cut = [message.indexOf('\nHeaders:'), message.search(/\n\s+at /)] + .filter((index) => index >= 0) + .reduce((first, index) => Math.min(first, index), message.length) + return message.slice(0, cut) +} + +/** The HTTP status an error stands for, or null when it carries none. */ +export function updateErrorStatus(error: unknown): number | null { + for (const link of errorChain(error)) { + if (link.statusCode !== null) return link.statusCode + const fromCode = /^HTTP_ERROR_(\d{3})$/.exec(link.code ?? '') + if (fromCode) return Number(fromCode[1]) + // A wrapped HttpError is pasted in as its stack: "HttpError: 404 ...". + const wrapped = /\bHttpError: (\d{3})\b/.exec(headline(link.message)) + if (wrapped) return Number(wrapped[1]) + } + return null +} + +/** The version a missing feed belongs to, read from the feed's URL. */ +function versionFromFeedUrl(links: ChainLink[]): string | null { + for (const link of links) { + for (const text of [link.feedUrl, headline(link.message)]) { + const match = text ? FEED_URL_VERSION.exec(text) : null + if (match) return match[1] + } + } + return null +} + +function isMissingFeed(links: ChainLink[]): boolean { + return links.some( + (link) => + link.code === CHANNEL_FILE_NOT_FOUND || + /^Cannot find \S+\.ya?ml in the latest release artifacts\b/.test(link.message) || + // The notify-only check reads `releases/latest/download/latest-linux.yml`. + (link.isReleaseFeed && link.statusCode === 404) + ) +} + +/** The whole original text, every link of the chain, for Details. */ +function detailOf(links: ChainLink[]): string | null { + const parts = links + .map((link) => [link.code && !link.message.includes(link.code) ? `[${link.code}]` : '', link.message.trim()]) + .map((pair) => pair.filter(Boolean).join(' ')) + .filter(Boolean) + const unique = parts.filter((part, index) => parts.indexOf(part) === index) + return unique.length > 0 ? unique.join('\n\ncaused by: ') : null +} + +export function describeUpdateError( + error: unknown, + action: UpdateAction, + platform: NodeJS.Platform = process.platform +): UpdateErrorCopy { + const links = errorChain(error) + const detail = detailOf(links) + const status = updateErrorStatus(error) + + if (action === 'check' && isMissingFeed(links)) { + return { + message: stillPublishingMessage(versionFromFeedUrl(links)), + detail, + kind: 'still-publishing', + retryable: false + } + } + + const transient = links.some((link) => TRANSIENT_TEXT.test(headline(link.message))) + if ((status !== null && status >= 500 && status <= 599) || status === 429 || transient) { + return { + message: UNAVAILABLE[action], + detail, + kind: 'unavailable', + // A rate limit does not lift in a second and a half. + retryable: status !== 429 + } + } + + if ( + platform === 'darwin' && + links.some((link) => /code signature|signature .*(?:valid|verif)|not signed/i.test(headline(link.message))) + ) { + return { message: UPDATE_SIGNATURE_MESSAGE, detail, kind: 'signature', retryable: false } + } + + return { message: OTHER[action], detail, kind: 'other', retryable: false } +} diff --git a/apps/desktop/src/main/update-feed.ts b/apps/desktop/src/main/update-feed.ts index 081fec214..57d132f2b 100644 --- a/apps/desktop/src/main/update-feed.ts +++ b/apps/desktop/src/main/update-feed.ts @@ -16,6 +16,8 @@ * carry. */ +import { ReleaseFeedHttpError } from './update-error-copy' + export const LATEST_LINUX_FEED_URL = 'https://github.com/ZenNotes/zennotes/releases/latest/download/latest-linux.yml' @@ -61,16 +63,21 @@ export function isNewerVersion(candidate: string, current: string): boolean { return compareVersions(candidate, current) > 0 } -export type FeedFetch = (url: string) => Promise<{ ok: boolean; status: number; text(): Promise }> +export type FeedFetch = ( + url: string +) => Promise<{ ok: boolean; status: number; url?: string; text(): Promise }> -/** The newest published release, from the feed at `url`. */ +/** The newest published release, from the feed at `url`. A failed answer + * throws with its status and the URL it ended at: GitHub redirects + * `releases/latest/download/...` to the release's tag, so a 404 there names + * the release that has no feed yet (#1003). */ export async function fetchLatestRelease( fetchImpl: FeedFetch = (url) => fetch(url, { headers: { 'user-agent': 'ZenNotes update check' } }), url: string = LATEST_LINUX_FEED_URL ): Promise { const response = await fetchImpl(url) if (!response.ok) { - throw new Error(`GitHub answered ${response.status} for the release feed.`) + throw new ReleaseFeedHttpError(response.status, response.url || url) } return parseLatestFeed(await response.text()) } diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 0d9b185e5..701b58719 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -3,11 +3,36 @@ import { afterEach, describe, expect, it, vi } from 'vitest' // What Chromium's network change notifier would answer; tests flip it to // play a link going down and coming back. const network = vi.hoisted(() => ({ online: true })) +// Whether the stubbed app is a packaged build, which is what lets the +// installing updaters (NSIS, Squirrel.Mac, AppImage) run at all. +const packaged = vi.hoisted(() => ({ value: false })) + +/** electron-updater's autoUpdater, reduced to events and a check. */ +const installer = vi.hoisted(() => { + const handlers = new Map void>>() + return { + handlers, + on(event: string, cb: (...args: unknown[]) => void) { + handlers.set(event, [...(handlers.get(event) ?? []), cb]) + return this + }, + emit(event: string, ...args: unknown[]) { + for (const cb of handlers.get(event) ?? []) cb(...args) + }, + checkForUpdates: async (): Promise => null + } +}) // updater.ts imports electron and electron-updater at module load. Stub both so // we can unit-test the pure Linux-install helpers without an Electron runtime. vi.mock('electron', () => ({ - app: { getVersion: () => '2.0.2' }, + app: { + getVersion: () => '2.0.2', + get isPackaged() { + return packaged.value + }, + runningUnderARM64Translation: false + }, BrowserWindow: { getAllWindows: () => [] }, Notification: { isSupported: () => false }, net: { isOnline: () => network.online }, @@ -15,7 +40,7 @@ vi.mock('electron', () => ({ })) vi.mock('electron-updater', () => ({ default: { - autoUpdater: {}, + autoUpdater: installer, AppImageUpdater: class {}, DebUpdater: class {}, RpmUpdater: class {}, @@ -364,18 +389,24 @@ function fetchFailed(code: string, detail: string): TypeError { }) } +/** A feed body, a thrown error, or an HTTP answer that is not a feed. */ +type FeedAnswer = string | Error | { status: number; url: string } + /** * A fresh updater module on the package-manager (notify-only) path, its feed - * served by `feed`: a fixed body, a fixed error, or a function answering per - * call so a test can bring the network back partway through. + * served by `feed`: a fixed answer, or a function answering per call so a + * test can bring the network back partway through. */ -async function loadManagedUpdater(feed: string | Error | (() => string | Error)) { +async function loadManagedUpdater(feed: FeedAnswer | (() => FeedAnswer)) { vi.resetModules() process.env.ZENNOTES_UPDATER_FORMAT = 'managed' process.env.ZENNOTES_UPDATE_FEED_URL = 'http://127.0.0.1:1/latest-linux.yml' const fetchMock = vi.fn(async () => { const body = typeof feed === 'function' ? feed() : feed if (body instanceof Error) throw body + if (typeof body !== 'string') { + return { ok: false, status: body.status, url: body.url, text: async () => 'Not Found' } + } return { ok: true, status: 200, text: async () => body } }) vi.stubGlobal('fetch', fetchMock) @@ -417,10 +448,55 @@ describe('checkForAppUpdates on a package-manager install', () => { }) it('surfaces a feed failure instead of staying on checking', async () => { - const { mod } = await loadManagedUpdater(new Error('GitHub answered 404 for the release feed.')) + const { mod } = await loadManagedUpdater(new Error('The release feed carried no version.')) + const state = await mod.checkForAppUpdates() + expect(state.phase).toBe('error') + expect(state.message).toBe( + "ZenNotes couldn't check for updates. Try again later, or open the latest release directly." + ) + expect(state.detail).toBe('The release feed carried no version.') + }) + + it('says the newest release is still being published when its feed answers 404 (#1003)', async () => { + const { mod, fetchMock } = await loadManagedUpdater({ + status: 404, + // Where GitHub's redirect from releases/latest/download lands. + url: 'https://github.com/ZenNotes/zennotes/releases/download/v2.0.3/latest-linux.yml' + }) const state = await mod.checkForAppUpdates() expect(state.phase).toBe('error') - expect(state.message).toMatch(/404/) + expect(state.message).toBe('ZenNotes 2.0.3 is still being published. Try again in a few minutes.') + expect(state.detail).toMatch(/GitHub answered 404 for the release feed/) + // Publishing takes minutes: no instant retries. + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('retries a server error, then words it without the raw answer', async () => { + vi.useFakeTimers() + try { + const { mod, fetchMock } = await loadManagedUpdater({ status: 503, url: 'http://127.0.0.1:1/latest-linux.yml' }) + const pending = mod.checkForAppUpdates() + await vi.advanceTimersByTimeAsync(10_000) + const state = await pending + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(state.phase).toBe('error') + expect(state.message).toBe( + 'GitHub returned a temporary network or server error while checking for updates. Try again in a moment, or open the latest release directly.' + ) + expect(state.detail).toMatch(/503/) + } finally { + vi.useRealTimers() + } + }) + + it('clears the details once a later check succeeds', async () => { + let answer: FeedAnswer = { status: 404, url: 'http://127.0.0.1:1/latest-linux.yml' } + const { mod } = await loadManagedUpdater(() => answer) + expect((await mod.checkForAppUpdates()).detail).toBeTruthy() + answer = FEED('2.0.3') + const state = await mod.checkForAppUpdates() + expect(state.phase).toBe('available') + expect(state.detail).toBeNull() }) }) @@ -617,3 +693,84 @@ describe('background update checks repeat while the app runs (#947)', () => { } }) }) + +// #1003: a Windows copy (NSIS) checked while the newest release was still +// uploading its files, and the dialog showed electron-updater's whole 404. +describe('checkForAppUpdates on an installing updater (#1003)', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + + afterEach(() => { + packaged.value = false + installer.handlers.clear() + installer.checkForUpdates = async () => null + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() + }) + + it('answers a missing feed with one sentence, at once, and logs the raw answer', async () => { + vi.resetModules() + packaged.value = true + Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const url = 'https://github.com/ZenNotes/zennotes/releases/download/v2.78.0/latest.yml' + const missing = Object.assign( + new Error( + `Cannot find latest.yml in the latest release artifacts (${url}): HttpError: 404 \n"method: GET url: ${url}"\nHeaders: {\n "strict-transport-security": "max-age=31536000; includeSubdomains; preload"\n}\n at createHttpError (app.asar/node_modules/builder-util-runtime/out/httpExecutor.js:53:12)` + ), + { code: 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' } + ) + const check = vi.fn(async () => { + // electron-updater emits the error, then rejects the same check. + installer.emit('error', missing) + throw missing + }) + installer.checkForUpdates = check + + const mod = await import('./updater') + const state = await mod.checkForAppUpdates() + + expect(state.phase).toBe('error') + expect(state.message).toBe('ZenNotes 2.78.0 is still being published. Try again in a few minutes.') + expect(state.detail).toContain('max-age=31536000') + expect(state.detail).toContain('at createHttpError') + // The old 5xx test matched "536" in max-age and retried three times. + expect(check).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[updater] check failed (still-publishing):', + expect.stringContaining('ERR_UPDATER_CHANNEL_FILE_NOT_FOUND') + ) + }) +}) + +describe('downloadAppUpdate with no network', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + + afterEach(() => { + packaged.value = false + installer.handlers.clear() + installer.checkForUpdates = async () => null + delete (installer as { downloadUpdate?: unknown }).downloadUpdate + Object.defineProperty(process, 'platform', platform) + }) + + it('asks for the connection in a sentence, with the cause under Details', async () => { + vi.resetModules() + packaged.value = true + Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }) + installer.checkForUpdates = async () => { + installer.emit('update-available', { version: '2.0.3', files: [], path: '', sha512: '', releaseDate: '' }) + return null + } + Object.assign(installer, { + downloadUpdate: async () => { + throw new Error('net::ERR_INTERNET_DISCONNECTED') + } + }) + const mod = await import('./updater') + expect((await mod.checkForAppUpdates()).phase).toBe('available') + const state = await mod.downloadAppUpdate() + expect(state.phase).toBe('error') + expect(state.message).toBe("ZenNotes couldn't reach GitHub. Check the connection and try again.") + expect(state.detail).toBe('net::ERR_INTERNET_DISCONNECTED') + }) +}) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 525c8d2c5..f8b4795ad 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, net, Notification, shell } from 'electron' import { execFile } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { fetchLatestRelease, isNewerVersion } from './update-feed' +import { describeUpdateError, UPDATE_OFFLINE_MESSAGE, type UpdateAction } from './update-error-copy' import { join, posix } from 'node:path' import { promisify } from 'node:util' import electronUpdater, { @@ -56,6 +57,7 @@ function makeState(overrides: Partial = {}): AppUpdateState { totalBytes: null, bytesPerSecond: null, message: 'Check GitHub releases for a newer ZenNotes build.', + detail: null, ...overrides } } @@ -138,29 +140,31 @@ function describeError(error: unknown): string { return messages.at(-1) ?? parts.at(-1) ?? 'Unknown updater error.' } -function humanizeUpdateError(error: unknown): string { - const message = describeError(error) - if (isNetworkUnreachableError(error)) { - return `${message} ZenNotes could not reach GitHub. Check the connection and try again.` - } - if (/5\d\d|gateway time-?out|timed out|econnreset|eai_again|socket hang up/i.test(message)) { - return `${message} GitHub returned a temporary network or server error while checking for updates. Try again in a moment, or open the latest release directly.` - } - if (/404|401|403|forbidden|unauthorized/i.test(message)) { - return `${message} GitHub-hosted end-user updates require public releases, or a special private-repo token setup.` - } - if (process.platform === 'darwin' && /sign|signature/i.test(message)) { - return `${message} macOS auto-updates require a signed app build.` - } - return message +/** + * The error state for a failed check or download: a sentence for the person + * (#1003), and the original text kept apart as `detail` for Settings' + * Details disclosure and for the log, never in the message or a dialog. + */ +function updateErrorState(error: unknown, action: UpdateAction): AppUpdateState { + const copy = describeUpdateError(error, action) + const message = isNetworkUnreachableError(error) ? UPDATE_OFFLINE_MESSAGE : copy.message + return managedInstall + ? makeState({ phase: 'error', message, detail: copy.detail }) + : nextStateFromInfo('error', lastInfo, message, { detail: copy.detail }) } -function isRetryableUpdateError(error: unknown): boolean { - const message = - error instanceof Error ? error.message : String(error) - return /5\d\d|gateway time-?out|timed out|econnreset|eai_again|socket hang up/i.test( - message - ) +/** The original text of a failure into the main process log. A missing + * network is left out: the offline state already names its cause. */ +function logUpdateError(error: unknown, action: UpdateAction): void { + if (isNetworkUnreachableError(error)) return + const copy = describeUpdateError(error, action) + console.warn(`[updater] ${action} failed (${copy.kind}):`, copy.detail ?? copy.message) +} + +/** Worth another attempt in a moment: GitHub answered 5xx or timed out. A + * missing feed is not: the release takes minutes to finish publishing. */ +function isRetryableUpdateError(error: unknown, action: UpdateAction): boolean { + return describeUpdateError(error, action).retryable } function sleep(ms: number): Promise { @@ -436,14 +440,14 @@ export function initAppUpdater(): void { } }) updater.on('error', (error) => { + const action: UpdateAction = updateState.phase === 'downloading' ? 'download' : 'check' + logUpdateError(error, action) // electron-updater emits this and rejects the same promise. Let the check // or download that owns the promise decide what an unreachable network // means (a wait, not an error); reporting it here first would flash the // error state through the renderer on the way. if (isNetworkUnreachableError(error)) return - setUpdateState( - nextStateFromInfo('error', lastInfo, humanizeUpdateError(error)) - ) + setUpdateState(updateErrorState(error, action)) }) setUpdateState(makeState()) @@ -464,7 +468,7 @@ export async function checkForAppUpdates(): Promise { await updater.checkForUpdates() return getAppUpdateState() } catch (error) { - const retryable = isRetryableUpdateError(error) + const retryable = isRetryableUpdateError(error, 'check') const hasAttemptsLeft = attempt < UPDATE_CHECK_MAX_ATTEMPTS if (retryable && hasAttemptsLeft) { setUpdateState( @@ -482,9 +486,7 @@ export async function checkForAppUpdates(): Promise { waitForNetwork(error) break } - setUpdateState( - nextStateFromInfo('error', lastInfo, humanizeUpdateError(error)) - ) + setUpdateState(updateErrorState(error, 'check')) break } } @@ -536,7 +538,9 @@ async function checkManagedInstallForUpdates(): Promise { } return getAppUpdateState() } catch (error) { - if (isRetryableUpdateError(error) && attempt < UPDATE_CHECK_MAX_ATTEMPTS) { + // No electron-updater here to emit and log the error for us. + logUpdateError(error, 'check') + if (isRetryableUpdateError(error, 'check') && attempt < UPDATE_CHECK_MAX_ATTEMPTS) { setUpdateState( makeState({ phase: 'checking', @@ -550,7 +554,7 @@ async function checkManagedInstallForUpdates(): Promise { waitForNetwork(error) return getAppUpdateState() } - setUpdateState(makeState({ phase: 'error', message: humanizeUpdateError(error) })) + setUpdateState(updateErrorState(error, 'check')) return getAppUpdateState() } } @@ -612,9 +616,7 @@ export async function downloadAppUpdate(): Promise { try { await updater.downloadUpdate() } catch (error) { - setUpdateState( - nextStateFromInfo('error', lastInfo, humanizeUpdateError(error)) - ) + setUpdateState(updateErrorState(error, 'download')) } return getAppUpdateState() diff --git a/packages/app-core/src/components/CloudErrorMessage.tsx b/packages/app-core/src/components/CloudErrorMessage.tsx index dc5da8d82..2b179bde4 100644 --- a/packages/app-core/src/components/CloudErrorMessage.tsx +++ b/packages/app-core/src/components/CloudErrorMessage.tsx @@ -1,13 +1,9 @@ -import { useEffect, useState } from "react"; import { describeCloudError, type CloudErrorCopy, } from "@zennotes/shared-domain/cloud-error-copy"; -import { writeClipboardText } from "../lib/clipboard-text"; import { signOutAfterRefusal } from "../lib/cloud-auto-sync"; -import { Button } from "./ui/Button"; - -const APP_ISSUES_URL = "https://github.com/ZenNotes/zennotes/issues"; +import { ErrorDetails } from "./ErrorDetails"; /** * A failed Cloud action in words a person can act on. The desktop main @@ -38,41 +34,10 @@ export function CloudErrorMessage({ message: string; detail: string | null; }): JSX.Element { - const [copied, setCopied] = useState(false); - useEffect(() => { - if (!copied) return; - const timer = window.setTimeout(() => setCopied(false), 1500); - return () => window.clearTimeout(timer); - }, [copied]); - return ( <> {message} - {detail && ( -
- - Details - -
-            {detail}
-          
-
- - -
-
- )} + {detail && } ); } diff --git a/packages/app-core/src/components/ErrorDetails.tsx b/packages/app-core/src/components/ErrorDetails.tsx new file mode 100644 index 000000000..a72d3e161 --- /dev/null +++ b/packages/app-core/src/components/ErrorDetails.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { writeClipboardText } from "../lib/clipboard-text"; +import { Button } from "./ui/Button"; + +const APP_ISSUES_URL = "https://github.com/ZenNotes/zennotes/issues"; + +/** + * The technical text behind an error sentence: a closed Details disclosure + * with the text, Copy details, and Report an Issue (the same words as the + * Help menu and the command palette). Cloud errors and the About page's + * update errors put it under their sentence, so the sentence stays readable + * and support still gets every header and stack line. + */ +export function ErrorDetails({ + detail, + ...marker +}: { + detail: string; +} & Record<`data-${string}`, string>): JSX.Element { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(timer); + }, [copied]); + + return ( +
+ + Details + +
+        {detail}
+      
+
+ + +
+
+ ); +} diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 98b1833d9..6eb62561e 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -164,6 +164,7 @@ import { import { CustomCodeLanguagesSettings } from "./CustomCodeLanguagesSettings"; import { TextReplacementsSettings } from "./TextReplacementsSettings"; import { CloudSettings } from "./CloudSettings"; +import { ErrorDetails } from "./ErrorDetails"; import { consumeSettingsTarget } from "../lib/settings-navigation"; import { normalizeApplicationSchemes } from "@shared/application-links"; @@ -594,10 +595,19 @@ function AppUpdatesCard({ homepage }: { homepage?: string }): JSX.Element {
-

+

{appUpdateState?.message ?? "Check GitHub releases for a newer ZenNotes build."}

+ {appUpdateState?.phase === "error" && appUpdateState.detail && ( + + )} {appUpdateState?.phase === "downloading" && (
diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 81fd3974a..5712790bd 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -204,7 +204,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Check for updates and install them', body: - 'Use Check for Updates from the app menu, the command palette, or Settings → About. ZenNotes also checks on its own shortly after launch. If that check finds no network (Settings → About then says "Waiting for network"), nothing needs doing: it checks again by itself once the connection is back, and keeps trying at growing intervals if GitHub stays out of reach. When a release is available, ZenNotes can download it in the background and then prompt you to install and relaunch. A copy installed by a package manager (the AUR package, or a tarball unpacked by hand) is only told that a newer version exists; install it the way you installed ZenNotes, since the package manager owns those files. On Arch, a `.pacman` build installs through a graphical polkit prompt; dismissing it keeps the download ready to retry, and if no graphical agent can run, Details in Settings → About shows the manual install command.' + 'Use Check for Updates from the app menu, the command palette, or Settings → About. ZenNotes also checks on its own shortly after launch. If that check finds no network (Settings → About then says "Waiting for network"), nothing needs doing: it checks again by itself once the connection is back, and keeps trying at growing intervals if GitHub stays out of reach. A check that runs while a new release is still being published says so (for example "ZenNotes 2.79.0 is still being published"): try again in a few minutes. Any other failed check or download is one sentence in Settings → About, with the technical text under Details, where Copy details and Report an Issue help with a bug report. When a release is available, ZenNotes can download it in the background and then prompt you to install and relaunch. A copy installed by a package manager (the AUR package, or a tarball unpacked by hand) is only told that a newer version exists; install it the way you installed ZenNotes, since the package manager owns those files. On Arch, a `.pacman` build installs through a graphical polkit prompt; dismissing it keeps the download ready to retry, and if no graphical agent can run, Details in Settings → About shows the manual install command.' }, { title: 'Run the self-hosted web version with Docker', diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 6ea1a9ade..cb00acd27 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -412,9 +412,16 @@ export interface AppUpdateState { totalBytes: number | null bytesPerSecond: number | null message: string + /** + * The original text of a failed check or download (headers, stack and + * all), for a Details disclosure and bug reports. Never part of `message`, + * which stays a sentence a person can act on (#1003). Optional because a + * host that never updates itself has nothing to put here. + */ + detail?: string | null } -export type NoteFolder = 'inbox' | 'quick' | 'archive' | 'trash' +export type NoteFolder ='inbox' | 'quick' | 'archive' | 'trash' export type PrimaryNotesLocation = 'inbox' | 'root' From 2db21e79736ae6af411d21c7216f039a4bc76cbc Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 10:20:31 -0500 Subject: [PATCH 10/34] Fix(tasks): the Tasks filter finds a task note by a tag in its body (#1008) davi-jorge-art asked, in the same issue, whether it is on purpose that the Tasks filter sees a tag in a task's title but not in its body. The task they mean is a file task, the kind New task makes (+ New task in the Tasks header, :newtask, a phone's Action Button): a note with title, status and `tags: [task]` in its frontmatter and the rest of the note as its body. The title is typed in the New task prompt, which has no tag picker, and so is its `title:` line, since the editor offers no tags inside frontmatter. The body is ordinary note text where # brings up the picker, so on a phone that is where a tag is easy to add. parseTaskFile takes a file task's tags from the frontmatter list alone, and the filter matches the task text (the title), the note title, those tags, priority and fields. A #tag typed in the title was found as text; one in the body was found by nothing. The Tags view meanwhile listed the same note under that tag, since a note's tags include its body. filterTasks now takes each note's tags by path (NoteMeta.tags, the list the Tags view and the sidebar read) and lets a file task match the tags anywhere in its note. The `task` marker is left out, as parseTaskFile leaves it out, so #task does not narrow to every task note. A checkbox task keeps to its own line: a tag elsewhere in its note says nothing about that one task. TasksView builds the map once and hands it to the list, to the calendar through the filtered list, and to the board, so the three sub-views keep agreeing. What it deliberately leaves alone: a task's `tags` still hold only the frontmatter list, so parseTaskFile and its copies (MCP list_tasks, zn, the Go server) report what they reported, the row's tag chips are unchanged, and none of the four task-line parser copies moves. The indented lines under a `- [ ]` are not treated as its body here; that would need the parser copies to agree on where a task's block ends. The Tasks filter paragraph in the in-app manual says so. The website's Tasks Filter entry (docs.blade.php) mirrors that paragraph and still needs the sentence; this branch does not touch the website repo. Tests: filterTasks with a task parsed by parseTaskFile whose body carries #errands and #Admin/Paperwork: found by #errands, errands, #admin and the full nested tag; a checkbox in a note tagged #errands on another line is not; #task finds nothing; without the map nothing changes; computeTasksRender passes the map through. In the built app over CDP with both stores isolated, a vault with that task note, a second task note with no tag, "- [ ] Buy milk #errands" and "- [ ] Water the plants" in a note that says #errands on another line: before, "#errands" listed Buy milk alone (1 of 4), the calendar showed 1 task today and the board 1 card. After, it lists Renew the passport and Buy milk (2 of 4), the calendar shows 2 and the board 2 cards, Water the plants and Call the bank stay out, #task lists nothing, and the Tags view for #errands lists the same two notes. --- .../app-core/src/components/TasksKanban.tsx | 18 ++++- .../app-core/src/components/TasksView.tsx | 18 +++-- packages/app-core/src/lib/help.ts | 2 +- .../app-core/src/lib/tasks-filter.test.ts | 79 ++++++++++++++++++- packages/app-core/src/lib/tasks-filter.ts | 40 ++++++++-- 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index b81a46aa5..32ef16ddd 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -45,7 +45,7 @@ import { type KanbanGroupBy, type TaskMutation } from '../store' -import { filterTasks } from '../lib/tasks-filter' +import { filterTasks, type NoteTagsByPath } from '../lib/tasks-filter' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' @@ -67,6 +67,9 @@ interface Props { * column set, group-by options, and card-order persistence keep working * from the full task list. (#583) */ filter?: string + /** Each note's tags, so the filter finds a file task by a tag in its body, + * as the list and calendar do (#1008). */ + noteTags?: NoteTagsByPath | null today: Date onOpenTask: (task: VaultTask) => void onToggleTask: (task: VaultTask) => void @@ -671,7 +674,14 @@ interface DragPreview { const POINTER_DRAG_THRESHOLD = 5 -export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: Props): JSX.Element { +export function TasksKanban({ + tasks, + filter, + noteTags, + today, + onOpenTask, + onToggleTask +}: Props): JSX.Element { const groupBy = useStore((s) => s.kanbanGroupBy) const cardSort = useStore((s) => s.kanbanCardSort) const setCardSort = useStore((s) => s.setKanbanCardSort) @@ -849,7 +859,7 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: const columns = useMemo(() => { if (!filterQuery) return fullColumns return fullColumns.map((column) => { - const visible = filterTasks(column.tasks, filterQuery) + const visible = filterTasks(column.tasks, filterQuery, noteTags) if (visible.length === column.tasks.length) return column let badge = column.badge if (badge?.kind === 'overdue') { @@ -858,7 +868,7 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: } return { ...column, tasks: visible, badge } }) - }, [fullColumns, filterQuery, today]) + }, [fullColumns, filterQuery, noteTags, today]) columnsRef.current = columns fullColumnsRef.current = fullColumns diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 0b1a01548..97a02fdbb 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isTasksViewActive, useStore, type KanbanCardSort, type TasksListOrder, type TasksViewMode } from '../store' import { filterTasksForDisplay, inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' import { buildDailyNoteDateByPath } from '../lib/vault-layout' -import { computeTasksRender, filterTasks, isOverdue } from '../lib/tasks-filter' +import { computeTasksRender, filterTasks, isOverdue, noteTagsByPath } from '../lib/tasks-filter' import { forwardTaskWithPicker } from '../lib/forward-task' import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' @@ -88,7 +88,12 @@ export function TasksView(): JSX.Element { // the calendar takes this pre-filtered list, and the Kanban receives the raw // query so it can narrow cards without losing the full board (its group-by // options and card-order persistence must keep seeing every task). - const filteredTasks = useMemo(() => filterTasks(tasks, filter), [tasks, filter]) + // A file task also matches the tags written in its note's body (#1008). + const noteTags = useMemo(() => noteTagsByPath(notes), [notes]) + const filteredTasks = useMemo( + () => filterTasks(tasks, filter, noteTags), + [tasks, filter, noteTags] + ) const keymapOverrides = useStore((s) => s.keymapOverrides) const vimMode = useStore((s) => s.vimMode) const listOrder = useStore((s) => s.tasksListOrder) @@ -141,14 +146,16 @@ export function TasksView(): JSX.Element { const render = useMemo(() => { const linger = lingerRef.current - if (linger.size === 0) return computeTasksRender(tasks, filter, today, collapsed, listOrder) + if (linger.size === 0) { + return computeTasksRender(tasks, filter, today, collapsed, listOrder, noteTags) + } // Group lingering tasks by their PRE-toggle checked state so a just-checked // task stays in its current group instead of jumping straight to Done. const source = tasks.map((t) => { const l = linger.get(t.id) return l ? { ...t, checked: l.groupChecked } : t }) - const r = computeTasksRender(source, filter, today, collapsed, listOrder) + const r = computeTasksRender(source, filter, today, collapsed, listOrder, noteTags) // Render the real task so the checkbox reflects the actual (new) state while // the row lingers in place. const byId = new Map(tasks.map((t) => [t.id, t])) @@ -157,7 +164,7 @@ export function TasksView(): JSX.Element { ) return { ...r, rows } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tasks, filter, today, collapsed, lingerVersion, listOrder]) + }, [tasks, filter, today, collapsed, lingerVersion, listOrder, noteTags]) // Index-into-rows map for just the 'task' rows (what the cursor navigates). const taskRowIndices = useMemo(() => { @@ -1064,6 +1071,7 @@ export function TasksView(): JSX.Element { void openTaskAt(task)} onToggleTask={(task) => void toggleTaskFromList(task)} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 81fd3974a..e82a4281f 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -352,7 +352,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Filter the Tasks views to one project', body: - 'The filter box in the Tasks header narrows all three sub-views: the list, the calendar, and the Kanban board (where cards filter out but the columns stay put, so the board keeps its shape while you type). Press `/` to focus it (Vim mode), type in it directly, or run `:filter ` from the ex line; `Esc` or a bare `:filter` clears it, and the query survives switching views, so a filtered list stays filtered when you jump to the board. Matching is a simple substring check across the task text, the note title, tags (with the `#`, so `#project-beta` narrows to that tag), `!high`-style priorities, and `@key:value` fields. The fields are the project trick: tag tasks with `@project:alpha`, then filter `@project:alpha` while the board is grouped by status, and you have a one-project board; the header shows how many tasks match. A filtered board is still fully live: drag or `Shift+H`/`L` still move cards, and hand-arranged card order is preserved for the cards the filter is hiding. A query worth typing twice is worth saving: press **Save filter…** in the row under the header (or run `:savefilter `) and it becomes a chip there; click a chip, run `:filter `, or press `F` (Vim mode) to pick one from a list, and the command palette lists every saved filter as "Tasks: name" so you can jump to it from any note. Right-click a chip to rename or delete it. Saved filters are kept in config.toml under `[saved_filters]`, one `"Name" = "query"` line each, so they travel with your dotfiles and a hand edit applies live.' + 'The filter box in the Tasks header narrows all three sub-views: the list, the calendar, and the Kanban board (where cards filter out but the columns stay put, so the board keeps its shape while you type). Press `/` to focus it (Vim mode), type in it directly, or run `:filter ` from the ex line; `Esc` or a bare `:filter` clears it, and the query survives switching views, so a filtered list stays filtered when you jump to the board. Matching is a simple substring check across the task text, the note title, tags (with the `#`, so `#project-beta` narrows to that tag), `!high`-style priorities, and `@key:value` fields. A task that is a whole note (made with New task, `tags: [task]` in its frontmatter) also matches a `#tag` written anywhere in its body, so you can tag it while you write its notes, where the tag picker helps; a checkbox task matches only the tags on its own line. The fields are the project trick: tag tasks with `@project:alpha`, then filter `@project:alpha` while the board is grouped by status, and you have a one-project board; the header shows how many tasks match. A filtered board is still fully live: drag or `Shift+H`/`L` still move cards, and hand-arranged card order is preserved for the cards the filter is hiding. A query worth typing twice is worth saving: press **Save filter…** in the row under the header (or run `:savefilter `) and it becomes a chip there; click a chip, run `:filter `, or press `F` (Vim mode) to pick one from a list, and the command palette lists every saved filter as "Tasks: name" so you can jump to it from any note. Right-click a chip to rename or delete it. Saved filters are kept in config.toml under `[saved_filters]`, one `"Name" = "query"` line each, so they travel with your dotfiles and a hand edit applies live.' }, { title: 'Forward a task to another note', diff --git a/packages/app-core/src/lib/tasks-filter.test.ts b/packages/app-core/src/lib/tasks-filter.test.ts index 37f566248..c2dec171b 100644 --- a/packages/app-core/src/lib/tasks-filter.test.ts +++ b/packages/app-core/src/lib/tasks-filter.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from 'vitest' -import type { VaultTask, VaultTaskGroups } from '@shared/tasks' -import { applyFileOrder, applyPriorityOrder, computeTasksRender, filterTasks } from './tasks-filter' +import { parseTaskFile, type VaultTask, type VaultTaskGroups } from '@shared/tasks' +import { + applyFileOrder, + applyPriorityOrder, + computeTasksRender, + filterTasks, + noteTagsByPath +} from './tasks-filter' function task(sourcePath: string, taskIndex: number, overrides?: Partial): VaultTask { return { @@ -71,6 +77,75 @@ describe('filterTasks', () => { }) }) +describe('filterTasks: tags in a file task\'s body (#1008)', () => { + // A task made with New task: the title is frontmatter, the body is the note. + const body = [ + '---', + 'title: Renew the passport', + 'status: open', + 'tags: [task]', + '---', + '', + 'Book the appointment first. #errands #Admin/Paperwork', + '' + ].join('\n') + const fileTask = parseTaskFile(body, { + path: 'inbox/Renew the passport.md', + title: 'Renew the passport', + folder: 'inbox' + })! + // What the note list carries for that note (the Tags view's source): the + // frontmatter tags and the body's inline tags. + const notes = [ + { path: 'inbox/Renew the passport.md', tags: ['task', 'errands', 'Admin/Paperwork'] }, + { path: 'inbox/Week.md', tags: ['errands'] } + ] + // A checkbox in another note that mentions #errands on a different line. + const checkbox = task('inbox/Week.md', 0, { content: 'water the plants' }) + + it('leaves the parsed task\'s own tags to the frontmatter', () => { + expect(fileTask.kind).toBe('file') + expect(fileTask.tags).toEqual([]) + }) + + it('finds a file task by a tag written in its body', () => { + const tags = noteTagsByPath(notes) + expect(filterTasks([fileTask, checkbox], '#errands', tags)).toEqual([fileTask]) + expect(filterTasks([fileTask, checkbox], 'errands', tags)).toEqual([fileTask]) + expect(filterTasks([fileTask, checkbox], '#admin', tags)).toEqual([fileTask]) + expect(filterTasks([fileTask, checkbox], '#admin/paperwork', tags)).toEqual([fileTask]) + }) + + it('keeps a checkbox task to its own line', () => { + const tags = noteTagsByPath(notes) + expect(filterTasks([checkbox], '#errands', tags)).toEqual([]) + }) + + it('does not treat the task marker as a body tag', () => { + const tags = noteTagsByPath(notes) + expect(filterTasks([fileTask], '#task', tags)).toEqual([]) + }) + + it('reads only the task line without the note tags, as before', () => { + expect(filterTasks([fileTask], '#errands')).toEqual([]) + }) + + it('carries the body tags through the list render', () => { + const tags = noteTagsByPath(notes) + const collapsed = { + today: false, + upcoming: false, + undated: false, + waiting: false, + forwarded: false, + done: false, + cancelled: false + } + const render = computeTasksRender([fileTask, checkbox], '#errands', new Date(), collapsed, 'note', tags) + expect(render.filtered).toEqual([fileTask]) + }) +}) + describe('applyFileOrder', () => { it('orders a group by task index within a note', () => { const out = applyFileOrder(groups([task('a.md', 2), task('a.md', 0), task('a.md', 1)])) diff --git a/packages/app-core/src/lib/tasks-filter.ts b/packages/app-core/src/lib/tasks-filter.ts index 69e7eb6ac..ead576be8 100644 --- a/packages/app-core/src/lib/tasks-filter.ts +++ b/packages/app-core/src/lib/tasks-filter.ts @@ -1,24 +1,51 @@ +import type { NoteMeta } from '@shared/ipc' import type { VaultTask, VaultTaskGroups } from '@shared/tasks' -import { groupTasks, isOverdue } from '@shared/tasks' +import { TASK_FILE_TAG, groupTasks, isOverdue } from '@shared/tasks' + +/** Each note's tags by vault path: what `filterTasks` reads for a file task's + * body. Built from the note list the Tags view reads, so the two agree. */ +export type NoteTagsByPath = ReadonlyMap + +export function noteTagsByPath(notes: readonly Pick[]): NoteTagsByPath { + return new Map(notes.map((note) => [note.path, note.tags])) +} /** Simple substring filter across content, note title, tags, fields, and * priority. Tags match with the leading `#` so `#proj` narrows the way it * reads in a note. `@key:value` fields are stripped from the display content, * so they match against the reassembled token: `@project`, `project:alpha`, - * and plain `alpha` all reach a task carrying `@project:alpha`. */ -export function filterTasks(tasks: VaultTask[], query: string): VaultTask[] { + * and plain `alpha` all reach a task carrying `@project:alpha`. + * + * A file task (a note with `tags: [task]`) is its whole note, so a `#tag` + * written in the note's body narrows to it too, read from `noteTags` (#1008). + * Its `tags` hold only the frontmatter list, which is what every parser copy + * (MCP, zn, the server) reports, so this widens the filter and nothing else. + * A checkbox task keeps to its own line: a tag elsewhere in its note says + * nothing about that one task. */ +export function filterTasks( + tasks: VaultTask[], + query: string, + noteTags?: NoteTagsByPath | null +): VaultTask[] { const q = query.trim().toLowerCase() if (!q) return tasks + const tagMatches = (tag: string): boolean => `#${tag.toLowerCase()}`.includes(q) return tasks.filter((task) => { if (task.content.toLowerCase().includes(q)) return true if (task.noteTitle.toLowerCase().includes(q)) return true if (task.priority && `!${task.priority}`.includes(q)) return true - if (task.tags.some((t) => `#${t.toLowerCase()}`.includes(q))) return true + if (task.tags.some(tagMatches)) return true if (task.fields) { for (const [key, value] of Object.entries(task.fields)) { if (`@${key}:${value}`.toLowerCase().includes(q)) return true } } + if (task.kind === 'file' && noteTags) { + // The `task` marker itself is left out, as parseTaskFile leaves it out + // of `tags`: "#task" should not narrow to every file task. + const bodyTags = noteTags.get(task.sourcePath) + if (bodyTags?.some((t) => t.toLowerCase() !== TASK_FILE_TAG && tagMatches(t))) return true + } return false }) } @@ -151,9 +178,10 @@ export function computeTasksRender( done: boolean cancelled: boolean }, - order: 'note' | 'priority' = 'note' + order: 'note' | 'priority' = 'note', + noteTags?: NoteTagsByPath | null ): TasksRender { - const filtered = filterTasks(tasks, filter) + const filtered = filterTasks(tasks, filter, noteTags) const grouped = groupTasks(filtered, today) const groups = order === 'priority' ? applyPriorityOrder(grouped) : applyFileOrder(grouped) const rows = flattenRows(groups, collapsed) From 57f9e8b36ea9d29a35116e4756a679336a98ed59 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 10:20:38 -0500 Subject: [PATCH 11/34] Fix(editor): select all takes the note's text and leaves its title alone (#1006) Ctrl+A in a note selected the title along with the text. The title here is not the header or a separate field: a new note is written as `# `, a rename keeps that heading in step with the file name (#455), and Live Preview shows it as the large heading at the top of the page. CodeMirror's select all takes the whole document, so it took the title too, and typing or pasting over a select all replaced it. Measured on the built app over CDP with a `# Shopping list` note: every mode that selects (Vim off, Vim insert, Vim normal and visual on macOS, Live Preview and source) selected 0 to 82 of 82 characters, and the DOM selection never left the editor. Nothing outside the editor could be the title on screen anyway, since the app chrome is user-select: none. Select all from the note's text now selects that text: from the first non-blank line under the title to the end of the note. A second press selects the whole note, frontmatter and title included, as before. The first press does that too when the cursor is on the title or above it, where the user is editing the title rather than the text, and in a note that does not start with a `# ` heading. The title line is the same one a rename retitles: `leadingTitleHeading` in shared-domain now locates it for both, and `retitleLeadingHeading` is rebuilt on it unchanged. Every editor keymap gets the new select all from one place, Vim on or off and on every platform, and the editor menu's Select All runs the same command. #993 stays as it was. In Vim normal and visual mode off macOS, Ctrl+A is still Vim's increment. With no number on the rest of the line it does nothing, as in Vim, and Vim still consumes the key; it does not fall back to select all. That would make Ctrl+A increment or select depending on where the cursor sits, and a missed increment would leave a visual selection for the next `x` or `c` to delete. `ggVG` selects in Vim, and insert mode selects with Ctrl+A. On the built macOS app over CDP, Cmd+A from the text now selects 17 to 82 (the text) in every mode that selects, a second press 0 to 82, and from the title line 0 to 82. The non-mac keymap was checked in the same build: the preload reported linux and navigator.platform was Linux, so both the app's platform check and CodeMirror's Mod read Ctrl, and Ctrl+A carried Chromium's Linux and Windows default action (selectAll), which runs only when nothing handles the key. Before, Vim off and insert mode selected 0 to 82; after, 17 to 82, then 0 to 82. Normal mode increments `Buy 3` to `Buy 4` and does nothing on a line without a number, before and after. The editor menu's Select All selects the text, and typing over Cmd+A leaves `# Shopping list` in the file on disk. Tests: select all dispatched through the keymap and Vim in the editors' key order (fails on the old keymap), the title locator, and the guards for #993. app-core 3,619 tests, shared-domain 2,184 and desktop 1,221 pass; typecheck clean. The website docs mirror of the new help entry is not in this change. --- .../app-core/src/components/EditorPane.tsx | 5 +- .../app-core/src/lib/cm-select-note.test.ts | 124 ++++++++++++++++++ packages/app-core/src/lib/cm-select-note.ts | 48 +++++++ .../app-core/src/lib/cm-vim-default-keymap.ts | 25 +++- packages/app-core/src/lib/help.ts | 1 + .../src/lib/note-heading-sync.test.ts | 21 +++ .../shared-domain/src/note-heading-sync.ts | 24 +++- 7 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 packages/app-core/src/lib/cm-select-note.test.ts create mode 100644 packages/app-core/src/lib/cm-select-note.ts diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 216f1ac8e..9b0895ba5 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -47,13 +47,13 @@ import { moveLineUp, redo, redoDepth, - selectAll, undo, undoDepth } from '@codemirror/commands' import { isImeComposing } from '../lib/ime' import { displayRowBoundaryKeymap } from '../lib/cm-display-row' import { scrollerClickFocus } from '../lib/cm-scroller-click-focus' +import { selectNoteText } from '../lib/cm-select-note' import { appHistoryKeymap } from '../lib/history-keymap' import { noteMarkdown } from '../lib/cm-markdown-language' import { customCodeFenceHighlightExtension } from '../lib/cm-custom-code-languages' @@ -4983,7 +4983,8 @@ function buildEditorContextItems( label: 'Select All', hint: formatKeyToken('Mod+A'), onSelect: async () => { - selectAll(view) + // The same select all as Mod+A, title left out first (#1006). + selectNoteText(view) view.focus() } }, diff --git a/packages/app-core/src/lib/cm-select-note.test.ts b/packages/app-core/src/lib/cm-select-note.test.ts new file mode 100644 index 000000000..ba9bd559d --- /dev/null +++ b/packages/app-core/src/lib/cm-select-note.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { vim } from '@replit/codemirror-vim' +import { selectNoteText } from './cm-select-note' +import { vimAwareDefaultKeymap } from './cm-vim-default-keymap' + +// #1006: a new note is `# <title>` and a rename keeps that heading in step with +// the file name, so select all from the note's text leaves it out. jsdom's +// navigator is not a Mac, so Mod is Ctrl here, as on Linux and Windows. +const NOTE = '# Shopping list\n\nBuy 3 apples and 2 pears.\nMilk and bread.\n' +const TEXT_FROM = NOTE.indexOf('Buy') +const MILK = NOTE.indexOf('Milk') + +describe('select all leaves the note title out (#1006)', () => { + const views: EditorView[] = [] + afterEach(() => { + views.splice(0).forEach((v) => v.destroy()) + }) + + // Keymap first, Vim second: the order the editors run them in (#993). + const mount = (doc: string, { vimMode = false, at = doc.length } = {}): EditorView => { + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: { anchor: at }, + extensions: [keymap.of([...vimAwareDefaultKeymap(vimMode, false)]), vimMode ? vim() : []] + }), + parent: document.body + }) + views.push(view) + view.focus() + return view + } + + const press = (view: EditorView, key: string, keyCode: number, ctrlKey = false): boolean => { + const event = new KeyboardEvent('keydown', { key, keyCode, ctrlKey, bubbles: true, cancelable: true }) + view.contentDOM.dispatchEvent(event) + return event.defaultPrevented + } + const ctrlA = (view: EditorView): boolean => press(view, 'a', 65, true) + const selected = (view: EditorView): [number, number] => [ + view.state.selection.main.from, + view.state.selection.main.to + ] + + it('selects the text under the title, and the whole note on a second press', () => { + const view = mount(NOTE, { at: MILK }) + expect(ctrlA(view)).toBe(true) + expect(selected(view)).toEqual([TEXT_FROM, NOTE.length]) + expect(view.state.sliceDoc(...selected(view))).toBe('Buy 3 apples and 2 pears.\nMilk and bread.\n') + expect(ctrlA(view)).toBe(true) + expect(selected(view)).toEqual([0, NOTE.length]) + }) + + it('counts the blank line under the title as the text, and skips frontmatter above it', () => { + const fm = '---\ntags: [food]\n---\n' + const view = mount(fm + NOTE, { at: fm.length + NOTE.indexOf('\n\n') + 1 }) + ctrlA(view) + expect(selected(view)).toEqual([fm.length + TEXT_FROM, fm.length + NOTE.length]) + }) + + it('selects the whole note from the title line or the frontmatter', () => { + const onTitle = mount(NOTE, { at: 4 }) + ctrlA(onTitle) + expect(selected(onTitle)).toEqual([0, NOTE.length]) + + const fm = '---\ntags: [food]\n---\n' + const inFrontmatter = mount(fm + NOTE, { at: 6 }) + ctrlA(inFrontmatter) + expect(selected(inFrontmatter)).toEqual([0, fm.length + NOTE.length]) + }) + + it('selects the whole note when there is no title to leave out', () => { + for (const doc of ['Buy 3 apples.\nMilk.\n', '## Section\n\nBuy 3 apples.\n', 'Intro\n# Later heading\n\ntext\n', '# Just a title\n\n']) { + const view = mount(doc) + ctrlA(view) + expect(selected(view), JSON.stringify(doc)).toEqual([0, doc.length]) + } + }) + + it('selects the text from Vim insert mode, where Ctrl+A is select all', () => { + const view = mount(NOTE, { vimMode: true, at: MILK }) + press(view, 'i', 73) + expect(ctrlA(view)).toBe(true) + expect(selected(view)).toEqual([TEXT_FROM, NOTE.length]) + }) + + // #993 stays: in normal mode off macOS Ctrl+A is Vim's increment. With no + // number on the rest of the line it does nothing, as in Vim, and still + // consumes the key, so the browser's own select all (which would take the + // whole window while the content is not editable) never runs. + it('leaves Ctrl+A to Vim in normal mode: increment, or nothing without a number', () => { + const number = mount(NOTE, { vimMode: true, at: TEXT_FROM }) + expect(ctrlA(number)).toBe(true) + expect(number.state.doc.toString()).toBe(NOTE.replace('Buy 3', 'Buy 4')) + expect(number.state.selection.main.empty).toBe(true) + + const plain = mount(NOTE, { vimMode: true, at: MILK }) + expect(ctrlA(plain)).toBe(true) + expect(plain.state.doc.toString()).toBe(NOTE) + expect(selected(plain)).toEqual([MILK, MILK]) + }) + + it('is the command behind Mod-a in every keymap, Vim on or off, macOS or not', () => { + for (const vimMode of [false, true]) { + for (const mac of [false, true]) { + const binding = vimAwareDefaultKeymap(vimMode, mac).find((b) => b.key === 'Mod-a') + const view = mount(NOTE, { at: MILK }) + expect(binding?.run?.(view), `vim ${vimMode} mac ${mac}`).toBe(true) + expect(selected(view), `vim ${vimMode} mac ${mac}`).toEqual([TEXT_FROM, NOTE.length]) + } + } + }) + + it('runs as a plain command too (the editor menu’s Select All)', () => { + const view = mount(NOTE, { at: MILK }) + expect(selectNoteText(view)).toBe(true) + expect(selected(view)).toEqual([TEXT_FROM, NOTE.length]) + expect(selectNoteText(view)).toBe(true) + expect(selected(view)).toEqual([0, NOTE.length]) + }) +}) diff --git a/packages/app-core/src/lib/cm-select-note.ts b/packages/app-core/src/lib/cm-select-note.ts new file mode 100644 index 000000000..75c079878 --- /dev/null +++ b/packages/app-core/src/lib/cm-select-note.ts @@ -0,0 +1,48 @@ +import type { EditorState } from '@codemirror/state' +import type { Command } from '@codemirror/view' +import { leadingTitleHeading } from '@shared/note-heading-sync' + +/** + * Select all, short of the note's title (#1006). + * + * A new note is written as `# <title>`, and a rename keeps that heading in + * step with the file name (#455), so the leading H1 is the note's title as + * much as the file name is; Live Preview shows it as the large heading at the + * top of the page. CodeMirror's select all took it along with the text, so + * typing or pasting over a select all replaced the title too. + * + * From the note's text, select all now selects that text: from the first + * non-blank line under the title to the end of the note. A second press + * selects the whole note, frontmatter and title included, as select all did + * before. The first press does too when the cursor is on the title or above + * it (there the user is editing the title, not the text under it) and in a + * note without a leading `# ` heading, which has no title to leave out. + */ +function textUnderTitle(state: EditorState): { from: number; to: number; titleEnd: number } | null { + const heading = leadingTitleHeading(state.doc.toString()) + if (!heading) return null + const doc = state.doc + for (let n = doc.lineAt(heading.to).number + 1; n <= doc.lines; n++) { + const line = doc.line(n) + if (line.text.trim()) return { from: line.from, to: doc.length, titleEnd: heading.to } + } + return null +} + +export const selectNoteText: Command = ({ state, dispatch }) => { + const text = textUnderTitle(state) + const { ranges, main } = state.selection + const inText = text !== null && ranges.every((range) => range.from > text.titleEnd) + const textSelected = + text !== null && ranges.length === 1 && main.from === text.from && main.to === text.to + dispatch( + state.update({ + selection: + text && inText && !textSelected + ? { anchor: text.from, head: text.to } + : { anchor: 0, head: state.doc.length }, + userEvent: 'select' + }) + ) + return true +} diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.ts b/packages/app-core/src/lib/cm-vim-default-keymap.ts index 9b9177b96..8cc1d5999 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -1,4 +1,4 @@ -import { defaultKeymap } from '@codemirror/commands' +import { defaultKeymap, selectAll } from '@codemirror/commands' import { markdownKeymap } from '@codemirror/lang-markdown' import { Prec, type Extension } from '@codemirror/state' import { keymap, type Command, type EditorView, type KeyBinding } from '@codemirror/view' @@ -12,6 +12,7 @@ import { import { getCM } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' import { insertNewlineContinueFrontmatterList } from './cm-frontmatter' +import { selectNoteText } from './cm-select-note' import { isMacPlatform } from './keymaps' /** @@ -56,8 +57,17 @@ const MAC_EMACS_CHORDS = new Set([ const isMacEmacsChord = (binding: KeyBinding): boolean => typeof binding.mac === 'string' && MAC_EMACS_CHORDS.has(binding.mac) +/** + * `defaultKeymap` with select all stopping short of the note's title the + * first time (#1006, see cm-select-note). Every keymap below starts here, so + * Vim on or off, macOS or not, Mod-a selects the same way. + */ +const noteDefaultKeymap: readonly KeyBinding[] = defaultKeymap.map((binding) => + binding.run === selectAll ? { ...binding, run: selectNoteText } : binding +) + /** `defaultKeymap` with the macOS emacs-style control chords removed. */ -const defaultKeymapWithoutMacEmacs: readonly KeyBinding[] = defaultKeymap.filter( +const defaultKeymapWithoutMacEmacs: readonly KeyBinding[] = noteDefaultKeymap.filter( (binding) => !isMacEmacsChord(binding) ) @@ -167,6 +177,12 @@ const vimModeKeymap: readonly KeyBinding[] = deferEscapeToVim( * no binding here and always reached Vim.) Like the motion keys it defers to * Vim outside insert mode, so insert mode keeps select all. macOS's Cmd+A * never collided. + * + * With no number on the rest of the line, `<C-a>` does nothing, as in Vim, + * and Vim still consumes the key. It deliberately does not fall back to + * select all (#1006): whether Ctrl+A increments or selects the note would then + * hang on where the cursor sits, and a missed increment would leave a visual + * selection for the next `x` or `c` to delete. `ggVG` selects in Vim. */ const vimModeKeymapModIsCtrl: readonly KeyBinding[] = deferKeysToVim( vimModeKeymap, @@ -179,13 +195,14 @@ const vimModeKeymapModIsCtrl: readonly KeyBinding[] = deferKeysToVim( * bindings work, the arrow keys defer to Vim in normal/visual mode so they * move/extend like `hjkl`, Escape defers to Vim in insert/visual mode so it * leaves the mode, and off macOS Ctrl+A defers to Vim outside insert mode; - * with Vim off the full keymap is used unchanged. + * with Vim off the full keymap is used. In every case select all leaves the + * note's title out on its first press (#1006). */ export function vimAwareDefaultKeymap( vimMode: boolean, mac: boolean = isMacPlatform() ): readonly KeyBinding[] { - if (!vimMode) return defaultKeymap + if (!vimMode) return noteDefaultKeymap return mac ? vimModeKeymap : vimModeKeymapModIsCtrl } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 81fd3974a..f7972b91d 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -576,6 +576,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+A', action: 'Select the note\u2019s text', detail: 'In the editor, select everything under the note\u2019s `# ` title heading, the line a new note starts with; press again to select the whole note, title and frontmatter included. With the cursor on the title, or in a note that does not start with a `# ` heading, the first press selects the whole note. With Vim mode on Linux and Windows, Ctrl+A in normal and visual mode stays Vim\u2019s increment (and does nothing without a number); it selects from insert mode, and `ggVG` selects in normal mode.' }, { 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: 'Shift+Mod+K', action: 'Open link popover', detail: 'Open the link popover for the [[wikilink]] under the cursor, keyboard inside: edit the note it names in place, or create it when it does not exist yet. Escape closes it and returns to the link; Mod+Enter opens the note in a tab. Works with Vim mode on or off (Vim mode also has `K`). Remappable as editor.linkPopover.' }, { 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.' }, diff --git a/packages/app-core/src/lib/note-heading-sync.test.ts b/packages/app-core/src/lib/note-heading-sync.test.ts index f7fd4fb33..90064d410 100644 --- a/packages/app-core/src/lib/note-heading-sync.test.ts +++ b/packages/app-core/src/lib/note-heading-sync.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { leadingTitleHeading } from '@shared/note-heading-sync' import { retitleLeadingHeading } from './note-heading-sync' describe('retitleLeadingHeading (#455)', () => { @@ -77,3 +78,23 @@ describe('retitleLeadingHeading (#455)', () => { expect(retitleLeadingHeading('# Untitled\n', '')).toBe('# Untitled\n') }) }) + +// The same line, located rather than rewritten: what select all leaves out (#1006). +describe('leadingTitleHeading', () => { + const at = (body: string): string | null => { + const found = leadingTitleHeading(body) + return found ? body.slice(found.from, found.to) : null + } + + it('finds the title past frontmatter and blank lines, without the line break', () => { + expect(at('# Groceries\n\nmilk\n')).toBe('# Groceries') + expect(at('---\ntags: [x]\n---\n\n # Groceries\r\nmilk\r\n')).toBe(' # Groceries') + expect(at('#\n')).toBe('#') + }) + + it('has no title when the first line is anything but an H1', () => { + for (const body of ['', '\n\n', 'milk\n# Groceries\n', '## Groceries\n', '#Groceries\n', ' # code\n']) { + expect(leadingTitleHeading(body), JSON.stringify(body)).toBeNull() + } + }) +}) diff --git a/packages/shared-domain/src/note-heading-sync.ts b/packages/shared-domain/src/note-heading-sync.ts index 5ce086607..884adf74d 100644 --- a/packages/shared-domain/src/note-heading-sync.ts +++ b/packages/shared-domain/src/note-heading-sync.ts @@ -37,6 +37,22 @@ export function retitleLeadingHeading(body: string, title: string): string { // sanitizing strips control chars, so this is belt-and-braces. if (!title || /[\r\n]/.test(title)) return body + const heading = leadingTitleHeading(body) + if (!heading) return body + const line = body.slice(heading.from, heading.to) + const next = `${H1_LINE_RE.exec(line)?.[1] ?? ''}# ${title}` + if (next === line) return body + return body.slice(0, heading.from) + next + body.slice(heading.to) +} + +/** + * Where the note's title heading sits: the first non-blank line after any + * frontmatter, when it is a `# ` heading, as offsets into `body` that leave + * out the line break (and a CRLF's `\r`). Null when that line is anything + * else, or when there is no such line. It is the line a rename retitles, and + * the line the editor's select all leaves out (#1006). + */ +export function leadingTitleHeading(body: string): { from: number; to: number } | null { const fm = FRONTMATTER_BLOCK_RE.exec(body) let lineStart = fm ? fm[0].length : 0 @@ -46,14 +62,10 @@ export function retitleLeadingHeading(body: string, title: string): string { const raw = body.slice(lineStart, lineEnd) const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw if (line.trim()) { - const m = H1_LINE_RE.exec(line) - if (!m) return body - const next = `${m[1]}# ${title}` - if (next === line) return body - return body.slice(0, lineStart) + next + body.slice(lineStart + line.length) + return H1_LINE_RE.test(line) ? { from: lineStart, to: lineStart + line.length } : null } if (nl === -1) break lineStart = nl + 1 } - return body + return null } From 27c30cdc98d728889cfd614976245e07948c1b4e Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 10:26:36 -0500 Subject: [PATCH 12/34] Fix(share): the Share sheet draws its content instead of an empty window that dims Safari The first real Safari share opened the ZenNotes sheet as an empty window: Safari dimmed behind it, typing reached the note line, and nothing showed. Safari had done its part (the page script handed over the title, address and selection). The view was built in code with a zero frame, and the share sheet takes the view's size when it presents, before Auto Layout has run. The root view now starts with a real frame, and the controller sizes itself to its fitted layout and reports it as preferredContentSize whenever what it shows changes (the shared text, the vault row, the notice). The second line under the title shows the page's address without its scheme, so a page shown by its host alone still says which page it is. Verified: the NSSharingService harness, which had shown the same empty window, draws the full sheet; then a real Safari share of the Zen article with a selection saved on Return and was filed with the page's own title, the quoted selection, the note line and the link. --- .../macos/ShareViewController.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/share-extension/macos/ShareViewController.swift b/apps/share-extension/macos/ShareViewController.swift index e2f6e1ed6..b78681009 100644 --- a/apps/share-extension/macos/ShareViewController.swift +++ b/apps/share-extension/macos/ShareViewController.swift @@ -148,7 +148,10 @@ final class ShareViewController: NSViewController { } stack.translatesAutoresizingMaskIntoConstraints = false - let root = NSView() + // A frame from the start: the share sheet takes the view's size when it + // presents, before Auto Layout has run, and a zero frame showed an + // empty sheet that only dimmed the host window. + let root = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 240)) root.addSubview(stack) NSLayoutConstraint.activate([ root.widthAnchor.constraint(equalToConstant: 440), @@ -158,6 +161,17 @@ final class ShareViewController: NSViewController { stack.bottomAnchor.constraint(equalTo: root.bottomAnchor) ]) view = root + fitToContent() + } + + /// Sizes the sheet to what it shows: the preview grows with the shared + /// text, and the vault row and notice come and go. + private func fitToContent() { + view.layoutSubtreeIfNeeded() + let size = NSSize(width: 440, height: ceil(view.fittingSize.height)) + guard size.height > 0, size != preferredContentSize else { return } + view.setFrameSize(size) + preferredContentSize = size } override func viewDidLoad() { @@ -307,7 +321,9 @@ final class ShareViewController: NSViewController { .split(separator: "\n", omittingEmptySubsequences: true) .first.map { $0.trimmingCharacters(in: .whitespaces) } titleLabel.stringValue = content.title ?? (url == nil ? firstLine : nil) ?? url?.host ?? "Shared text" - detailLabel.stringValue = url.map { $0.host ?? $0.absoluteString } ?? "" + // The address without its scheme, so a page shown by its host alone + // still says which page it is. + detailLabel.stringValue = url.map { $0.absoluteString.replacingOccurrences(of: #"^https?://(www\.)?"#, with: "", options: .regularExpression) } ?? "" detailLabel.isHidden = url == nil let excerptText = url == nil && content.title == nil ? content.text?.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: true).dropFirst().first.map(String.init) @@ -316,6 +332,7 @@ final class ShareViewController: NSViewController { (excerptLabel.superview as? NSStackView)?.isHidden = (excerptText ?? "").isEmpty saveButton.isEnabled = url != nil || content.text != nil updateNotice() + fitToContent() #if SHARE_SELFTEST let marker = [content.title, content.text, url?.absoluteString].compactMap { $0 }.joined(separator: " ") log.error("selftest received: title=\(self.content.title ?? "-", privacy: .public) url=\(url?.absoluteString ?? "-", privacy: .public) text=\(self.content.text?.prefix(80) ?? "-", privacy: .public)") @@ -418,6 +435,7 @@ final class ShareViewController: NSViewController { notice.stringValue = "" } notice.isHidden = notice.stringValue.isEmpty + if isViewLoaded { fitToContent() } } // MARK: Save and cancel From 605b023b1905c9038786982e3d27554b3ec4aa00 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 10:26:50 -0500 Subject: [PATCH 13/34] Feat(share): each vault chooses where Share > ZenNotes files a share Shares always landed in Quick Notes. Settings > Vault > Notes now has a Default shares location beside the drawings, databases and tasks location rows: Quick Notes (the default), the primary notes location, or a specific folder inside it such as Clippings. Search finds it by "safari", "share" or "clippings". It is a vault setting, `sharesLocation` in vault.json, like the other location settings, so each vault keeps its own choice and it follows the vault to every machine. shared-domain's share-location module reads it the same way for every caller: the main process resolves it per vault when it files a share (createNote still resolves remapped system folders), and the vault list it keeps for the sheet now names each vault's destination, so the sheet's heading says where a share will go ("Save to Clippings"). The list is rewritten when a vault's settings are saved or change on disk, not only when the remembered vaults change. The normalizers in main and app-core and the settings-conflict sections carry the new key, so no round trip drops it. What it does not do: the row shows only in the macOS desktop app with a local vault. Windows, Linux and the web have no share extension, and a self-hosted server rebuilds vault.json from the fields it knows, so it would drop the setting; a server vault keeps Quick Notes until znserver mirrors the field. No "active note's folder" mode: a share is filed with no note in view. The in-app manual and the website docs describe it. Verified in the packed app over CDP: searching Settings for "safari" lands on the row; Specific folder with Clippings saved to vault.json, the sheet's vault list and heading said Clippings, and a share was filed in inbox/Clippings/; switching to Primary location relabelled the vault Inbox. Unit tests cover the resolver and labels, the vault.json round trip, the destination list, and the Settings row on macOS and its absence elsewhere. --- apps/desktop/src/main/index.ts | 68 +++++++++++++++---- apps/desktop/src/main/share-inbox.test.ts | 13 ++++ apps/desktop/src/main/share-inbox.ts | 23 ++++++- apps/desktop/src/main/vault.test.ts | 9 +++ apps/desktop/src/main/vault.ts | 5 ++ apps/share-extension/README.md | 6 +- .../macos/ShareViewController.swift | 21 ++++-- .../src/components/SettingsModal.test.ts | 36 +++++++++- .../app-core/src/components/SettingsModal.tsx | 59 ++++++++++++++++ packages/app-core/src/lib/help.ts | 3 +- packages/app-core/src/lib/vault-layout.ts | 2 + .../src/lib/vault-settings-conflict.ts | 1 + packages/bridge-contract/src/ipc.ts | 15 ++++ .../shared-domain/src/share-location.test.ts | 30 ++++++++ packages/shared-domain/src/share-location.ts | 47 +++++++++++++ .../src/vault-settings-conflict.test.ts | 1 + .../src/vault-settings-conflict.ts | 1 + 17 files changed, 315 insertions(+), 25 deletions(-) create mode 100644 packages/shared-domain/src/share-location.test.ts create mode 100644 packages/shared-domain/src/share-location.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b2a14b1e6..deb0d2e20 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -258,8 +258,10 @@ import { SHARE_CAPTURES_FOLDER, shareInboxFromInfoPlist, shareVaultsFile, + withShareDestinations, writeShareVaults, } from "./share-inbox"; +import { resolveShareLocation, shareLocationLabel } from "@shared/share-location"; import { RemoteRequestError, RemoteServerClient } from "./remote/server-client"; import { getMcpClientStatuses, @@ -426,7 +428,10 @@ const windowVaults = new WindowVaultRegistry({ // Only for events that can actually change the settings: this cache backs // a whole-root readdir that every note read and write already awaits, so // dropping it per event made a save burst pay for one listing per file. - if (vaultChangeAffectsSettings(ev)) invalidateVaultSettingsCache(root); + if (vaultChangeAffectsSettings(ev)) { + invalidateVaultSettingsCache(root); + scheduleShareVaultsRefresh(); + } }, sendVaultChange: (windowId, ev) => { const win = BrowserWindow.fromId(windowId); @@ -828,9 +833,10 @@ function handleStartupDeepLinks(argv: string[]): ExternalOpenUrlResult { } // Share > ZenNotes from the macOS Share menu (share-inbox.ts). The share -// extension leaves each share in the share inbox; they are filed in -// Quick Notes at startup, as they arrive, and when a server one names -// connects. A share made while the app was closed waits for the next launch. +// extension leaves each share in the share inbox; they are filed where each +// vault says (Quick Notes by default) at startup, as they arrive, and when a +// server one names connects. A share made while the app was closed waits for +// the next launch. let shareInboxDir: string | null = null; let shareDrainRunning = false; let shareDrainAgain = false; @@ -891,8 +897,13 @@ async function runShareDrain(): Promise<void> { const meta = await fetchLinkMetadata(url); return meta.ok ? meta.title : undefined; }, + // Each vault says where its shares go (Settings > Notes > Default + // shares location); Quick Notes when it says nothing. fileLocal: async (root, title, body) => { - const meta = await createNote(root, "quick", title); + const { folder, subpath } = resolveShareLocation( + (await getVaultSettings(root)).sharesLocation, + ); + const meta = await createNote(root, folder, title, subpath); await writeNote(root, meta.path, body); return meta.path; }, @@ -901,7 +912,10 @@ async function runShareDrain(): Promise<void> { if (!client || currentRemoteWorkspaceProfileId !== profileId) { return null; } - const meta = await client.createNote("quick", title); + const { folder, subpath } = resolveShareLocation( + (await client.getVaultSettings()).sharesLocation, + ); + const meta = await client.createNote(folder, title, subpath); await client.writeNote(meta.path, body); return meta.path; }, @@ -924,23 +938,52 @@ async function runShareDrain(): Promise<void> { } } +let shareVaultsTimer: ReturnType<typeof setTimeout> | null = null; + +/** Rewrites the sheet's vault list: the remembered vaults, the current one, + * and where each files a share. A server vault always says Quick Notes: the + * server does not keep the setting yet. */ +async function refreshShareVaults(): Promise<void> { + const dir = shareInboxDir; + if (!dir) return; + try { + const file = await withShareDestinations( + shareVaultsFile(await loadConfig()), + async (choice) => { + if (choice.kind === "remote") return shareLocationLabel(undefined, "inbox", choice.name); + const root = choice.id.slice("local:".length); + const settings = await getVaultSettings(root); + return shareLocationLabel(settings.sharesLocation, settings.primaryNotesLocation, choice.name); + }, + ); + await writeShareVaults(dir, file); + } catch (err) { + console.warn("Could not update the share sheet's vaults", err); + } +} + +function scheduleShareVaultsRefresh(): void { + if (!shareInboxDir) return; + if (shareVaultsTimer) clearTimeout(shareVaultsTimer); + shareVaultsTimer = setTimeout(() => { + shareVaultsTimer = null; + void refreshShareVaults(); + }, 300); +} + async function startShareInbox(): Promise<void> { const dir = await resolveShareInboxDir(); if (!dir) return; const captures = path.join(dir, SHARE_CAPTURES_FOLDER); try { await fsp.mkdir(captures, { recursive: true }); - await writeShareVaults(dir, shareVaultsFile(await loadConfig())); } catch (err) { console.warn("Share inbox unavailable", err); return; } shareInboxDir = dir; - onConfigSaved((cfg) => { - writeShareVaults(dir, shareVaultsFile(cfg)).catch((err) => - console.warn("Could not update the share sheet's vaults", err), - ); - }); + await refreshShareVaults(); + onConfigSaved(() => scheduleShareVaultsRefresh()); try { // An unhandled watcher error (the inbox deleted under it) would throw in // main; shares made after it still file at the next launch. @@ -3871,6 +3914,7 @@ function registerIpc(): void { // The display name may have moved with this save (#692); the windows on // this root and the remembered list follow before the renderer hears back. await syncLocalVaultName(v.root); + scheduleShareVaultsRefresh(); return saved; }); diff --git a/apps/desktop/src/main/share-inbox.test.ts b/apps/desktop/src/main/share-inbox.test.ts index 61b8c59fc..2a87d5bda 100644 --- a/apps/desktop/src/main/share-inbox.test.ts +++ b/apps/desktop/src/main/share-inbox.test.ts @@ -14,6 +14,7 @@ import { shareCaptureTitle, shareInboxFromInfoPlist, shareVaultsFile, + withShareDestinations, writeShareVaults, type ShareCapture, type ShareConfig, @@ -140,6 +141,18 @@ describe('share vaults', () => { }) }) +describe('withShareDestinations', () => { + it('names where each vault files a share, and skips a vault it cannot read', async () => { + const file = await withShareDestinations(shareVaultsFile(BASE_CONFIG), async (choice) => { + if (choice.kind === 'remote') return 'Quick Notes' + if (choice.name === 'Home') throw new Error('gone') + return 'Clippings' + }) + expect(file.vaults.map((vault) => vault.destination)).toEqual(['Clippings', undefined, 'Quick Notes']) + expect(file.current).toBe(localShareKey('/vaults/Work')) + }) +}) + describe('shareInboxFromInfoPlist', () => { it('reads the inbox the extension was built with, inside the home folder only', () => { const plist = (value: string): string => `<key>ZenNotesShareInbox</key>\n <string>${value}</string>` diff --git a/apps/desktop/src/main/share-inbox.ts b/apps/desktop/src/main/share-inbox.ts index 1dde83666..105024775 100644 --- a/apps/desktop/src/main/share-inbox.ts +++ b/apps/desktop/src/main/share-inbox.ts @@ -3,7 +3,8 @@ * app. The share extension (apps/share-extension) is sandboxed and can reach * no vault, so it leaves each share as one JSON file in the share inbox, the * one folder its sandbox opens to it (inside the app's Application Support), - * and the app files them in Quick Notes: at startup, and as they arrive while + * and the app files them as notes (in Quick Notes unless the vault's + * sharesLocation names another place): at startup, and as they arrive while * it runs. The app leaves `vaults.json` there in turn, so the share sheet can * offer the vaults it knows. * @@ -190,6 +191,9 @@ export interface ShareVaultChoice { kind: 'local' | 'remote' /** The server's host for a remote vault. */ detail?: string + /** Where a share to this vault is filed, as the sheet's heading names it + * ("Save to Clippings"); see shareLocationLabel. */ + destination?: string } export interface ShareVaultsFile { @@ -242,6 +246,21 @@ export function shareVaultsFile(cfg: ShareConfig): ShareVaultsFile { return { version: 1, vaults, current: currentShareTarget(cfg)?.key ?? null } } +/** The vault list with each vault's share destination filled in. A vault + * whose label cannot be read (its folder is gone) is listed without one. */ +export async function withShareDestinations( + file: ShareVaultsFile, + destinationFor: (choice: ShareVaultChoice) => Promise<string | undefined> +): Promise<ShareVaultsFile> { + const vaults = await Promise.all( + file.vaults.map(async (choice) => { + const destination = await destinationFor(choice).catch(() => undefined) + return destination ? { ...choice, destination } : choice + }) + ) + return { ...file, vaults } +} + /** The vault a share names, when the app still remembers it; otherwise the * app's current vault. */ export function resolveShareTarget(key: string | null, cfg: ShareConfig): ShareTarget | null { @@ -265,7 +284,7 @@ export interface ShareInboxDeps { loadConfig: () => Promise<ShareConfig> /** The page title for a link shared without one; undefined when offline. */ fetchTitle: (url: string) => Promise<string | undefined> - /** Creates the note in the local vault's Quick Notes; resolves its path. */ + /** Creates the note where the local vault files shares; resolves its path. */ fileLocal: (root: string, title: string, body: string) => Promise<string> /** Creates the note on a server, or null while that server is not the * connected workspace: the share then waits. */ diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 6aa98e2ed..a4b3bb1fd 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -206,6 +206,15 @@ describe('file-location settings round-trip (#446)', () => { const settings = await getVaultSettings(root) expect(settings.tasksLocation).toEqual({ mode: 'primary' }) }) + + it('keeps where shares are filed through set/get, Quick Notes when unset', async () => { + const root = await makeTempDir('zennotes-vault-sharesloc-') + await mkdir(root, { recursive: true }) + const base = await getVaultSettings(root) + expect(base.sharesLocation).toEqual({ mode: 'quick' }) + await setVaultSettings(root, { ...base, sharesLocation: { mode: 'folder', folder: '/Clippings/' } }) + expect((await getVaultSettings(root)).sharesLocation).toEqual({ mode: 'folder', folder: 'Clippings' }) + }) }) describe('remapped system folders (#398)', () => { diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 83d4e0c55..924fff664 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -63,6 +63,7 @@ import { } from '@shared/ipc' import { currentDemoTourPath, DEMO_TOUR_DIR, demoTourNestedDirs } from '@shared/demo-tour' import { normalizeNoteComments } from '@shared/note-comments' +import { normalizeShareLocation } from '@shared/share-location' import { FRONTMATTER_BLOCK_RE, frontmatterTags } from '@shared/frontmatter' import { rewriteAssetReferences } from '@shared/asset-link-rename' import { resolveAssetPathAmong } from '@shared/asset-path-resolution' @@ -906,6 +907,7 @@ function cloneVaultSettings(settings: VaultSettings): VaultSettings { // The value handed back is what the renderer keeps until its next full // reload, so every optional field that was written has to come back too. ...(settings.tasksLocation ? { tasksLocation: { ...settings.tasksLocation } } : {}), + ...(settings.sharesLocation ? { sharesLocation: { ...settings.sharesLocation } } : {}), ...(settings.systemFolderPaths ? { systemFolderPaths: { ...settings.systemFolderPaths } } : {}), @@ -1130,6 +1132,7 @@ function normalizeVaultSettings( drawingsLocation: { mode: 'primary' }, databasesLocation: { mode: 'primary' }, tasksLocation: { mode: 'primary' }, + sharesLocation: { mode: 'quick' }, folderIcons: {}, folderColors: {}, favorites: [] @@ -1167,6 +1170,7 @@ function normalizeVaultSettings( drawingsLocation?: unknown databasesLocation?: unknown tasksLocation?: unknown + sharesLocation?: unknown folderIcons?: Record<string, unknown> | null folderColors?: Record<string, unknown> | null favorites?: unknown @@ -1228,6 +1232,7 @@ function normalizeVaultSettings( drawingsLocation: normalizeFileLocation(candidate.drawingsLocation), databasesLocation: normalizeFileLocation(candidate.databasesLocation), tasksLocation: normalizeFileLocation(candidate.tasksLocation), + sharesLocation: normalizeShareLocation(candidate.sharesLocation), folderIcons, folderColors: normalizeFolderColors(candidate.folderColors), favorites: normalizeFavorites(candidate.favorites), diff --git a/apps/share-extension/README.md b/apps/share-extension/README.md index 436642768..5900f29d3 100644 --- a/apps/share-extension/README.md +++ b/apps/share-extension/README.md @@ -1,7 +1,8 @@ # Share extension (macOS) Share > ZenNotes in Safari and any other app with a Share menu saves the page -(its link and title), the selected text, or both into Quick Notes. +(its link and title), the selected text, or both as a note: in Quick Notes, or +where the vault's Default shares location (Settings → Vault → Notes) says. `ZenNotesShare.appex` ships inside `ZenNotes.app/Contents/PlugIns`. - `macos/ShareViewController.swift`: the sheet. It previews what is shared, @@ -12,7 +13,8 @@ Share > ZenNotes in Safari and any other app with a Share menu saves the page - The app files the shares: `apps/desktop/src/main/share-inbox.ts` drains `Captures/` at startup, as files arrive while it runs, and when a server a share names connects. It creates the note with the vault code every other - note goes through, so a remapped Quick Notes folder is honored, and deletes + note goes through, in the place the vault's `sharesLocation` names (Quick + Notes by default, see `@shared/share-location`), so a remapped folder is honored, and deletes a share only after its note is written. It keeps `vaults.json` in the same folder for the sheet's vault menu. diff --git a/apps/share-extension/macos/ShareViewController.swift b/apps/share-extension/macos/ShareViewController.swift index b78681009..4b1180836 100644 --- a/apps/share-extension/macos/ShareViewController.swift +++ b/apps/share-extension/macos/ShareViewController.swift @@ -19,6 +19,8 @@ private struct VaultChoice { let name: String let kind: String let detail: String? + /// Where a share to this vault is filed, as the app names it. + let destination: String? var label: String { guard kind == "remote" else { return name } @@ -35,10 +37,11 @@ private extension String { /// Share > ZenNotes. The sheet previews what is shared, takes an optional /// note line and a vault, and on Save leaves the share as one JSON file in /// the share inbox, the one folder its sandbox opens to it. The extension -/// reaches no vault: ZenNotes files the share in Quick Notes, at once when it +/// reaches no vault: ZenNotes files the share where the vault says, at once when it /// is running and the next time it opens when it is not. @objc(ShareViewController) final class ShareViewController: NSViewController { + private let headingLabel = NSTextField(labelWithString: "Save to ZenNotes") private let titleLabel = NSTextField(labelWithString: "") private let detailLabel = NSTextField(labelWithString: "") private let excerptLabel = NSTextField(wrappingLabelWithString: "") @@ -67,9 +70,9 @@ final class ShareViewController: NSViewController { icon.imageScaling = .scaleProportionallyUpOrDown icon.widthAnchor.constraint(equalToConstant: 28).isActive = true icon.heightAnchor.constraint(equalToConstant: 28).isActive = true - let heading = NSTextField(labelWithString: "Save to Quick Notes") - heading.font = .boldSystemFont(ofSize: 14) - let header = NSStackView(views: [icon, heading]) + headingLabel.font = .boldSystemFont(ofSize: 14) + headingLabel.lineBreakMode = .byTruncatingMiddle + let header = NSStackView(views: [icon, headingLabel]) header.spacing = 8 titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) @@ -391,7 +394,10 @@ final class ShareViewController: NSViewController { let list = file["vaults"] as? [[String: Any]] else { return } vaults = list.compactMap { entry in guard let id = entry["id"] as? String, let name = entry["name"] as? String else { return nil } - return VaultChoice(id: id, name: name, kind: entry["kind"] as? String ?? "local", detail: entry["detail"] as? String) + return VaultChoice( + id: id, name: name, kind: entry["kind"] as? String ?? "local", + detail: entry["detail"] as? String, destination: entry["destination"] as? String + ) } guard !vaults.isEmpty else { return } vaultMenu.removeAllItems() @@ -420,6 +426,7 @@ final class ShareViewController: NSViewController { } private func updateNotice(error: String? = nil) { + headingLabel.stringValue = "Save to \(selectedVault?.destination ?? "ZenNotes")" if let error { notice.stringValue = error notice.textColor = .systemRed @@ -428,9 +435,9 @@ final class ShareViewController: NSViewController { } notice.textColor = .secondaryLabelColor if selectedVault?.kind == "remote" { - notice.stringValue = "ZenNotes files it in Quick Notes once it is connected to this server." + notice.stringValue = "ZenNotes files it once it is connected to this server." } else if !appRunning { - notice.stringValue = "ZenNotes files it in Quick Notes the next time it opens." + notice.stringValue = "ZenNotes files it the next time it opens." } else { notice.stringValue = "" } diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 795e849b3..6b36daa73 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -102,6 +102,7 @@ const mocks = vi.hoisted(() => { return { state, runtime: "desktop" as "desktop" | "web", + platform: "linux" as string, capabilities: {} as Record<string, boolean>, setSettingsOpen: state.setSettingsOpen, setVaultSettings: state.setVaultSettings, @@ -126,7 +127,7 @@ vi.mock("../lib/app-update-state", () => ({ vi.mock("@zennotes/bridge-contract/bridge", () => ({ getZenBridge: () => ({ - platformSync: () => "linux", + platformSync: () => mocks.platform, getAppInfo: () => ({ runtime: mocks.runtime, version: "2.4.0", @@ -162,6 +163,7 @@ const REACHED_THROUGH_LOCATION_ROW = new Set([ "drawings-folder", "databases-folder", "tasks-folder", + "shares-folder", ]); type BuiltSettingsCategory = SettingsSearchCategory & { @@ -205,6 +207,7 @@ describe("SettingsModal date note directories", () => { beforeEach(() => { vi.clearAllMocks(); mocks.runtime = "desktop"; + mocks.platform = "linux"; originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, "scrollIntoView"); Object.defineProperty(Element.prototype, "scrollIntoView", { configurable: true, value: vi.fn() }); mocks.state.vimMode = false; @@ -312,6 +315,37 @@ describe("SettingsModal date note directories", () => { } }); + it("offers the shares location on macOS and saves a specific folder", async () => { + mocks.platform = "darwin"; + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "safari")); + const result = [...host.querySelectorAll<HTMLButtonElement>("aside nav button")].find( + (button) => button.textContent?.includes("Default shares location"), + ); + expect(result).toBeTruthy(); + await act(async () => result!.click()); + const row = host.querySelector('[data-settings-search-id="shares-location"]'); + const folder = [...(row?.querySelectorAll<HTMLButtonElement>("button") ?? [])].find( + (button) => button.textContent === "Specific folder", + ); + expect(folder).toBeTruthy(); + await act(async () => folder!.click()); + expect(mocks.setVaultSettings).toHaveBeenCalledWith( + expect.objectContaining({ sharesLocation: { mode: "folder" } }), + ); + }); + + it("leaves the shares location out where there is no Share menu extension", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "safari")); + const result = [...host.querySelectorAll<HTMLButtonElement>("aside nav button")].find( + (button) => button.textContent?.includes("Default shares location"), + ); + expect(result).toBeFalsy(); + }); + it("says where a specific tasks folder really lands for the vault's layout", async () => { const original = mocks.state.vaultSettings; const withTasksFolder = (primaryNotesLocation: "inbox" | "root") => diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 98b1833d9..3e97ec444 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -798,6 +798,12 @@ export function SettingsModal(): JSX.Element { const setNoteCoverPlacement = useStore((s) => s.setNoteCoverPlacement); const vault = useStore((s) => s.vault); const workspaceMode = useStore((s) => s.workspaceMode); + // Share > ZenNotes is a macOS share extension inside the desktop app, and a + // self-hosted server does not keep the setting yet (share-inbox.ts). + const sharesLocationAvailable = + appInfo.runtime === "desktop" && + zenBridge.platformSync() === "darwin" && + workspaceMode !== "remote"; const renameVault = useStore((s) => s.renameVault); const vaultDisplayName = useStore((s) => s.vaultSettings.displayName ?? ""); const remoteWorkspaceInfo = useStore((s) => s.remoteWorkspaceInfo); @@ -4055,6 +4061,22 @@ export function SettingsModal(): JSX.Element { "todos", ], }, + { + id: "shares-location", + title: "Default shares location", + description: + "Where pages and text shared from Safari and other apps (Share > ZenNotes) are filed: Quick Notes, your primary notes area, or a folder you name.", + keywords: [ + "share", + "shares", + "share menu", + "share extension", + "safari", + "clippings", + "web clipper", + ], + available: sharesLocationAvailable, + }, { id: "view-settings-scope", title: "View settings", @@ -4647,6 +4669,7 @@ export function SettingsModal(): JSX.Element { "drawings-location", "databases-location", "tasks-location", + ...(sharesLocationAvailable ? ["shares-location"] : []), "view-settings-scope", ], content: ( @@ -4787,6 +4810,42 @@ export function SettingsModal(): JSX.Element { } /> )} + {sharesLocationAvailable && ( + <SegmentedRow + label="Default shares location" + description="Where a page or text shared from Safari and other apps with Share > ZenNotes is filed. `Specific folder` uses a folder you name inside your primary notes area." + value={vaultSettings.sharesLocation?.mode ?? "quick"} + settingId="shares-location" + options={[ + { value: "quick", label: "Quick Notes" }, + { value: "primary", label: "Primary location" }, + { value: "folder", label: "Specific folder" }, + ]} + onChange={(mode) => + void persistVaultSettings({ + ...vaultSettings, + sharesLocation: { ...vaultSettings.sharesLocation, mode }, + }) + } + /> + )} + {sharesLocationAvailable && + vaultSettings.sharesLocation?.mode === "folder" && ( + <TextInputRow + label="Shares folder" + description={`A folder inside your primary notes area, e.g. \`Clippings\`. Shares go to ${specificFolderDestinationLabel(vaultSettings.sharesLocation?.folder, vaultSettings)}.`} + value={vaultSettings.sharesLocation?.folder ?? ""} + placeholder="Clippings" + settingId="shares-folder" + commitOnBlur + onChange={(next) => + void persistVaultSettings({ + ...vaultSettings, + sharesLocation: { mode: "folder", folder: next ?? "" }, + }) + } + /> + )} </Section> <Section title="View settings" diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 1942519b8..eefb28049 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -99,7 +99,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Save a page or text from Safari and other apps (Share → ZenNotes)', body: - 'On macOS, choose Share → ZenNotes in Safari or any app with a Share menu to save the page you are on, the text you selected, or both, into Quick Notes. The sheet shows what you are saving, a line for an optional note, and, when you use more than one vault, the vault it goes to: your local and Cloud vaults and the servers you have connected to. Return saves and Escape cancels; ⌘1 to ⌘9 pick a vault in the order the menu lists them, and the sheet remembers the vault you picked last. A page becomes a note named after the page, with your selection quoted, your note under it and a link back to the page; text on its own becomes a note named from its first line, the way the quick capture window names one. The note lands in Quick Notes even when you mapped Quick Notes to another folder. With ZenNotes running the note appears right away; when it is not running, the share waits and is filed the next time you open ZenNotes, and a share for a server is filed once ZenNotes is connected to that server. The extension comes with the app and registers once you have opened ZenNotes; macOS may list it switched off at first, so if ZenNotes is missing from a Share menu, choose Edit Extensions… at the bottom of that menu and turn ZenNotes on, or turn it on in System Settings → General → Login Items & Extensions → Sharing.' + 'On macOS, choose Share → ZenNotes in Safari or any app with a Share menu to save the page you are on, the text you selected, or both, as a note: in Quick Notes unless you choose another place. The sheet shows what you are saving and, in its heading, where it will go, a line for an optional note, and, when you use more than one vault, the vault it goes to: your local and Cloud vaults and the servers you have connected to. Return saves and Escape cancels; ⌘1 to ⌘9 pick a vault in the order the menu lists them, and the sheet remembers the vault you picked last. A page becomes a note named after the page, with your selection quoted, your note under it and a link back to the page; text on its own becomes a note named from its first line, the way the quick capture window names one. Settings → Vault → Notes → **Default shares location** decides where shares land, per vault: Quick Notes (the default), your primary notes location, or a specific folder inside it such as `Clippings`; a Quick Notes or Inbox folder you remapped is honored, and a vault on a self-hosted server keeps Quick Notes for now. With ZenNotes running the note appears right away; when it is not running, the share waits and is filed the next time you open ZenNotes, and a share for a server is filed once ZenNotes is connected to that server. The extension comes with the app and registers once you have opened ZenNotes; macOS may list it switched off at first, so if ZenNotes is missing from a Share menu, choose Edit Extensions… at the bottom of that menu and turn ZenNotes on, or turn it on in System Settings → General → Login Items & Extensions → Sharing.' }, { title: 'Create a note in the folder you are already in', @@ -1240,6 +1240,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Vault name', detail: 'What the sidebar header, the vault switcher and the title bar call this vault; the folder keeps its own name on disk. Leave it empty to use the folder name. Saved in the vault’s `.zennotes/vault.json`, so it follows the folder wherever it is opened. Also “Rename Vault…” in the command palette (`:vault_rename`) and the sidebar’s vault menu. Local vaults only.' }, { label: 'Primary notes location', detail: 'Treat `inbox/` as the main notes area, or use the vault root directly for an Obsidian-style flat vault.' }, { label: 'New Drawings, Databases & Tasks location', detail: 'Choose where new Excalidraw drawings, databases, and task files are created so they do not pile up in the vault root: the primary notes location (the default), the folder of the note you are currently viewing, or a specific folder you name (e.g. `assets/drawings` or `Tasks`). A specific folder lives inside the primary notes area, so `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault; the folder field shows the exact destination. Search Settings for "tasks folder" or "drawings folder" to jump there. Only new files are affected; existing ones stay put.' }, + { label: 'Shares location (macOS)', detail: 'Where a page or text shared from Safari and other apps with Share → ZenNotes is filed in this vault: Quick Notes (the default), the primary notes location, or a specific folder you name inside it. Saved in the vault’s `.zennotes/vault.json`. Not offered for a vault on a self-hosted server, which keeps Quick Notes for now.' }, { label: 'Daily notes', detail: "Enable a daily-notes workflow, choose a directory pattern, naming pattern, locale, and template so each day’s note starts in the right place. Supported tokens are `yyyy`, `yy`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `EEE`, `EEEE`, `w`, and `ww`; quote literal words like `'Daily Notes'/yyyy/MM-MMM`. Open today’s note with `Space d`, `:daily`, or the command palette. For another day, past or future, double-click it in the calendar panel, or follow a link like `[[2026-11-03]]` to a day that has no note yet: both create that day’s note in the right place, from its template. Hover a day in the calendar panel to read its note, drawn the way it reads, without opening it. Two task options live here too: “Tasks are due on the note’s date” makes tasks in a daily note show on the calendar for that day (on by default), and “Roll over unfinished tasks to today” moves every unchecked task from past daily notes into today when you open it (off by default; also runnable from the command palette)." }, { label: 'Weekly notes', detail: "Enable weekly notes with a directory pattern, naming pattern, locale, and template. Weekly patterns support the same tokens as daily notes plus ISO week `w` and `ww`; the default title pattern is `yyyy-'W'ww`. Open this week’s note with `Space w`, `:weekly`, or the command palette." }, { label: 'Monthly notes', detail: 'Enable monthly notes with a directory pattern, naming pattern, locale, and template. It creates one note per calendar month, handy for monthly reviews and reflections. The default title pattern is `yyyy-MM` (e.g. `2026-07`). Open this month’s note with `Space m`, `:monthly`, or the command palette. Each notes section in Settings now collapses its fields when its toggle is off.' }, diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index 99b7db2b6..275f8b5df 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -31,6 +31,7 @@ import { } from '@shared/tasks-excluded-folders' import { normalizeTypstPreambleSettings } from '@shared/typst-preamble-folder' import { normalizeHarperVaultState } from '@shared/harper-settings' +import { normalizeShareLocation } from '@shared/share-location' import { normalizeVaultDisplayName } from '@shared/vault-display-name' import { getISOWeek, getISOWeekYear, mondayOfISOWeek } from './template-render' @@ -685,6 +686,7 @@ export function normalizeVaultSettings( drawingsLocation: normalizeFileLocation(settings?.drawingsLocation), databasesLocation: normalizeFileLocation(settings?.databasesLocation), tasksLocation: normalizeFileLocation(settings?.tasksLocation), + sharesLocation: normalizeShareLocation(settings?.sharesLocation), folderIcons: normalizedFolderIcons, folderColors: normalizedFolderColors, favorites: normalizedFavorites, diff --git a/packages/app-core/src/lib/vault-settings-conflict.ts b/packages/app-core/src/lib/vault-settings-conflict.ts index 12735768e..1f4b052ad 100644 --- a/packages/app-core/src/lib/vault-settings-conflict.ts +++ b/packages/app-core/src/lib/vault-settings-conflict.ts @@ -24,6 +24,7 @@ export const VAULT_SETTINGS_SECTION_LABELS: Record<VaultSettingsSection, string> drawingsLocation: "New drawings location", databasesLocation: "New databases location", tasksLocation: "New tasks location", + sharesLocation: "Shares location", view: "View settings", folderIcons: "Folder icons", folderColors: "Folder colors", diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index b109fc9dd..ed21aa8a8 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -427,6 +427,16 @@ export interface FileLocationSetting { folder?: string } export const DEFAULT_FILE_LOCATION: FileLocationSetting = { mode: 'primary' } +/** Where a share from the macOS Share menu is filed: `quick` → Quick Notes + * (the default); `primary` → the primary notes location root; `folder` → the + * `folder` subfolder of the primary location. No `active-note`: the app + * files a share with no note in view. */ +export type ShareLocationMode = 'quick' | 'primary' | 'folder' +export interface ShareLocationSetting { + mode: ShareLocationMode + /** Vault-relative subfolder used when `mode === 'folder'`, e.g. `Clippings`. */ + folder?: string +} export type FolderIconId = | 'folder' | 'bolt' @@ -583,6 +593,10 @@ export interface VaultSettings { /** Where new task files (`#task`-tagged notes) are created; absent means the * default (`primary`, i.e. the inbox). */ tasksLocation?: FileLocationSetting + /** Where shares from the macOS Share menu are filed (share-inbox.ts); absent + * means Quick Notes. The Go server does not mirror it yet, so a vault on a + * self-hosted server files shares in Quick Notes. */ + sharesLocation?: ShareLocationSetting /** Per-vault view overrides (#292); absent/empty means "inherit global". */ view?: VaultViewSettings folderIcons: Record<string, FolderIconId> @@ -670,6 +684,7 @@ export const DEFAULT_VAULT_SETTINGS: VaultSettings = { drawingsLocation: { mode: 'primary' }, databasesLocation: { mode: 'primary' }, tasksLocation: { mode: 'primary' }, + sharesLocation: { mode: 'quick' }, folderIcons: {}, folderColors: {}, favorites: [], diff --git a/packages/shared-domain/src/share-location.test.ts b/packages/shared-domain/src/share-location.test.ts new file mode 100644 index 000000000..b3c919156 --- /dev/null +++ b/packages/shared-domain/src/share-location.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { normalizeShareLocation, resolveShareLocation, shareLocationLabel } from './share-location' + +describe('share location', () => { + it('defaults to Quick Notes, also for an unknown or missing value', () => { + expect(normalizeShareLocation(undefined)).toEqual({ mode: 'quick' }) + expect(normalizeShareLocation({ mode: 'active-note' })).toEqual({ mode: 'quick' }) + expect(resolveShareLocation(null)).toEqual({ folder: 'quick', subpath: '' }) + }) + + it('files in the primary location, or a folder inside it', () => { + expect(resolveShareLocation({ mode: 'primary' })).toEqual({ folder: 'inbox', subpath: '' }) + expect(resolveShareLocation({ mode: 'folder', folder: ' /Reading/Clippings/ ' })).toEqual({ + folder: 'inbox', + subpath: 'Reading/Clippings' + }) + }) + + it('keeps folder mode while the folder is still empty, filing like primary', () => { + expect(normalizeShareLocation({ mode: 'folder' })).toEqual({ mode: 'folder', folder: '' }) + expect(resolveShareLocation({ mode: 'folder', folder: ' ' })).toEqual({ folder: 'inbox', subpath: '' }) + }) + + it('names the place for the share sheet', () => { + expect(shareLocationLabel(undefined, 'inbox', 'Work')).toBe('Quick Notes') + expect(shareLocationLabel({ mode: 'primary' }, 'inbox', 'Work')).toBe('Inbox') + expect(shareLocationLabel({ mode: 'primary' }, 'root', 'Work')).toBe('Work') + expect(shareLocationLabel({ mode: 'folder', folder: 'Clippings' }, 'inbox', 'Work')).toBe('Clippings') + }) +}) diff --git a/packages/shared-domain/src/share-location.ts b/packages/shared-domain/src/share-location.ts new file mode 100644 index 000000000..bae0d6629 --- /dev/null +++ b/packages/shared-domain/src/share-location.ts @@ -0,0 +1,47 @@ +import type { + NoteFolder, + PrimaryNotesLocation, + ShareLocationSetting +} from '@zennotes/bridge-contract/ipc' + +/** + * Where a share from the macOS Share menu is filed, per vault + * (`VaultSettings.sharesLocation`). The app's main process files shares with + * it and Settings describes it, so both read it the same way. + */ + +/** A stored setting made safe to use: an unknown mode is Quick Notes, and a + * folder loses surrounding slashes. `folder` mode is kept with an empty + * folder so Settings can show the folder input while it is being typed; + * until a folder is named it files like `primary`. */ +export function normalizeShareLocation(value: unknown): ShareLocationSetting { + const v = value as { mode?: unknown; folder?: unknown } | null | undefined + if (v?.mode === 'primary') return { mode: 'primary' } + if (v?.mode === 'folder') { + const folder = (typeof v.folder === 'string' ? v.folder : '').trim().replace(/^\/+|\/+$/g, '') + return { mode: 'folder', folder } + } + return { mode: 'quick' } +} + +/** The `(folder, subpath)` pair the vault's createNote takes for a share. A + * folder is inside the primary notes location, like the other location + * settings; the vault's own path checks still apply to it. */ +export function resolveShareLocation(value: unknown): { folder: NoteFolder; subpath: string } { + const setting = normalizeShareLocation(value) + if (setting.mode === 'quick') return { folder: 'quick', subpath: '' } + if (setting.mode === 'folder' && setting.folder) return { folder: 'inbox', subpath: setting.folder } + return { folder: 'inbox', subpath: '' } +} + +/** What the share sheet calls the place a share goes ("Save to …"). */ +export function shareLocationLabel( + value: unknown, + primaryNotesLocation: PrimaryNotesLocation, + vaultName: string +): string { + const setting = normalizeShareLocation(value) + if (setting.mode === 'folder' && setting.folder) return setting.folder + if (setting.mode === 'quick') return 'Quick Notes' + return primaryNotesLocation === 'root' ? vaultName : 'Inbox' +} diff --git a/packages/shared-domain/src/vault-settings-conflict.test.ts b/packages/shared-domain/src/vault-settings-conflict.test.ts index b78e2822c..089827699 100644 --- a/packages/shared-domain/src/vault-settings-conflict.test.ts +++ b/packages/shared-domain/src/vault-settings-conflict.test.ts @@ -104,6 +104,7 @@ describe('diffVaultSettings', () => { drawingsLocation: true, databasesLocation: true, tasksLocation: true, + sharesLocation: true, view: true, folderIcons: true, folderColors: true, diff --git a/packages/shared-domain/src/vault-settings-conflict.ts b/packages/shared-domain/src/vault-settings-conflict.ts index 31a615266..14869abfb 100644 --- a/packages/shared-domain/src/vault-settings-conflict.ts +++ b/packages/shared-domain/src/vault-settings-conflict.ts @@ -30,6 +30,7 @@ const SECTIONS: Record<VaultSettingsSection, true> = { drawingsLocation: true, databasesLocation: true, tasksLocation: true, + sharesLocation: true, view: true, folderIcons: true, folderColors: true, From 7edddae864111e805fdbfc7d4e5b149b71e353df Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 10:27:32 -0500 Subject: [PATCH 14/34] Fix(release): a release becomes latest only once every update feed is on it (#1003) The release workflow created the GitHub release public and latest in its first job, then uploaded each platform's installers and update feed as its build finished. On 2.78.0 that left releases/latest pointing at a release with no latest.yml for 39 minutes and no latest-arm64.yml for 50. Every in-app update check in that window asked releases/latest for its feed and got a 404; a Windows user on 2.71.0 hit it and filed #1003. The app now says "still being published" in that case, but the window itself was ours. The release is still created public at the start, because the AUR, Nix and Homebrew steps and the boundary packages read per-tag download URLs mid-run, and a draft's assets answer 404 to the public. What changes is --latest=false at create, and a promote-latest job after the platform jobs that marks the release latest only when all five feeds (latest.yml, latest-arm64.yml, latest-mac.yml, latest-linux.yml, latest-linux-arm64.yml) are on it. It runs after a failed platform job too (!cancelled()), so a dispatch that rebuilds the missing platform and uploads the last feed promotes the release from its own run. It never takes latest from a newer release, so rebuilding an old tag by dispatch leaves latest alone. It does not change how notes are published (gh release edit without --latest leaves latest where it is), and the feed list must change with the platform matrix. A run cancelled and finished by hand from its artifact skips promotion, so that path now ends with gh release edit vX.Y.Z --latest; the release runbook says so. --- .github/workflows/release.yml | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b16594b2..4968ff3f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,11 @@ jobs: - name: Create release if missing env: GH_TOKEN: ${{ github.token }} + # Public from the start, so per-tag download URLs work for the AUR, + # Nix and Homebrew steps, but not "latest": the in-app updater and the + # website's download buttons follow releases/latest, and until the + # last feed is up an update check answers 404 (#1003). promote-latest + # moves "latest" once every feed is on the release. run: | if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then echo "Release ${RELEASE_TAG} already exists." @@ -62,6 +67,7 @@ jobs: gh release create "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --title "ZenNotes ${RELEASE_TAG}" \ + --latest=false \ --notes "Download the installer for your platform from the assets below." fi @@ -285,3 +291,38 @@ jobs: for file in "${installers[@]}" "${manifests[@]}"; do upload "${file}" done + + promote-latest: + name: Mark the release latest + needs: [create-release, build-and-upload] + # Also after a failed platform job: the release becomes latest once every + # feed is on it, whichever run (this one, or a dispatch that rebuilt the + # missing platform) uploaded the last one. + if: ${{ !cancelled() && needs.create-release.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Mark latest once every update feed is up + env: + GH_TOKEN: ${{ github.token }} + run: | + # One feed per platform job; keep in step with the matrix above. + feeds=(latest.yml latest-arm64.yml latest-mac.yml latest-linux.yml latest-linux-arm64.yml) + assets=$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name') + missing=() + for feed in "${feeds[@]}"; do + grep -qxF "${feed}" <<<"${assets}" || missing+=("${feed}") + done + if [ "${#missing[@]}" -gt 0 ]; then + echo "::warning::${RELEASE_TAG} stays off latest until these feeds are up: ${missing[*]}" + exit 0 + fi + # A dispatch that rebuilds an older release must not take "latest" + # from a newer one. + current=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name) + newest=$(printf '%s\n%s\n' "${current#v}" "${RELEASE_TAG#v}" | sort -V | tail -n 1) + if [ "${newest}" != "${RELEASE_TAG#v}" ]; then + echo "${current} is newer than ${RELEASE_TAG}; latest stays on ${current}." + exit 0 + fi + gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --latest + echo "${RELEASE_TAG} is latest." From 9e35242d406c81b38996bde64a984b1b5617844b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 10:53:14 -0500 Subject: [PATCH 15/34] Feat(share): a vault on a self-hosted server chooses where its shares land Default shares location stayed hidden for a vault on a self-hosted server, and the Share sheet always said "Save to Quick Notes" for one, because the server dropped vault.json's sharesLocation on every settings save. znserver 2.79 keeps the field and advertises supportsSharesLocation. The Settings row now shows for a server workspace when its server advertises that capability, and stays hidden behind an older server, where a choice would be lost on the next save. Shares were already filed from the server's own setting (fileRemote reads it), so with a 2.79 server they land in the chosen folder; what was wrong was only what the sheet said. The sheet now names the connected server's choice, falls back to Quick Notes when the server is older or cannot be read, and is refreshed after a server vault's settings are saved and when a server connects. It deliberately does not remember a disconnected server's choice: that server's shares wait for the connection anyway, and its label is corrected as it connects. The in-app manual says server 2.79 is needed. Tests: a Settings case shows the row for a server with supportsSharesLocation and hides it for one without (the old gate hid it for every server). Typecheck clean; shared-domain 2188, app-core 3677, quicklook 18 and desktop 1241 pass. --- apps/desktop/src/main/index.ts | 37 ++++++++++++++++--- .../src/components/SettingsModal.test.ts | 25 +++++++++++++ .../app-core/src/components/SettingsModal.tsx | 14 ++++--- packages/app-core/src/lib/help.ts | 4 +- packages/bridge-contract/src/ipc.ts | 10 ++++- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 56bf8b4e0..18f58d158 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -255,6 +255,7 @@ import { renderTikz } from "./tikz"; import { fetchLinkMetadata } from "./link-metadata"; import { drainShareInbox, + remoteShareKey, SHARE_CAPTURES_FOLDER, shareInboxFromInfoPlist, shareVaultsFile, @@ -942,8 +943,12 @@ async function runShareDrain(): Promise<void> { let shareVaultsTimer: ReturnType<typeof setTimeout> | null = null; /** Rewrites the sheet's vault list: the remembered vaults, the current one, - * and where each files a share. A server vault always says Quick Notes: the - * server does not keep the setting yet. */ + * and where each files a share. A server vault names its own setting only + * while it is the connected one and keeps the setting (2.79 and later, + * `supportsSharesLocation`); otherwise it says Quick Notes, which is where an + * older server files shares. A server that is not connected may have chosen + * another place: its shares wait for the connection, and the label is + * corrected as it connects. */ async function refreshShareVaults(): Promise<void> { const dir = shareInboxDir; if (!dir) return; @@ -951,7 +956,25 @@ async function refreshShareVaults(): Promise<void> { const file = await withShareDestinations( shareVaultsFile(await loadConfig()), async (choice) => { - if (choice.kind === "remote") return shareLocationLabel(undefined, "inbox", choice.name); + if (choice.kind === "remote") { + const client = remoteWorkspaceClient; + if ( + client && + remoteServerCapabilities?.supportsSharesLocation === true && + currentRemoteWorkspaceProfileId !== null && + choice.id === remoteShareKey(currentRemoteWorkspaceProfileId) + ) { + const settings = await client.getVaultSettings().catch(() => null); + if (settings) { + return shareLocationLabel( + settings.sharesLocation, + settings.primaryNotesLocation, + choice.name, + ); + } + } + return shareLocationLabel(undefined, "inbox", choice.name); + } const root = choice.id.slice("local:".length); const settings = await getVaultSettings(root); return shareLocationLabel(settings.sharesLocation, settings.primaryNotesLocation, choice.name); @@ -2575,8 +2598,10 @@ async function setRemoteWorkspace( baseUrl: client.baseUrl, }; startRemoteWatch(client, capabilities); - // Shares sent to this server while it was not connected file now. + // Shares sent to this server while it was not connected file now, and the + // sheet learns where this server files them. scheduleShareDrain(); + scheduleShareVaultsRefresh(); if (options.persist !== false) { await updateConfig((cfg) => ({ @@ -3912,7 +3937,9 @@ function registerIpc(): void { handle(IPC.VAULT_SET_SETTINGS, async (_e, next: VaultSettings) => { if (isRemoteWorkspaceActive()) { - return await requireRemoteWorkspaceClient().setVaultSettings(next); + const saved = await requireRemoteWorkspaceClient().setVaultSettings(next); + scheduleShareVaultsRefresh(); + return saved; } const v = requireVault(); const saved = await setVaultSettings(v.root, next); diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 3fb54a3a4..a233055f6 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -349,6 +349,31 @@ describe("SettingsModal date note directories", () => { ); }); + it("offers the shares location on a server vault only when the server keeps it", async () => { + mocks.platform = "darwin"; + mocks.state.workspaceMode = "remote"; + const serverWith = (capabilities: Record<string, unknown>) => ({ + mode: "remote", + baseUrl: "http://localhost:7878", + authConfigured: true, + capabilities, + profileId: null, + bootError: null, + }); + const sharesRowFound = async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "safari")); + return [...host.querySelectorAll<HTMLButtonElement>("aside nav button")].some( + (button) => button.textContent?.includes("Default shares location"), + ); + }; + mocks.state.remoteWorkspaceInfo = serverWith({ supportsCustomTemplates: true }); + expect(await sharesRowFound()).toBe(false); + mocks.state.remoteWorkspaceInfo = serverWith({ supportsSharesLocation: true }); + expect(await sharesRowFound()).toBe(true); + }); + it("leaves the shares location out where there is no Share menu extension", async () => { await act(async () => root.render(createElement(SettingsModal))); const search = host.querySelector<HTMLInputElement>('input[placeholder="Search settings…"]')!; diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 47192cfa3..b633095c0 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -808,15 +808,17 @@ export function SettingsModal(): JSX.Element { const setNoteCoverPlacement = useStore((s) => s.setNoteCoverPlacement); const vault = useStore((s) => s.vault); const workspaceMode = useStore((s) => s.workspaceMode); - // Share > ZenNotes is a macOS share extension inside the desktop app, and a - // self-hosted server does not keep the setting yet (share-inbox.ts). - const sharesLocationAvailable = - appInfo.runtime === "desktop" && - zenBridge.platformSync() === "darwin" && - workspaceMode !== "remote"; const renameVault = useStore((s) => s.renameVault); const vaultDisplayName = useStore((s) => s.vaultSettings.displayName ?? ""); const remoteWorkspaceInfo = useStore((s) => s.remoteWorkspaceInfo); + // Share > ZenNotes is a macOS share extension inside the desktop app. A + // self-hosted server keeps the setting from 2.79 (supportsSharesLocation); + // an older one drops it on every save, so the row stays hidden there. + const sharesLocationAvailable = + appInfo.runtime === "desktop" && + zenBridge.platformSync() === "darwin" && + (workspaceMode !== "remote" || + remoteWorkspaceInfo?.capabilities?.supportsSharesLocation === true); const remoteWorkspaceProfiles = useStore((s) => s.remoteWorkspaceProfiles); const vaultSettings = useStore((s) => s.vaultSettings); const persistVaultSettings = useStore((s) => s.setVaultSettings); diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 518796081..884de9387 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -99,7 +99,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Save a page or text from Safari and other apps (Share → ZenNotes)', body: - 'On macOS, choose Share → ZenNotes in Safari or any app with a Share menu to save the page you are on, the text you selected, or both, as a note: in Quick Notes unless you choose another place. The sheet shows what you are saving and, in its heading, where it will go, a line for an optional note, and, when you use more than one vault, the vault it goes to: your local and Cloud vaults and the servers you have connected to. Return saves and Escape cancels; ⌘1 to ⌘9 pick a vault in the order the menu lists them, and the sheet remembers the vault you picked last. A page becomes a note named after the page, with your selection quoted, your note under it and a link back to the page; text on its own becomes a note named from its first line, the way the quick capture window names one. Settings → Vault → Notes → **Default shares location** decides where shares land, per vault: Quick Notes (the default), your primary notes location, or a specific folder inside it such as `Clippings`; a Quick Notes or Inbox folder you remapped is honored, and a vault on a self-hosted server keeps Quick Notes for now. With ZenNotes running the note appears right away; when it is not running, the share waits and is filed the next time you open ZenNotes, and a share for a server is filed once ZenNotes is connected to that server. The extension comes with the app and registers once you have opened ZenNotes; macOS may list it switched off at first, so if ZenNotes is missing from a Share menu, choose Edit Extensions… at the bottom of that menu and turn ZenNotes on, or turn it on in System Settings → General → Login Items & Extensions → Sharing.' + 'On macOS, choose Share → ZenNotes in Safari or any app with a Share menu to save the page you are on, the text you selected, or both, as a note: in Quick Notes unless you choose another place. The sheet shows what you are saving and, in its heading, where it will go, a line for an optional note, and, when you use more than one vault, the vault it goes to: your local and Cloud vaults and the servers you have connected to. Return saves and Escape cancels; ⌘1 to ⌘9 pick a vault in the order the menu lists them, and the sheet remembers the vault you picked last. A page becomes a note named after the page, with your selection quoted, your note under it and a link back to the page; text on its own becomes a note named from its first line, the way the quick capture window names one. Settings → Vault → Notes → **Default shares location** decides where shares land, per vault: Quick Notes (the default), your primary notes location, or a specific folder inside it such as `Clippings`; a Quick Notes or Inbox folder you remapped is honored, and a vault on a self-hosted server offers the setting once that server runs 2.79 or later (an older server keeps Quick Notes). With ZenNotes running the note appears right away; when it is not running, the share waits and is filed the next time you open ZenNotes, and a share for a server is filed once ZenNotes is connected to that server. The extension comes with the app and registers once you have opened ZenNotes; macOS may list it switched off at first, so if ZenNotes is missing from a Share menu, choose Edit Extensions… at the bottom of that menu and turn ZenNotes on, or turn it on in System Settings → General → Login Items & Extensions → Sharing.' }, { title: 'Create a note in the folder you are already in', @@ -1253,7 +1253,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Vault name', detail: 'What the sidebar header, the vault switcher and the title bar call this vault; the folder keeps its own name on disk. Leave it empty to use the folder name. Saved in the vault’s `.zennotes/vault.json`, so it follows the folder wherever it is opened. Also “Rename Vault…” in the command palette (`:vault_rename`) and the sidebar’s vault menu. Local vaults only.' }, { label: 'Primary notes location', detail: 'Treat `inbox/` as the main notes area, or use the vault root directly for an Obsidian-style flat vault.' }, { label: 'New Drawings, Databases & Tasks location', detail: 'Choose where new Excalidraw drawings, databases, and task files are created so they do not pile up in the vault root: the primary notes location (the default), the folder of the note you are currently viewing, or a specific folder you name (e.g. `assets/drawings` or `Tasks`). A specific folder lives inside the primary notes area, so `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault; the folder field shows the exact destination. Search Settings for "tasks folder" or "drawings folder" to jump there. Only new files are affected; existing ones stay put.' }, - { label: 'Shares location (macOS)', detail: 'Where a page or text shared from Safari and other apps with Share → ZenNotes is filed in this vault: Quick Notes (the default), the primary notes location, or a specific folder you name inside it. Saved in the vault’s `.zennotes/vault.json`. Not offered for a vault on a self-hosted server, which keeps Quick Notes for now.' }, + { label: 'Shares location (macOS)', detail: 'Where a page or text shared from Safari and other apps with Share → ZenNotes is filed in this vault: Quick Notes (the default), the primary notes location, or a specific folder you name inside it. Saved in the vault’s `.zennotes/vault.json`. On a self-hosted server it needs server 2.79 or later; an older server keeps Quick Notes.' }, { label: 'Daily notes', detail: "Enable a daily-notes workflow, choose a directory pattern, naming pattern, locale, and template so each day’s note starts in the right place. Supported tokens are `yyyy`, `yy`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `EEE`, `EEEE`, `w`, and `ww`; quote literal words like `'Daily Notes'/yyyy/MM-MMM`. Open today’s note with `Space d`, `:daily`, or the command palette. For another day, past or future, double-click it in the calendar panel, or follow a link like `[[2026-11-03]]` to a day that has no note yet: both create that day’s note in the right place, from its template. Hover a day in the calendar panel to read its note, drawn the way it reads, without opening it. Two task options live here too: “Tasks are due on the note’s date” makes tasks in a daily note show on the calendar for that day (on by default), and “Roll over unfinished tasks to today” moves every unchecked task from past daily notes into today when you open it (off by default; also runnable from the command palette)." }, { label: 'Weekly notes', detail: "Enable weekly notes with a directory pattern, naming pattern, locale, and template. Weekly patterns support the same tokens as daily notes plus ISO week `w` and `ww`; the default title pattern is `yyyy-'W'ww`. Open this week’s note with `Space w`, `:weekly`, or the command palette." }, { label: 'Monthly notes', detail: 'Enable monthly notes with a directory pattern, naming pattern, locale, and template. It creates one note per calendar month, handy for monthly reviews and reflections. The default title pattern is `yyyy-MM` (e.g. `2026-07`). Open this month’s note with `Space m`, `:monthly`, or the command palette. Each notes section in Settings now collapses its fields when its toggle is off.' }, diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index ecdf327d6..62a4c6e83 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -603,8 +603,9 @@ export interface VaultSettings { * default (`primary`, i.e. the inbox). */ tasksLocation?: FileLocationSetting /** Where shares from the macOS Share menu are filed (share-inbox.ts); absent - * means Quick Notes. The Go server does not mirror it yet, so a vault on a - * self-hosted server files shares in Quick Notes. */ + * means Quick Notes. A self-hosted server keeps it from 2.79 + * (`supportsSharesLocation`); an older one drops it on every save, so a + * vault there files shares in Quick Notes. */ sharesLocation?: ShareLocationSetting /** Per-vault view overrides (#292); absent/empty means "inherit global". */ view?: VaultViewSettings @@ -985,6 +986,11 @@ export interface ServerCapabilities { * before 2.46, which keeps Settings, Templates read-only on the web client * and on a desktop connected to that server (#723). */ supportsCustomTemplates?: boolean + /** vault.json's `sharesLocation` survives the server's settings round + * trip. Absent before 2.79, whose settings save drops it, so the desktop + * keeps Default shares location hidden and the Share sheet says Quick + * Notes for a vault on such a server. */ + supportsSharesLocation?: boolean } export interface ServerSessionStatus { From ecb353688afdf1caf1ae9e86ad4d210b2be7ba1e Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 11:33:14 -0500 Subject: [PATCH 16/34] Feat(tasks): a Timeline panel lays the vault's dated tasks down one line (#1007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1007 asked for tasks in date order in the right sidebar, with overdue work and milestones marked. The Calendar panel shows one week of one month; nothing showed what is coming up across the vault at a glance. The Timeline is a fifth right-hand panel beside Outline, Connections, Comments and Calendar. Overdue tasks sit on top in the danger color with an "Nd late" pill, Today is the accent ring ("Today · Wed 28"), each later day with work gets a node, and a run of empty days folds into one "N quiet days" line. The header counts what the rail shows ("9 open · 2 overdue"), the switch reaches 2 weeks (the default), a month (to the same date next month) or All, and the filter takes the Tasks filter syntax, note-tags map included (#1008). Tasks past the range are counted at the foot and a click shows them all; "N undated in Tasks" opens the Tasks view. It writes no rule of its own. The input is the list every Tasks surface starts from (display filter, the daily-note date inference the Calendar panel uses, filterTasks), groupTasks decides which tasks are live Today and Upcoming work, and isOverdue decides lateness. So a [/] task is on the rail with its half-filled box, while @waiting and forwarded tasks stay in their own Tasks groups, as the list keeps them. The rail is a pure builder in lib/timeline-rail; the four task-line parsers are untouched. A task tagged #milestone draws as a diamond on a day labelled "· milestone", its row stands out, and it says how many open tasks are due before it, counted since the previous milestone (the first one counts from the top of the rail, overdue included). Counting from today instead made every later milestone repeat the earlier ones' work; the rail already shows the running position, so the number says what it cannot: the size of this milestone's own stretch, each task counted once. It counts what the filter leaves, so it matches the rows above the diamond. A file task's body tag counts, as in the filter. The tag is special nowhere else. Rows reuse the task pieces: TaskStateBox, the shared task menu (with the inline Edit the calendar has; taskTail and the priority marks moved to lib so both panels and the Tasks list share one copy), the note hover preview, and toggleTaskFromList, so a ticked task leaves the rail the way it leaves the calendar's list. Keys: Mod+7, leader n, :timeline and Toggle Timeline in the palette, all in the keymap catalog and rebindable. Leader n opens the panel with the keyboard in it, as leader c does for the calendar. With Vim on, j/k, gg/G, x, m (task menu) and / (filter) work; with Vim off only the arrows, Home/End, Enter and Escape, the hints in the footer show only with Vim on, and the handler stands down while a menu or dialog is open. Escape or Left hands the keyboard back. The handler is the panel's own onKeyDown, which runs after VimNav's capture handler; VimNav stands down for the panel except for Ctrl+W and the leader, so leader n closes it from inside and leader h hints over its rows. The panel claims focusedPanel 'timeline' on its own capture handlers, after the pane's claim of 'editor'. It resizes, tucks into the rail, joins the Alt+hjkl and Ctrl+W cycle after the calendar, closes with :closepanel, and its open state lives where the other panels' does: sticky per pane, or per note in the workspace snapshot with "Keep panels when switching notes" off. Its width joins panelWidths (290 by default), which stay on the device like every panel width. The range is portable, timeline_range under [view] in config.toml beside tasks_view_mode: it is how you like to read your tasks, not a fact about a screen. The filter is session state, like the Tasks filter. The panel is one of the surfaces that keep the task cache fresh, and it rescans when it opens after a while closed, not on a quick remount while a note loads. Phones: their shells hide the pane toolbar and turn any right panel into a sheet with Done, so nothing new shows there. The web client gets the panel as desktop does. Docs: the in-app manual has a Timeline card with the #milestone convention, Mod+7 and Space n in the shortcut lists, <Space> n and :timeline in the Vim commands, and the Timeline in the panel cycle, the task menu and :closepanel texts. Tests: timeline-rail (overdue, late days, today, day nodes, quiet-day folds, range cuts, milestone counts, undated, the inferred date, the groups), keymaps (defaults, no clash, Cmd+7 vs Ctrl+7, rebind and unbind), pane-panels snapshot, the focus cycle, store and config.toml round trips, and TimelinePanel component cases in the style of CalendarPanel.test.ts. Typecheck clean; shared-domain 2188, app-core 3715, quicklook 18 and desktop 1241 pass. Driven over CDP in the built app (69 checks) and the web client at 390 px; the Vim editor, sidebar Vim and editor-improvements smoke suites pass on the same build. --- apps/desktop/src/main/app-config.test.ts | 4 + apps/desktop/src/main/app-config.ts | 7 +- packages/app-core/src/App.tsx | 6 + .../app-core/src/components/CalendarPanel.tsx | 14 +- packages/app-core/src/components/Editor.tsx | 7 +- .../app-core/src/components/EditorPane.tsx | 55 +- packages/app-core/src/components/HelpView.tsx | 2 + packages/app-core/src/components/TasksRow.tsx | 15 +- .../src/components/TimelinePanel.test.ts | 238 ++++++ .../app-core/src/components/TimelinePanel.tsx | 694 ++++++++++++++++++ .../src/components/TuckedPanelsRail.tsx | 5 +- packages/app-core/src/components/VimNav.tsx | 53 ++ packages/app-core/src/components/icons.tsx | 11 + packages/app-core/src/lib/commands.ts | 15 +- packages/app-core/src/lib/help.ts | 25 +- packages/app-core/src/lib/keymaps.test.ts | 61 ++ packages/app-core/src/lib/keymaps.ts | 26 + packages/app-core/src/lib/pane-nav.ts | 7 +- packages/app-core/src/lib/pane-panels.test.ts | 23 + packages/app-core/src/lib/pane-panels.ts | 11 +- packages/app-core/src/lib/side-panel-fit.ts | 6 +- packages/app-core/src/lib/task-priority.ts | 18 + packages/app-core/src/lib/task-tail.ts | 16 + .../app-core/src/lib/timeline-rail.test.ts | 265 +++++++ packages/app-core/src/lib/timeline-rail.ts | 217 ++++++ packages/app-core/src/lib/use-pane-panels.ts | 10 +- packages/app-core/src/lib/vim-nav.test.ts | 23 + packages/app-core/src/lib/vim-nav.ts | 12 +- packages/app-core/src/store.test.ts | 38 + packages/app-core/src/store.ts | 39 +- packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + packages/shared-domain/src/keymaps-catalog.ts | 2 + 33 files changed, 1871 insertions(+), 56 deletions(-) create mode 100644 packages/app-core/src/components/TimelinePanel.test.ts create mode 100644 packages/app-core/src/components/TimelinePanel.tsx create mode 100644 packages/app-core/src/lib/task-priority.ts create mode 100644 packages/app-core/src/lib/task-tail.ts create mode 100644 packages/app-core/src/lib/timeline-rail.test.ts create mode 100644 packages/app-core/src/lib/timeline-rail.ts diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index ffa0ab432..781c68370 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -134,6 +134,7 @@ describe('TOML serialization', () => { kanbanGroupBy: 'folder', kanbanCardSort: 'due', tasksListOrder: 'priority', + timelineRange: 'month', kanbanFolderRoot: 'Projects', externalApplicationSchemes: ['zotero', 'obsidian'], ignoredKeys: ['KanaMode', 'F24'] @@ -172,6 +173,9 @@ describe('TOML serialization', () => { expect(round.kanbanCardSort).toBe('due') expect(text).toContain('tasks_list_order = "priority"') expect(round.tasksListOrder).toBe('priority') + // The Timeline panel's reach travels as [view].timeline_range (#1007). + expect(text).toMatch(/\[view\][^[]*timeline_range = "month"/) + expect(round.timelineRange).toBe('month') expect(round.kanbanFolderRoot).toBe('Projects') expect(text).toContain('ignored_keys = ["KanaMode", "F24"]') expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian']) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index dc61b2e28..6887966af 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -407,7 +407,7 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = { keepPanelsAcrossNotes: { section: 'view', tomlKey: 'keep_panels_across_notes', - comment: 'true = Connections / Outline / Comments / Calendar stay as set while you switch notes; false = each note remembers its own panels (saved with the workspace, so they survive a restart)' + comment: 'true = Connections / Outline / Comments / Calendar / Timeline stay as set while you switch notes; false = each note remembers its own panels (saved with the workspace, so they survive a restart)' }, calendarWeekStart: { section: 'view', @@ -440,6 +440,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = { tomlKey: 'tasks_list_order', comment: 'note | priority: each Tasks list group keeps its notes\' line order, or puts high priority first' }, + timelineRange: { + section: 'view', + tomlKey: 'timeline_range', + comment: 'two-weeks | month | all: how far ahead the Timeline panel reaches; overdue tasks always show' + }, kanbanFolderRoot: { section: 'view', tomlKey: 'kanban_folder_root', diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index f9ebeca56..979dd9d46 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -1028,6 +1028,12 @@ function App(): JSX.Element { window.dispatchEvent(new Event('zen:toggle-comments')) return } + // ⌘7: toggle the Timeline panel in the active pane (#1007) + if (matchesShortcut(e, overrides, 'global.toggleTimelinePanel')) { + e.preventDefault() + window.dispatchEvent(new Event('zen:toggle-timeline')) + return + } // ⌥⌘M — comment the current selection (or line) without the mouse if (matchesShortcut(e, overrides, 'global.addComment')) { e.preventDefault() diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index 3e90bceb8..712d70117 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -50,23 +50,11 @@ import { PanelResizeHandle } from './PanelResizeHandle' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { LazyNoteHoverPreview as NoteHoverPreview } from './LazyNoteHoverPreview' import { buildTaskMenuItems } from '../lib/task-context-menu' +import { taskTail } from '../lib/task-tail' const FULL_DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const HOVER_DELAY_MS = 280 -// Every task state, so the "lossless" promise below actually holds for a `[>]` -// or `[/]` task too: an unmatched prefix falls back to the token-stripped -// `content`, which an inline edit would then write back over the real line. -// (#512) -const TASK_PREFIX_RE = /^\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[[ xX>/-]\]\s?/ - -/** The raw text after the checkbox (content + any `due:`/`!priority` tokens) — - * what an inline edit field prefills with so editing is lossless. */ -function taskTail(task: VaultTask): string { - const m = task.rawText.match(TASK_PREFIX_RE) - return m ? task.rawText.slice(m[0].length) : task.content -} - function monthLabel(d: Date): string { return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) } diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 72e8b9cb9..a802b318d 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -1242,10 +1242,14 @@ function registerVimNoteCommands(): void { }; Vim.defineEx("outline", "outline", openOutline); // `:closepanel` / `:closep` closes whichever right-hand panel (connections, - // outline, comments, or calendar) is open in the active pane. + // outline, comments, calendar, or timeline) is open in the active pane. Vim.defineEx("closepanel", "closep", () => { window.dispatchEvent(new Event("zen:close-right-panel")); }); + // `:timeline` toggles the Timeline panel in the active pane (#1007). + Vim.defineEx("timeline", "timeline", () => { + window.dispatchEvent(new Event("zen:toggle-timeline")); + }); const setZenMode = (next: "toggle" | "on" | "off"): void => { requestAnimationFrame(() => { const state = useStore.getState(); @@ -1413,6 +1417,7 @@ const MANUAL_EX_NAMES = new Set([ "buffers", "ls", "outline", + "timeline", "view", "zen", "zenmode", diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index e1d300840..464ec6de6 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -172,6 +172,7 @@ import { LazyDiagramTabView, LazyPreview as Preview } from './LazyPreview' import { ConnectionsPanel } from './ConnectionsPanel' import { OutlinePanel } from './OutlinePanel' import { CalendarPanel } from './CalendarPanel' +import { TimelinePanel } from './TimelinePanel' import { selectTypstPreambleFor } from '../lib/typst-preamble-select' import { CommentsPanel, type CommentDraft } from './CommentsPanel' import { ContextMenu, type ContextMenuItem } from './ContextMenu' @@ -262,6 +263,7 @@ import { ArrowRightIcon, ArrowUpRightIcon, CalendarIcon, + TimelineIcon, CheckSquareIcon, CloseIcon, PaperclipIcon, @@ -1056,11 +1058,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { outline: outlineOpen, comments: commentsOpen, calendar: calendarPanel, + timeline: timelineOpen, calendarAutoOpenAllowed, setConnectionsOpen: setConnectionsOpenRaw, setOutlineOpen: setOutlineOpenRaw, setCommentsOpen: setCommentsOpenRaw, - setCalendarPanel: setCalendarPanelRaw + setCalendarPanel: setCalendarPanelRaw, + setTimelineOpen: setTimelineOpenRaw } = usePanePanels(paneId, activeTab) // Which panel was asked for last, most recent first. In a pane too narrow // for all the open panels the most recent ones are shown and the rest are @@ -1102,6 +1106,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }, [revealSidePanel, setCalendarPanelRaw] ) + const setTimelineOpen = useCallback( + (next: SetStateAction<boolean>) => { + if (next === true) revealSidePanel('timeline') + setTimelineOpenRaw(next) + }, + [revealSidePanel, setTimelineOpenRaw] + ) const [activeOutlineLine, setActiveOutlineLine] = useState<number | null>(null) const calendarOpen = calendarPanel.open // The calendar panel is a date navigator. It auto-opens while the pane shows @@ -1357,6 +1368,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { setCalendarPanel(calendarPanelOnToggle) }, [revealSidePanel, setCalendarPanel]) + const toggleTimelinePanel = useCallback(() => { + if (tuckedSidePanelsRef.current.includes('timeline')) return revealSidePanel('timeline') + setTimelineOpen((open) => !open) + }, [revealSidePanel, setTimelineOpen]) + const lockOutlinePreviewSync = useCallback((durationMs = OUTLINE_JUMP_SCROLL_SYNC_LOCK_MS): void => { // Outline jumps target a rendered heading; ratio sync can otherwise override them. @@ -1499,6 +1515,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return () => window.removeEventListener('zen:toggle-calendar', handler) }, [panelShortcutsApply, toggleCalendarPanel]) + // `zen:toggle-timeline` (Mod+7, leader n, :timeline, the palette): the + // same active-pane routing. (#1007) + useEffect(() => { + if (!panelShortcutsApply) return + const handler = (): void => { + toggleTimelinePanel() + } + window.addEventListener('zen:toggle-timeline', handler) + return () => window.removeEventListener('zen:toggle-timeline', handler) + }, [panelShortcutsApply, toggleTimelinePanel]) + // `zen:close-right-panel` — Esc (when a right panel is focused) or the // "Close right panel" command dismiss whichever right-hand panel is open in // the active pane and return focus to the editor. @@ -1509,9 +1536,15 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { setOutlineOpen(false) setCommentsOpen(false) setCalendarPanel(CALENDAR_PANEL_CLOSED) + setTimelineOpen(false) setConnectionPreview(null) const panel = useStore.getState().focusedPanel - if (panel === 'connections' || panel === 'comments' || panel === 'hoverpreview') { + if ( + panel === 'connections' || + panel === 'comments' || + panel === 'timeline' || + panel === 'hoverpreview' + ) { setFocusedPanel('editor') } } @@ -3902,6 +3935,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (commentsOpen) open.push('comments') if (outlineOpen) open.push('outline') if (calendarOpen && calendarAvailable) open.push('calendar') + if (timelineOpen) open.push('timeline') return open }, [ calendarAvailable, @@ -3911,6 +3945,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { content, isActive, outlineOpen, + timelineOpen, zenMode ]) useEffect(() => { @@ -3999,6 +4034,15 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { <CalendarIcon /> </IconBtn> )} + <IconBtn + title={ + timelineOpen && !tucked.includes('timeline') ? 'Hide timeline' : 'Show timeline' + } + active={timelineOpen} + onClick={toggleTimelinePanel} + > + <TimelineIcon /> + </IconBtn> <IconBtn title="Export as PDF (⇧⌘E)" onClick={() => void exportActiveNotePdf()}> <FileDownIcon /> </IconBtn> @@ -4044,6 +4088,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { calendarAvailable, calendarOpen, toggleCalendarPanel, + timelineOpen, + toggleTimelinePanel, sidePanelFit.tucked, trashActive, deleteActivePermanently, @@ -4403,7 +4449,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if ( e.target instanceof HTMLElement && e.target.closest( - '[data-connections-panel],[data-comments-panel],[data-outline-panel],[data-calendar-panel]' + '[data-connections-panel],[data-comments-panel],[data-outline-panel],[data-calendar-panel],[data-timeline-panel]' ) ) { return @@ -4741,6 +4787,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {content && sidePanelShown('calendar') && ( <CalendarPanel note={content} fitWidth={sidePanelFit.widths.calendar} /> )} + {content && sidePanelShown('timeline') && ( + <TimelinePanel fitWidth={sidePanelFit.widths.timeline} /> + )} <TuckedPanelsRail tucked={sidePanelFit.tucked} onReveal={(id) => { diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 4526746ed..88784a3a0 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -66,6 +66,7 @@ function resolveShortcutKeys( if (action === 'Open Settings') return shortcut(overrides, 'global.openSettings') if (action === 'Toggle sidebar') return shortcut(overrides, 'global.toggleSidebar') if (action === 'Toggle connections') return shortcut(overrides, 'global.toggleConnections') + if (action === 'Toggle timeline panel') return shortcut(overrides, 'global.toggleTimelinePanel') if (action === 'Toggle Zen mode') return shortcut(overrides, 'global.toggleZenMode') if (action === 'Close active tab') return shortcut(overrides, 'global.closeActiveTab') if (action === 'Export note as PDF') return shortcut(overrides, 'global.exportNotePdf') @@ -101,6 +102,7 @@ function resolveShortcutKeys( } if (action === 'Toggle left sidebar') return leaderShortcut(overrides, 'vim.leaderToggleSidebar') if (action === 'Note outline') return leaderShortcut(overrides, 'vim.leaderNoteOutline') + if (action === 'Toggle timeline') return leaderShortcut(overrides, 'vim.leaderTimeline') if (action === 'Unresolved links') return leaderShortcut(overrides, 'vim.leaderUnresolvedLinks') if (action === 'Switch vault') return leaderShortcut(overrides, 'vim.leaderSwitchVault') if (action === 'Open workflows') return leaderShortcut(overrides, 'vim.leaderWorkflows') diff --git a/packages/app-core/src/components/TasksRow.tsx b/packages/app-core/src/components/TasksRow.tsx index 68d61e925..f1176d1ad 100644 --- a/packages/app-core/src/components/TasksRow.tsx +++ b/packages/app-core/src/components/TasksRow.tsx @@ -3,6 +3,7 @@ import type { VaultTask } from '@shared/tasks' import { ArrowUpRightIcon } from './icons' import { InlineMarkdown } from '../lib/inline-markdown' import { TaskStateBox } from './TaskStateBox' +import { priorityClass, priorityLabel } from '../lib/task-priority' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { getCurrentDragPayload, readDragPayload, setDragPayload } from '../lib/dnd' @@ -25,20 +26,6 @@ interface Props { vimMode?: boolean } -function priorityLabel(p: VaultTask['priority']): string { - if (p === 'high') return '!high' - if (p === 'med') return '!med' - if (p === 'low') return '!low' - return '' -} - -function priorityClass(p: VaultTask['priority']): string { - if (p === 'high') return 'text-rose-400' - if (p === 'med') return 'text-amber-400' - if (p === 'low') return 'text-sky-400' - return 'text-current/50' -} - function formatDue(iso: string | undefined): string { if (!iso) return '' const d = new Date(`${iso}T00:00:00`) diff --git a/packages/app-core/src/components/TimelinePanel.test.ts b/packages/app-core/src/components/TimelinePanel.test.ts new file mode 100644 index 000000000..6231dc403 --- /dev/null +++ b/packages/app-core/src/components/TimelinePanel.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { VaultTask } from '@shared/tasks' +import { useStore } from '../store' +import { TimelinePanel } from './TimelinePanel' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function isoOffset(days: number): string { + const now = new Date() + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function task(id: string, overrides?: Partial<VaultTask>): VaultTask { + return { + id, + sourcePath: `inbox/${id}.md`, + noteTitle: `Note ${id}`, + noteFolder: 'inbox', + lineNumber: 0, + taskIndex: 0, + rawText: `- [ ] ${id}`, + content: id, + checked: false, + forwarded: false, + cancelled: false, + inProgress: false, + waiting: false, + tags: [], + ...overrides + } +} + +const VAULT: VaultTask[] = [ + task('late', { due: isoOffset(-3), priority: 'high' }), + task('today', { due: isoOffset(0) }), + task('doing', { due: isoOffset(0), inProgress: true, rawText: '- [/] doing' }), + task('launch', { due: isoOffset(5), tags: ['milestone'], content: 'launch #milestone' }), + task('far', { due: isoOffset(20) }), + task('someday') +] + +describe('TimelinePanel', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + const toggleTaskFromList = vi.fn(async (_task: VaultTask) => {}) + const openTaskAt = vi.fn(async (_task: VaultTask) => {}) + const openTasksView = vi.fn(async () => {}) + const refreshTasks = vi.fn(async () => {}) + + beforeEach(() => { + originalState = useStore.getState() + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn() + }) + useStore.setState({ + notes: [], + vaultTasks: VAULT, + tasksLoading: false, + showArchivedTasks: false, + vimMode: true, + keymapOverrides: {}, + focusedPanel: 'timeline', + timelineRange: 'two-weeks', + timelineFilter: '', + toggleTaskFromList, + openTaskAt, + openTasksView, + refreshTasks + }) + for (const fn of [toggleTaskFromList, openTaskAt, openTasksView, refreshTasks]) fn.mockClear() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + function render(): HTMLElement { + act(() => root.render(createElement(TimelinePanel, {}))) + const panel = host.querySelector<HTMLElement>('[data-timeline-panel]') + if (!panel) throw new Error('no panel') + return panel + } + + function press(panel: HTMLElement, key: string, init: KeyboardEventInit = {}): void { + const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : key + act(() => { + panel.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, ...init })) + }) + } + + const rowIds = (): string[] => + [...host.querySelectorAll('[data-timeline-task]')].map((el) => el.getAttribute('data-timeline-task') ?? '') + const cursorId = (): string | null => + host.querySelector('.vim-cursor')?.getAttribute('data-timeline-task') ?? null + + it('draws overdue, today, quiet days and the milestone, and counts what it shows', () => { + const panel = render() + // Within a day, the Tasks list's order: priority, then the notes' order. + expect(rowIds()).toEqual(['late', 'doing', 'today', 'launch']) + expect(panel.querySelector('[data-timeline-count]')?.textContent).toBe('4 open · 1 overdue') + expect(panel.querySelector('[data-timeline-overdue]')?.textContent).toContain('3d late') + expect(panel.querySelector('[data-timeline-overdue]')?.textContent).toContain('!high') + expect(panel.querySelector(`[data-timeline-day="${isoOffset(0)}"]`)?.textContent).toMatch(/^Today · /) + expect(panel.querySelector('[data-timeline-quiet]')?.textContent).toBe('4 quiet days') + const milestoneDay = panel.querySelector(`[data-timeline-day="${isoOffset(5)}"]`) + expect(milestoneDay?.textContent).toContain('· milestone') + // Three open tasks stand before it: the overdue one and today's two. + expect(milestoneDay?.querySelector('[data-timeline-before]')?.textContent).toBe('· 3 tasks before it') + expect(panel.querySelector('[data-timeline-task="doing"] [role="checkbox"]')?.getAttribute('title')).toMatch( + /^In progress\./ + ) + expect(panel.querySelector('[data-timeline-later]')?.textContent).toMatch(/^1 after /) + expect(panel.querySelector('[data-timeline-undated]')?.textContent).toBe('1 undated in Tasks') + }) + + it('reaches further with Month and All, and keeps the choice', () => { + const panel = render() + act(() => panel.querySelector<HTMLButtonElement>('[data-timeline-range="month"]')?.click()) + expect(useStore.getState().timelineRange).toBe('month') + expect(rowIds()).toContain('far') + act(() => panel.querySelector<HTMLButtonElement>('[data-timeline-range="two-weeks"]')?.click()) + expect(rowIds()).not.toContain('far') + act(() => panel.querySelector<HTMLButtonElement>('[data-timeline-later]')?.click()) + expect(useStore.getState().timelineRange).toBe('all') + expect(rowIds()).toContain('far') + }) + + it('narrows with the Tasks filter syntax', () => { + useStore.setState({ timelineFilter: '!high' }) + render() + expect(rowIds()).toEqual(['late']) + }) + + it('moves with j/k, ticks with x and opens with Enter in Vim mode', () => { + const panel = render() + expect(cursorId()).toBe('late') + press(panel, 'j') + press(panel, 'j') + expect(cursorId()).toBe('today') + press(panel, 'k') + expect(cursorId()).toBe('doing') + press(panel, 'x') + expect(toggleTaskFromList).toHaveBeenCalledWith(expect.objectContaining({ id: 'doing' })) + press(panel, 'Enter') + expect(openTaskAt).toHaveBeenCalledWith(expect.objectContaining({ id: 'doing' })) + press(panel, 'G', { shiftKey: true }) + expect(cursorId()).toBe('launch') + press(panel, 'g') + press(panel, 'g') + expect(cursorId()).toBe('late') + expect(panel.querySelector('[data-timeline-hints]')?.textContent).toContain('tick') + }) + + it('takes a ticked task off the rail, as the calendar panel does', () => { + render() + act(() => + useStore.setState({ + vaultTasks: VAULT.map((t) => (t.id === 'today' ? { ...t, checked: true } : t)) + }) + ) + expect(rowIds()).toEqual(['late', 'doing', 'launch']) + expect(host.querySelector('[data-timeline-count]')?.textContent).toBe('3 open · 1 overdue') + }) + + it('leaves the letters alone with Vim off; the arrows, Enter and Escape still work', () => { + useStore.setState({ vimMode: false }) + const panel = render() + press(panel, 'j') + expect(cursorId()).toBe('late') + press(panel, 'x') + expect(toggleTaskFromList).not.toHaveBeenCalled() + press(panel, 'ArrowDown') + expect(cursorId()).toBe('doing') + press(panel, 'Enter') + expect(openTaskAt).toHaveBeenCalledWith(expect.objectContaining({ id: 'doing' })) + expect(panel.querySelector('[data-timeline-hints]')).toBeNull() + expect(panel.querySelector('[role="checkbox"]')?.getAttribute('title')).not.toMatch(/\(x\)/) + press(panel, 'Escape') + expect(useStore.getState().focusedPanel).toBe('editor') + }) + + it('stands down while a menu is open over it', () => { + const panel = render() + const menu = document.createElement('div') + menu.setAttribute('data-ctx-menu', '') + document.body.append(menu) + try { + press(panel, 'j') + expect(cursorId()).toBe('late') + } finally { + menu.remove() + } + }) + + it('opens the shared task menu on right-click', () => { + render() + const row = host.querySelector('[data-timeline-task="today"]') + act(() => { + row?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })) + }) + const menu = document.querySelector('[data-ctx-menu]') + expect(menu?.textContent).toContain('Open note') + expect(menu?.textContent).toContain('Mark in progress') + expect(menu?.textContent).toContain('Edit') + }) + + it('sends undated tasks to the Tasks view', () => { + render() + act(() => host.querySelector<HTMLButtonElement>('[data-timeline-undated]')?.click()) + expect(openTasksView).toHaveBeenCalledTimes(1) + }) + + it('reads the vault again when it opens after a while closed, not on a quick remount', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000) + try { + render() + expect(refreshTasks).toHaveBeenCalledTimes(1) + // A pane remounts its panels while a note loads: no second full scan. + act(() => root.render(createElement('div'))) + render() + expect(refreshTasks).toHaveBeenCalledTimes(1) + } finally { + now.mockRestore() + } + }) +}) diff --git a/packages/app-core/src/components/TimelinePanel.tsx b/packages/app-core/src/components/TimelinePanel.tsx new file mode 100644 index 000000000..f28153d9d --- /dev/null +++ b/packages/app-core/src/components/TimelinePanel.tsx @@ -0,0 +1,694 @@ +/** + * Right-side Timeline panel (#1007): every open, dated task in the vault on + * one rail, in date order. Overdue work sits on top in the danger color, + * today is the accent ring, each later day with work gets a node, and a run + * of empty days folds into one quiet line. A task tagged `#milestone` draws + * as a diamond and says how much open work stands before it. + * + * The rail itself is pure (lib/timeline-rail) and takes its rules from the + * Tasks surfaces: the same display filter, the same daily-note date inference + * the calendar panel uses, the same filter syntax, `groupTasks` for which + * tasks are live and `isOverdue` for lateness. This file only draws it and + * gives it a keyboard. + * + * Keys: with Vim on, j/k move, gg/G jump, x ticks, m opens the task menu, / + * focuses the filter; with Vim off only the arrows, Home/End, Enter and + * Escape act, as in every list. Enter opens the task's line and Escape (or + * Left) hands the keyboard back to the editor. The handler is the section's + * own onKeyDown, so it runs after VimNav's window capture handler, which + * stands down for this panel except for Ctrl+W and the leader. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { NoteMeta } from '@shared/ipc' +import { filterTasksForDisplay, inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' +import { useStore } from '../store' +import { buildDailyNoteDateByPath } from '../lib/vault-layout' +import { filterTasks, noteTagsByPath } from '../lib/tasks-filter' +import { + buildTimelineRail, + timelineDayLabel, + type TimelineEntry, + type TimelineRange, + type TimelineRow +} from '../lib/timeline-rail' +import { + getKeymapDisplay, + getSequenceTokens, + matchesSequenceToken, + sequenceTokenFromEvent +} from '../lib/keymaps' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' +import { buildTaskMenuItems } from '../lib/task-context-menu' +import { taskTail } from '../lib/task-tail' +import { priorityClass, priorityLabel } from '../lib/task-priority' +import { InlineMarkdown } from '../lib/inline-markdown' +import { usePanelResize } from '../lib/use-panel-resize' +import { PanelResizeHandle } from './PanelResizeHandle' +import { ContextMenu, type ContextMenuItem } from './ContextMenu' +import { LazyNoteHoverPreview as NoteHoverPreview } from './LazyNoteHoverPreview' +import { TaskStateBox } from './TaskStateBox' +import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' +import { Button } from './ui/Button' + +const HOVER_DELAY_MS = 280 +/** How long the rail's task list counts as fresh after the panel closes. A + * pane remounts its panels while a newly opened note loads, and a full vault + * scan on each of those would be waste; past this, edits made while no task + * surface was on screen may have been missed, so the panel rescans. */ +const RESCAN_AFTER_MS = 5000 +let lastShownAt = 0 + +const RANGE_BUTTONS: ReadonlyArray<{ id: TimelineRange; label: string }> = [ + { id: 'two-weeks', label: '2 weeks' }, + { id: 'month', label: 'Month' }, + { id: 'all', label: 'All' } +] + +function startOfToday(): Date { + const now = new Date() + return new Date(now.getFullYear(), now.getMonth(), now.getDate()) +} + +/** Today, rolled over at midnight, so a panel left open overnight moves its + * Today node and recounts what is late. */ +function useToday(): Date { + const [today, setToday] = useState(startOfToday) + useEffect(() => { + const now = new Date() + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) + const timer = setTimeout(() => setToday(startOfToday()), tomorrow.getTime() - now.getTime() + 1000) + return () => clearTimeout(timer) + }, [today]) + return today +} + +function plural(n: number, one: string, many: string): string { + return `${n} ${n === 1 ? one : many}` +} + +function shortDate(iso: string): string { + const [y, m, d] = iso.split('-').map(Number) + return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +export function TimelinePanel({ + fitWidth +}: { + /** Width to render at when the pane has less room than the width the user + * chose; see lib/side-panel-fit. (#805) */ + fitWidth?: number +}): JSX.Element { + const notes = useStore((s) => s.notes) + const vaultSettings = useStore((s) => s.vaultSettings) + const vaultTasks = useStore((s) => s.vaultTasks) + const tasksLoading = useStore((s) => s.tasksLoading) + const showArchivedTasks = useStore((s) => s.showArchivedTasks) + const vimMode = useStore((s) => s.vimMode) + const keymapOverrides = useStore((s) => s.keymapOverrides) + const focusedPanel = useStore((s) => s.focusedPanel) + const setFocusedPanel = useStore((s) => s.setFocusedPanel) + const range = useStore((s) => s.timelineRange) + const setRange = useStore((s) => s.setTimelineRange) + const filter = useStore((s) => s.timelineFilter) + const setFilter = useStore((s) => s.setTimelineFilter) + const toggleTaskFromList = useStore((s) => s.toggleTaskFromList) + const openTaskAt = useStore((s) => s.openTaskAt) + const applyTaskMutation = useStore((s) => s.applyTaskMutation) + const openTasksView = useStore((s) => s.openTasksView) + const width = useStore((s) => s.panelWidths.timeline) + const setPanelWidth = useStore((s) => s.setPanelWidth) + const { startResize } = usePanelResize(fitWidth ?? width, (px) => setPanelWidth('timeline', px)) + const today = useToday() + + // The same task list every Tasks surface starts from: archived notes out + // unless asked for, and a daily note's undated tasks due on its date, + // exactly as the calendar panel infers them. + const dueByPath = useMemo( + () => buildDailyNoteDateByPath(notes, vaultSettings), + [notes, vaultSettings] + ) + const tasks = useMemo( + () => inferDailyTaskDueDates(filterTasksForDisplay(vaultTasks, showArchivedTasks), dueByPath), + [vaultTasks, showArchivedTasks, dueByPath] + ) + // A file task also matches the tags written in its note's body (#1008). + const noteTags = useMemo(() => noteTagsByPath(notes), [notes]) + const filtered = useMemo(() => filterTasks(tasks, filter, noteTags), [tasks, filter, noteTags]) + const rail = useMemo( + () => buildTimelineRail(filtered, { today, range, noteTags }), + [filtered, today, range, noteTags] + ) + const notesByPath = useMemo(() => new Map(notes.map((n) => [n.path, n])), [notes]) + + // The watcher keeps the task list fresh only while a task surface is on + // screen (this panel is one), so a panel that has been closed for a while + // reads the vault again as it opens. + useEffect(() => { + const s = useStore.getState() + const fresh = Date.now() - lastShownAt < RESCAN_AFTER_MS + if (!s.tasksLoading && (s.vaultTasks.length === 0 || !fresh)) void s.refreshTasks() + return () => { + lastShownAt = Date.now() + } + }, []) + + const panelRef = useRef<HTMLElement>(null) + const filterRef = useRef<HTMLInputElement>(null) + const [cursor, setCursor] = useState(0) + const cursorIdx = Math.min(cursor, Math.max(0, rail.rows.length - 1)) + const panelFocused = focusedPanel === 'timeline' + + // Keyboard focus follows the Calendar panel's rules (#285): take DOM focus + // when this becomes the focused panel (pane navigation, leader n), and hand + // it back to the editor when the panel goes away while it holds it. + useEffect(() => { + const panel = panelRef.current + if (focusedPanel !== 'timeline' || !panel) return + if (!panel.contains(document.activeElement)) panel.focus({ preventScroll: true }) + }, [focusedPanel]) + useLayoutEffect( + () => () => { + const s = useStore.getState() + const hadFocus = !!panelRef.current?.contains(document.activeElement) + if (s.focusedPanel === 'timeline') s.setFocusedPanel('editor') + if (hadFocus) requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) + }, + [] + ) + // The pane claims focusedPanel 'editor' for any click or focus inside it; + // claiming it back on the panel's own capture handlers (which run after the + // pane's) keeps this panel's keys alive after a click. + const claimFocus = useCallback(() => { + if (useStore.getState().focusedPanel !== 'timeline') setFocusedPanel('timeline') + }, [setFocusedPanel]) + + useEffect(() => { + if (!panelFocused) return + panelRef.current + ?.querySelector(`[data-timeline-row="${cursorIdx}"]`) + ?.scrollIntoView({ block: 'nearest' }) + }, [cursorIdx, panelFocused]) + + // --- Hover preview ------------------------------------------------------- + const [hover, setHover] = useState<{ + meta: NoteMeta + rect: Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom' | 'width' | 'height'> + } | null>(null) + const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null) + const clearHover = useCallback(() => { + if (hoverTimer.current) clearTimeout(hoverTimer.current) + hoverTimer.current = null + setHover(null) + }, []) + const armHover = useCallback( + (el: HTMLElement, task: VaultTask) => { + const meta = notesByPath.get(task.sourcePath) + if (!meta) return + // Beside the panel, level with the row, as the calendar places it, so + // the preview never covers the rows the pointer moves on to. + const row = el.getBoundingClientRect() + const edge = panelRef.current?.getBoundingClientRect().left ?? row.left + const rect = { left: edge, right: edge, width: 0, top: row.top, bottom: row.bottom, height: row.height } + if (hoverTimer.current) clearTimeout(hoverTimer.current) + hoverTimer.current = setTimeout(() => setHover({ meta, rect }), HOVER_DELAY_MS) + }, + [notesByPath] + ) + useEffect(() => clearHover, [clearHover]) + + // --- Inline edit and the task menu ---------------------------------------- + const [editingTaskId, setEditingTaskId] = useState<string | null>(null) + const [editValue, setEditValue] = useState('') + const cancelEditRef = useRef(false) + const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) + const openMenuAt = useCallback( + (x: number, y: number, task: VaultTask) => { + clearHover() + setMenu({ + x, + y, + items: buildTaskMenuItems(task, { + today, + // Only x of the task keys works here, so the menu names none, as + // the calendar panel's does. + showKeyHints: false, + onEdit: () => { + setEditingTaskId(task.id) + setEditValue(taskTail(task)) + } + }) + }) + }, + [clearHover, today] + ) + const closeMenu = useCallback(() => setMenu(null), []) + + const focusEditor = useCallback(() => { + const s = useStore.getState() + s.setFocusedPanel('editor') + s.editorViewRef?.focus() + }, []) + + // --- Keyboard ------------------------------------------------------------ + const gPending = useRef(false) + const gTimer = useRef<ReturnType<typeof setTimeout>>() + useEffect(() => () => clearTimeout(gTimer.current), []) + + const onKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => { + const panel = panelRef.current + const target = event.target as HTMLElement + // Portaled children (the task menu) bubble here through React's tree; only + // keys aimed at the panel's own DOM are ours. + if (!panel || !(target instanceof HTMLElement) || !panel.contains(target)) return + if (event.defaultPrevented) return + if (isOverlayOrDialogOpen() || document.querySelector('[data-vim-hint-overlay]')) return + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return + } + if (event.metaKey || event.ctrlKey || event.altKey) return + // A focused control keeps its own Enter and Space. + const onControl = target !== panel && !!target.closest('button, a, [role="checkbox"]') + if (onControl && (event.key === 'Enter' || event.key === ' ')) return + // With Vim off the single-character keys are not the panel's: arrows, + // Home/End, Enter and Escape are universal, the letters belong to Vim. + if (!vimMode && event.key.length === 1) return + + const native = event.nativeEvent + const rows = rail.rows + const current: TimelineRow | undefined = rows[cursorIdx] + const consume = (): void => { + event.preventDefault() + event.stopPropagation() + } + const move = (to: number): void => { + if (rows.length === 0) return + setCursor(Math.max(0, Math.min(rows.length - 1, to))) + } + + if (event.key === 'Escape' || event.key === 'ArrowLeft') { + consume() + focusEditor() + return + } + if (event.key === 'ArrowDown' || (vimMode && matchesSequenceToken(native, keymapOverrides, 'nav.moveDown'))) { + consume() + move(cursorIdx + 1) + return + } + if (event.key === 'ArrowUp' || (vimMode && matchesSequenceToken(native, keymapOverrides, 'nav.moveUp'))) { + consume() + move(cursorIdx - 1) + return + } + if (event.key === 'Home' || event.key === 'End') { + consume() + move(event.key === 'Home' ? 0 : rows.length - 1) + return + } + if (event.key === 'Enter') { + consume() + if (current) void openTaskAt(current.task) + return + } + if ( + (event.shiftKey && event.key === 'F10') || + event.key === 'ContextMenu' || + (vimMode && matchesSequenceToken(native, keymapOverrides, 'nav.contextMenu')) + ) { + consume() + if (!current) return + const row = panel.querySelector(`[data-timeline-row="${cursorIdx}"]`)?.getBoundingClientRect() + openMenuAt(row ? row.left + 24 : 0, row ? row.bottom : 0, current.task) + return + } + if (!vimMode) return + + if (matchesSequenceToken(native, keymapOverrides, 'nav.toggleTask')) { + consume() + if (current) void toggleTaskFromList(current.task) + return + } + if (matchesSequenceToken(native, keymapOverrides, 'nav.filter')) { + consume() + filterRef.current?.focus() + return + } + if (matchesSequenceToken(native, keymapOverrides, 'nav.jumpBottom')) { + consume() + move(rows.length - 1) + return + } + // `g g`, or whatever two-key sequence nav.jumpTop is bound to. + const top = getSequenceTokens(keymapOverrides, 'nav.jumpTop') + const token = sequenceTokenFromEvent(native) + if (top.length === 1 && token === top[0]) { + consume() + move(0) + return + } + if (top.length === 2) { + if (gPending.current && token === top[1]) { + consume() + gPending.current = false + clearTimeout(gTimer.current) + move(0) + return + } + if (token === top[0]) { + consume() + gPending.current = true + clearTimeout(gTimer.current) + gTimer.current = setTimeout(() => (gPending.current = false), 600) + return + } + } + } + + // --- Drawing ------------------------------------------------------------- + const toggleKey = vimMode ? getKeymapDisplay(keymapOverrides, 'nav.toggleTask') || null : null + const rowIndex = useMemo(() => new Map(rail.rows.map((row, i) => [row.task.id, i])), [rail.rows]) + + const renderRow = (row: TimelineRow): JSX.Element => { + const { task } = row + const index = rowIndex.get(task.id) ?? -1 + if (editingTaskId === task.id) { + return ( + <div key={task.id} className="flex items-center gap-2 py-1 pl-2 pr-1"> + <span className="h-4 w-4 shrink-0 rounded border border-paper-400/70" /> + <input + autoFocus + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + } else if (e.key === 'Escape') { + e.stopPropagation() + cancelEditRef.current = true + e.currentTarget.blur() + } + }} + onBlur={() => { + if (cancelEditRef.current) { + cancelEditRef.current = false + } else { + const text = editValue.trim() + if (text && text !== taskTail(task)) { + void applyTaskMutation(task, { kind: 'set-text', text }) + } + } + setEditingTaskId(null) + panelRef.current?.focus({ preventScroll: true }) + }} + className="min-w-0 flex-1 rounded border border-accent/60 bg-paper-100 px-1 py-0.5 text-xs outline-none" + spellCheck={false} + autoComplete="off" + /> + </div> + ) + } + const isCursor = panelFocused && index === cursorIdx + return ( + <div + key={task.id} + data-timeline-row={index} + data-timeline-task={task.id} + data-timeline-milestone={row.milestone ? 'true' : undefined} + onClick={() => { + setCursor(index) + clearHover() + void openTaskAt(task) + }} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + setCursor(index) + openMenuAt(e.clientX, e.clientY, task) + }} + onMouseEnter={(e) => armHover(e.currentTarget, task)} + onMouseLeave={clearHover} + className={[ + 'flex cursor-pointer items-start gap-2 rounded-md py-1 pl-2 pr-1 transition-colors', + isCursor ? 'vim-cursor' : row.milestone ? 'bg-accent/5 hover:bg-accent/10' : 'hover:bg-paper-200/60' + ].join(' ')} + > + <TaskStateBox + task={task} + onToggle={() => void toggleTaskFromList(task)} + toggleKey={toggleKey} + // The muted ink the note title under it uses: the default 40% of + // the text color all but vanishes on the light themes' paper. + idleClassName="border border-ink-400 hover:bg-current/10" + /> + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-1.5"> + <InlineMarkdown + text={task.content || '(empty task)'} + interactiveLinks={false} + className={[ + 'min-w-0 flex-1 truncate text-xs', + row.milestone ? 'font-semibold text-ink-900' : 'text-ink-800' + ].join(' ')} + /> + {task.priority && ( + <span className={`shrink-0 text-2xs font-medium ${priorityClass(task.priority)}`}> + {priorityLabel(task.priority)} + </span> + )} + {row.lateDays > 0 && ( + <span + data-timeline-late + className="shrink-0 rounded bg-danger/15 px-1.5 py-px text-2xs font-medium text-danger" + > + {row.lateDays}d late + </span> + )} + </div> + <div className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs text-ink-400"> + <span className="truncate">{task.noteTitle}</span> + {row.before !== null && ( + <span data-timeline-before className="shrink-0"> + · {plural(row.before, 'task', 'tasks')} before it + </span> + )} + <CloudTaskConflictIndicator path={task.sourcePath} /> + </div> + </div> + </div> + ) + } + + // A node sits on the rail line, which runs down the left edge at left-3. + const node = (className: string): JSX.Element => ( + <span + aria-hidden + className={`absolute left-3 top-1/2 -translate-x-1/2 -translate-y-1/2 ${className}`} + /> + ) + + const renderEntry = (entry: TimelineEntry): JSX.Element => { + if (entry.kind === 'quiet') { + return ( + <div + key={`quiet-${entry.afterIso}`} + data-timeline-quiet={entry.days} + className="py-1.5 pl-6 text-2xs italic text-ink-400" + > + {plural(entry.days, 'quiet day', 'quiet days')} + </div> + ) + } + if (entry.kind === 'overdue') { + return ( + <div key="overdue" data-timeline-overdue className="pb-1"> + <div className="relative flex h-6 items-center pl-6"> + {node('h-2 w-2 rounded-full bg-danger')} + <span className="text-2xs font-semibold uppercase tracking-wide text-danger"> + Overdue · {entry.rows.length} + </span> + </div> + <div className="space-y-0.5 pl-4">{entry.rows.map(renderRow)}</div> + </div> + ) + } + const label = timelineDayLabel(entry.iso, today) + return ( + <div key={entry.iso} data-timeline-day={entry.iso} className="pb-1"> + <div className="relative flex h-6 items-center pl-6"> + {entry.today + ? node( + entry.milestone + ? 'h-3 w-3 rotate-45 border-2 border-accent bg-paper-50' + : 'h-3 w-3 rounded-full border-2 border-accent bg-paper-50' + ) + : entry.milestone + ? node('h-2.5 w-2.5 rotate-45 bg-accent') + : node('h-2 w-2 rounded-full bg-ink-400')} + <span + className={[ + 'text-2xs font-semibold uppercase tracking-wide', + entry.today ? 'text-accent' : entry.milestone ? 'text-ink-800' : 'text-ink-500' + ].join(' ')} + > + {entry.today ? `Today · ${label}` : label} + {entry.milestone && <span className="font-medium normal-case"> · milestone</span>} + </span> + </div> + {entry.rows.length > 0 ? ( + <div className="space-y-0.5 pl-4">{entry.rows.map(renderRow)}</div> + ) : ( + entry.today && <div className="py-1 pl-6 text-2xs text-ink-400">Nothing due today</div> + )} + </div> + ) + } + + // Vim's keys only, so with Vim off nothing here names a key that does + // nothing; an unbound action drops out rather than showing an empty chip. + const display = (id: Parameters<typeof getKeymapDisplay>[1]): string => + getKeymapDisplay(keymapOverrides, id) + const keyHints: Array<[string, string]> = vimMode + ? ( + [ + [[display('nav.moveDown'), display('nav.moveUp')].filter(Boolean).join('/'), 'move'], + [display('nav.toggleTask'), 'tick'], + ['⏎', 'open'], + [display('nav.contextMenu'), 'menu'], + [display('nav.filter'), 'filter'], + ['Esc', 'back'] + ] as Array<[string, string]> + ).filter(([key]) => key !== '') + : [] + const loadingFirstScan = tasksLoading && vaultTasks.length === 0 + + return ( + <section + ref={panelRef} + data-timeline-panel + aria-label="Timeline" + tabIndex={0} + style={{ width: fitWidth ?? width }} + onMouseDownCapture={claimFocus} + onFocusCapture={claimFocus} + onKeyDown={onKeyDown} + className="relative flex shrink-0 flex-col border-l border-paper-300/70 bg-paper-50/18 outline-none" + > + <PanelResizeHandle onStart={startResize} /> + + <div className="border-b border-paper-300/60 px-4 py-4"> + <div className="flex items-baseline justify-between gap-2"> + <div className="text-xs font-medium uppercase tracking-[0.16em] text-ink-400">Timeline</div> + <div data-timeline-count className="truncate text-2xs text-ink-500"> + {rail.openCount} open + {rail.overdueCount > 0 && <span className="text-danger"> · {rail.overdueCount} overdue</span>} + </div> + </div> + <div + role="group" + aria-label="How far ahead" + className="mt-3 flex items-center gap-0.5 rounded-md bg-paper-200/60 p-0.5" + > + {RANGE_BUTTONS.map(({ id, label }) => ( + <button + key={id} + type="button" + data-timeline-range={id} + aria-pressed={range === id} + onClick={() => setRange(id)} + className={[ + 'flex-1 whitespace-nowrap rounded px-2 py-1 text-xs transition-colors', + range === id + ? 'bg-paper-50 text-ink-900 shadow-sm' + : 'text-ink-500 hover:bg-paper-200/60 hover:text-ink-800' + ].join(' ')} + > + {label} + </button> + ))} + </div> + <input + ref={filterRef} + type="text" + data-timeline-filter + value={filter} + onChange={(e) => { + setFilter(e.target.value) + setCursor(0) + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + if (filter) setFilter('') + else panelRef.current?.focus({ preventScroll: true }) + } else if (e.key === 'Enter') { + e.preventDefault() + panelRef.current?.focus({ preventScroll: true }) + } + }} + placeholder="Filter: text, #tag, !high, @key:value" + aria-label="Filter the timeline" + className="mt-2 w-full rounded-md border border-paper-300/60 bg-paper-200/40 px-2 py-1 text-xs outline-none placeholder:text-ink-400 focus:border-accent/60" + spellCheck={false} + autoComplete="off" + /> + </div> + + <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3"> + {loadingFirstScan ? ( + <div className="px-2 py-1 text-xs text-ink-400">Reading tasks…</div> + ) : ( + <div className="relative"> + <span + aria-hidden + className="absolute bottom-0 left-3 top-3 w-px -translate-x-1/2 bg-paper-300" + /> + {rail.entries.map(renderEntry)} + </div> + )} + </div> + + {(rail.undatedCount > 0 || rail.laterCount > 0 || keyHints.length > 0) && ( + <div className="space-y-1 border-t border-paper-300/60 px-3 py-2"> + {(rail.undatedCount > 0 || rail.laterCount > 0) && ( + <div className="flex flex-wrap items-center gap-1"> + {rail.laterCount > 0 && rail.rangeEnd && ( + <Button + variant="ghost" + data-timeline-later + title="Show every dated task" + onClick={() => setRange('all')} + > + {rail.laterCount} after {shortDate(rail.rangeEnd)} + </Button> + )} + {rail.undatedCount > 0 && ( + <Button + variant="ghost" + data-timeline-undated + title="Open the Tasks view, where tasks with no date are listed" + onClick={() => void openTasksView()} + > + {rail.undatedCount} undated in Tasks + </Button> + )} + </div> + )} + {keyHints.length > 0 && ( + <div data-timeline-hints className="flex flex-wrap gap-x-2 gap-y-0.5 text-2xs text-ink-400"> + {keyHints.map(([key, label]) => ( + <span key={label}> + <kbd className="font-mono text-ink-500">{key}</kbd> {label} + </span> + ))} + </div> + )} + </div> + )} + + {hover && <NoteHoverPreview note={hover.meta} anchorRect={hover.rect} />} + {menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={closeMenu} />} + </section> + ) +} diff --git a/packages/app-core/src/components/TuckedPanelsRail.tsx b/packages/app-core/src/components/TuckedPanelsRail.tsx index 099da9a41..bf9688439 100644 --- a/packages/app-core/src/components/TuckedPanelsRail.tsx +++ b/packages/app-core/src/components/TuckedPanelsRail.tsx @@ -1,11 +1,12 @@ import { TUCKED_RAIL_WIDTH, type SidePanelId } from '../lib/side-panel-fit' -import { CalendarIcon, FeedbackIcon, ListTreeIcon, PanelRightIcon } from './icons' +import { CalendarIcon, FeedbackIcon, ListTreeIcon, PanelRightIcon, TimelineIcon } from './icons' const PANELS: Record<SidePanelId, { label: string; icon: typeof PanelRightIcon }> = { connections: { label: 'Connections', icon: PanelRightIcon }, comments: { label: 'Comments', icon: FeedbackIcon }, outline: { label: 'Outline', icon: ListTreeIcon }, - calendar: { label: 'Calendar', icon: CalendarIcon } + calendar: { label: 'Calendar', icon: CalendarIcon }, + timeline: { label: 'Timeline', icon: TimelineIcon } } /** diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 5926f2b8e..7e839f18e 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -221,6 +221,9 @@ export function VimNav(): JSX.Element | null { const calendarToggleAvailable = useStore((s) => isCalendarToggleAvailable(s.vaultSettings, s.activeNote) ) + // The Timeline sits beside a note like the other right-hand panels, so its + // hint shows only where a note is open to sit beside. (#1007) + const timelineToggleAvailable = useStore((s) => s.activeNote != null) const cloudConflictsWaiting = useCloudSyncStatusStore( (s) => resolvableCloudConflictCount(s.lastSummary) > 0 || s.settingsConflict !== null ) @@ -422,6 +425,15 @@ export function VimNav(): JSX.Element | null { detail: 'Show or hide the calendar for the active daily/weekly note.' } ] + : []), + ...(timelineToggleAvailable + ? [ + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderTimeline'), + label: 'Toggle timeline', + detail: 'Open tasks in date order, with overdue tasks and milestones marked.' + } + ] : []) ] if (whichKeyState.allowEditorActions) { @@ -560,6 +572,21 @@ export function VimNav(): JSX.Element | null { ) { return } + // #1007: the Timeline panel owns its keys the same way, through its own + // onKeyDown, which runs after this capture handler. Unlike the calendar + // it lets the leader through as well, so `<leader>n` closes the panel + // from inside it and `<leader>h` hints over its rows; everything that is + // not a leader sequence goes back to the panel below. + const inTimelinePanel = !!target?.closest('[data-timeline-panel]') + if ( + inTimelinePanel && + !ctrlWPending.current && + sequenceTokenFromEvent(e) !== panePrefixToken && + sequenceTokenFromEvent(e) !== leaderToken && + !leaderPending.current + ) { + return + } // #309: In an Excalidraw canvas, hold-Space pans (the Hand tool). Don't // swallow the Space keydown as the leader — let it reach Excalidraw so // panning works, and arm the leader only on a quick TAP (see the keyup @@ -1142,6 +1169,27 @@ export function VimNav(): JSX.Element | null { } return } + if (matchesSequenceToken(e, overrides, 'vim.leaderTimeline')) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + // Like the calendar: opening hands the keyboard to the panel (it + // focuses itself once it sees focusedPanel 'timeline'), so j/k work + // at once; closing leaves focus where it is, or brings it back to + // the editor when it was in the panel. (#1007) + if (state.activeNote) { + const panelEl = document.querySelector('[data-timeline-panel]') + const wasOpen = panelEl !== null + const focusWasInside = !!panelEl && panelEl.contains(document.activeElement) + window.dispatchEvent(new Event('zen:toggle-timeline')) + if (!wasOpen) state.setFocusedPanel('timeline') + else if (focusWasInside) { + state.setFocusedPanel('editor') + state.editorViewRef?.focus() + } + } + return + } // Any other key cancels leader and falls through to normal routing. resetLeader() } @@ -1212,6 +1260,11 @@ export function VimNav(): JSX.Element | null { if (panelViewActive && panelViewFocused && sequenceTokenFromEvent(e) !== leaderToken) { return } + // Same for the Timeline panel (#1007): a key that ended a leader + // sequence without matching it belongs to the panel again. + if (inTimelinePanel && sequenceTokenFromEvent(e) !== leaderToken) { + return + } // #338: an unbound second leader press — a leader sequence was pending and // no binding above consumed this key — dismisses the pending which-key diff --git a/packages/app-core/src/components/icons.tsx b/packages/app-core/src/components/icons.tsx index 4c6d3305f..a7d4273e5 100644 --- a/packages/app-core/src/components/icons.tsx +++ b/packages/app-core/src/components/icons.tsx @@ -211,6 +211,17 @@ export const CalendarIcon = (p: IconProps): JSX.Element => ( </I> ) +/** Tabler's `timeline`: a line through four points, for the Timeline panel. */ +export const TimelineIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M4 16l6-7 5 5 5-6" /> + <circle cx="15" cy="14" r="1" /> + <circle cx="10" cy="9" r="1" /> + <circle cx="4" cy="16" r="1" /> + <circle cx="20" cy="8" r="1" /> + </I> +) + export const KanbanIcon = (p: IconProps): JSX.Element => ( <I {...p}> <rect x="3" y="4" width="5" height="16" rx="1" /> diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 5ed7a0b0f..77df51a67 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -881,11 +881,24 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma window.dispatchEvent(new Event('zen:toggle-calendar')) } }, + { + id: 'view.timeline-panel', + title: 'Toggle Timeline', + category: 'View', + shortcut: shortcut('global.toggleTimelinePanel'), + keywords: 'timeline tasks due dates overdue milestones upcoming agenda schedule panel right', + // Like the other right-hand panels it sits beside a note, so it needs + // one in the pane; the tasks it lists are the whole vault's. (#1007) + when: () => !!getState().activeNote, + run: () => { + window.dispatchEvent(new Event('zen:toggle-timeline')) + } + }, { id: 'view.close-right-panel', title: 'Close Right Panel', category: 'View', - keywords: 'close hide dismiss right panel pane connections comments outline calendar', + keywords: 'close hide dismiss right panel pane connections comments outline calendar timeline', when: () => !!getState().activeNote, run: () => { window.dispatchEvent(new Event('zen:close-right-panel')) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 884de9387..2fc05adec 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -71,7 +71,7 @@ export const HELP_QUICK_START: HelpCard[] = [ { title: 'Keep supporting material nearby', body: - 'Tabs, splits, floating windows, the reference pane, and the connections panel are all there to help you keep related material visible while you write instead of forcing constant back-and-forth navigation. The Connections, Outline, Comments and Calendar panels stay as you set them while you move between notes. If you would rather each note kept its own, turn off Settings → Editor → Keep panels when switching notes (or run Remember Panels per Note from the command palette): a note then comes back with the panels you left it with, after a restart too (the memory is saved with your tabs and layout, never in the note), and a note you have not opened yet starts with none. With the automatic calendar on, a daily or weekly note whose calendar you closed stays closed when you return to it. The setting travels with your portable config as `keep_panels_across_notes` under `[view]`. The note always keeps a readable width: in a narrow pane (a split, a small window) the panels first shrink, and if they still do not fit, the ones you opened longest ago are tucked into a slim rail at the pane\'s right edge. They stay open; click one in the rail, or press its usual shortcut, to bring it forward.' + 'Tabs, splits, floating windows, the reference pane, and the connections panel are all there to help you keep related material visible while you write instead of forcing constant back-and-forth navigation. The Connections, Outline, Comments, Calendar and Timeline panels stay as you set them while you move between notes. If you would rather each note kept its own, turn off Settings → Editor → Keep panels when switching notes (or run Remember Panels per Note from the command palette): a note then comes back with the panels you left it with, after a restart too (the memory is saved with your tabs and layout, never in the note), and a note you have not opened yet starts with none. With the automatic calendar on, a daily or weekly note whose calendar you closed stays closed when you return to it. The setting travels with your portable config as `keep_panels_across_notes` under `[view]`. The note always keeps a readable width: in a narrow pane (a split, a small window) the panels first shrink, and if they still do not fit, the ones you opened longest ago are tucked into a slim rail at the pane\'s right edge. They stay open; click one in the rail, or press its usual shortcut, to bring it forward.' }, { title: 'Use files without leaving ZenNotes', @@ -344,6 +344,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: "Switch Tasks to Calendar (button or `2`) to see tasks laid out by due date. A task written inside a daily note automatically shows on that day (no `due:` needed), so the day you wrote it on is the day it lands. Type in the box under the grid to add a task to the selected day (it’s created in that day’s daily note, offering to create the note first for a day that has none). Reschedule by dragging a task onto another day, or from the keyboard: `Tab` picks a task in the day list, `<` / `>` shifts it a day earlier/later, and `T` moves it to today." }, + { + title: 'The Timeline panel shows what is coming up', + body: + 'The Timeline is a right-hand panel beside your note that lays every open task in the vault with a date down one line, in date order. Open it with `Mod+7`, `Space n` in Vim mode, `:timeline`, Toggle Timeline in the command palette, or its button in the note header. A task’s date is its `due:` date, or for a task in a daily note, that note’s date, the same as the calendars. Overdue tasks sit on top in red, each with how many days late it is; Today follows with the accent ring, then one node for each later day that has work, and a run of empty days in between folds into one “N quiet days” line. Which tasks count is the Tasks view’s rule: open Today and Upcoming work, `[/]` in progress included; done, cancelled, forwarded and @waiting tasks keep to their own groups in Tasks. The header counts what is on the rail (“9 open · 2 overdue”), and the switch under it reaches 2 weeks ahead (the default), a month, or All; tasks past the range are counted at the bottom, and clicking that count shows them all. The choice is kept in config.toml as `timeline_range` under `[view]`. The filter takes the Tasks filter syntax: words, `#tag`, `!high`, `@key:value`. Tag a task `#milestone` to make it a milestone: its day draws as a diamond marked “· milestone”, its row stands out, and it says how many open tasks are due before it, counted from the previous milestone (or from the top of the rail for the first one, overdue included), so each task counts toward exactly one milestone. Nowhere else treats `#milestone` as special. Undated tasks stay off the rail; “N undated in Tasks” at the bottom opens the Tasks view, where they are listed. Click a row (or press Enter) to open the note at the task’s line, tick its box to check it off (it leaves the rail), right-click for the task menu, and rest the pointer on it to read the note. In Vim mode `j` / `k` move, `gg` / `G` jump to the ends, `x` ticks, `m` opens the task menu and `/` focuses the filter; with Vim mode off the arrows, Home / End, Enter and Escape work. Escape (or ←) hands the keyboard back to the note. Like the other panels it resizes from its left edge, tucks into the rail in a narrow pane, and stays open as you move between notes.' + }, { title: 'The Tasks Kanban board, custom statuses, and any field', body: @@ -367,7 +372,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Right-click a task for actions', body: - 'Every task surface has the same right-click menu: the Tasks list, the Kanban board, the Tasks calendar, and the calendar side panel. It carries Open note, Mark done, Mark in progress, Cancel, @waiting, Forward to note…, due-date presets (today / tomorrow / next week / clear) with Choose a date… for any other day, priority, and Delete, with the keyboard equivalent shown beside each one so the menu doubles as a reminder of the shortcut. Right-clicking also moves the cursor to that task, so the menu and the keyboard always act on the same row. The menu takes typing as a filter: right-click, type “prio”, and only the priority entries stay. Editing a task inline is offered on the calendar surfaces, which have an edit field; elsewhere “Open note” takes you to the line.' + 'Every task surface has the same right-click menu: the Tasks list, the Kanban board, the Tasks calendar, the calendar side panel, and the Timeline panel. It carries Open note, Mark done, Mark in progress, Cancel, @waiting, Forward to note…, due-date presets (today / tomorrow / next week / clear) with Choose a date… for any other day, priority, and Delete, with the keyboard equivalent shown beside each one so the menu doubles as a reminder of the shortcut. Right-clicking also moves the cursor to that task, so the menu and the keyboard always act on the same row. The menu takes typing as a filter: right-click, type “prio”, and only the priority entries stay. Editing a task inline is offered on the calendar surfaces and the Timeline, which have an edit field; elsewhere “Open note” takes you to the line.' }, { title: 'Mark a task in progress', @@ -564,9 +569,10 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+1', action: 'Toggle sidebar', detail: 'Hide or show the left sidebar.' }, { keys: 'Mod+2', action: 'Toggle connections', detail: 'Toggle the connections panel for the active editor pane.' }, { keys: 'Mod+Shift+C', action: 'Toggle comments panel', detail: 'Show or hide the Comments panel for the active pane.' }, + { keys: 'Mod+7', action: 'Toggle timeline panel', detail: 'Show or hide the Timeline panel for the active pane: every open task with a date, in date order, overdue tasks and `#milestone` tasks marked. Also `Space n` in Vim mode, `:timeline`, and Toggle Timeline in the command palette.' }, { keys: 'Mod+Alt+M', action: 'Add comment', detail: 'Start a comment on the selected text (or the current line) without reaching for the mouse.' }, { keys: 'a', action: 'Reply in a comment thread', detail: 'Comments panel: open a reply box under the selected comment; `Mod+Enter` sends it, `Esc` cancels.' }, - { keys: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions: they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.) Both walk the same cycle, in the order the panels appear on screen: sidebar → note list → editor → connections → comments → outline → calendar, and back again.' }, + { keys: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions: they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.) Both walk the same cycle, in the order the panels appear on screen: sidebar → note list → editor → connections → comments → outline → calendar → timeline, and back again.' }, { keys: '↑ / ↓ / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus, the arrows move its row cursor, Home and End jump to the ends, Enter opens the row under the cursor, and Esc (or ←) returns focus to the editor. These work with Vim mode off; the single-key motions (j / k, gg / G) stay Vim-only.' }, { keys: 'Mod+.', action: 'Toggle Zen mode', detail: 'Hide or restore the app chrome so only the active editor, preview, or split view stays on screen.' }, { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab. With Settings open it closes Settings instead, and in a floating window or the quick capture window it closes that window, like its close button. While a dialog, palette or menu is open it does nothing, so it never closes the tab behind it; press Esc to dismiss those first. With Vim mode on, Linux and Windows keep Ctrl+W as the pane prefix while a tab is open: close the tab with :q or :bd, from the tab menu, or rebind this.' }, @@ -628,6 +634,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space b', action: 'Hide to tray', detail: 'With Settings → Appearance → Keep running in the tray on, hide this window. ZenNotes keeps running, Cloud sync and the Quick capture hotkey keep working, and the tray icon brings the window back. Hide Window to Tray in the command palette does the same.' }, { keys: 'Space i', action: 'Insert template into note', detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it. Without Vim mode, give “Insert template into current note” a key under Settings → Keymap.' }, { keys: 'Space c', action: 'Toggle calendar', detail: 'Show or hide the calendar panel for the active pane.' }, + { keys: 'Space n', action: 'Toggle timeline', detail: 'Show or hide the Timeline panel for the active pane, and move the keyboard into it: j / k move, gg / G jump to the ends, x ticks the task, Enter opens it at its line, m opens the task menu, / focuses the filter, and Esc goes back to the note. Mod+7 toggles it in any mode.' }, { keys: 'Space l s', action: 'Toggle favorite', detail: 'Add or remove the active note from the Favorites section shown in the sidebar and on the home view. Folders join it from their context menu.' }, { keys: 'Space l c', action: 'Cover image', detail: 'Open the cover picker for the active note: a gradient, a gallery photo, an image in the vault, an upload, a pasted image or an https link. Also “Add Cover…” in the command palette and `:cover`.' }, { keys: 'Space l u', action: 'Cover from Unsplash', detail: 'Open the cover picker on its Unsplash tab to search free photos for the active note’s cover. Also “Add Cover from Unsplash…” in the command palette and `:cover unsplash {search}`.' }, @@ -1111,6 +1118,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Leader toggle calendar', detail: 'Show or hide the calendar panel for the active pane.' }, + { + command: '<Space> n', + summary: 'Leader toggle timeline', + detail: 'Show or hide the Timeline panel (open tasks in date order, overdue and `#milestone` tasks marked) and move the keyboard into it.' + }, { command: '<Space> l s', summary: 'Leader toggle favorite', @@ -1144,7 +1156,12 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ { command: ':closepanel / :closep', summary: 'Close the right panel', - detail: 'Dismiss whichever right-hand panel is open (connections, outline, comments, calendar). Also a “Close Right Panel” command in the palette.' + detail: 'Dismiss whichever right-hand panel is open (connections, outline, comments, calendar, timeline). Also a “Close Right Panel” command in the palette.' + }, + { + command: ':timeline', + summary: 'Toggle the Timeline panel', + detail: 'Show or hide the Timeline panel in the active pane, the same as Mod+7 or `Space n`.' }, { command: ':outline', diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 6faeb60bc..68c474b8a 100644 --- a/packages/app-core/src/lib/keymaps.test.ts +++ b/packages/app-core/src/lib/keymaps.test.ts @@ -587,3 +587,64 @@ describe('keymapAvailableOnHost', () => { for (const id of rest) expect(keymapAvailableOnHost(id, {})).toBe(true) }) }) + +describe('Timeline panel keymaps (#1007)', () => { + it('binds Mod+7 and leader n, like the other right-hand panels', () => { + expect(getKeymapDefinition('global.toggleTimelinePanel')).toMatchObject({ + kind: 'shortcut', + scope: 'app', + group: 'global', + title: 'Toggle timeline panel', + defaultBinding: 'Mod+7' + }) + expect(getKeymapDefinition('vim.leaderTimeline')).toMatchObject({ + kind: 'sequence', + scope: 'leader', + group: 'vim', + title: 'Leader: toggle timeline', + defaultBinding: 'n', + vimOnly: true + }) + }) + + it('takes a key no other global shortcut or leader action holds', () => { + expect(findKeymapConflict({}, 'global.toggleTimelinePanel', 'Mod+7')).toBeNull() + // No leader binding at any depth uses n, so nothing else answers it. + const leaderKeys = getKeymapDefinitions() + .filter((def) => def.scope === 'leader' && def.id !== 'vim.leaderTimeline') + .map((def) => def.defaultBinding) + expect(leaderKeys).not.toContain('n') + }) + + it('fires on Cmd+7 on the Mac and Ctrl+7 elsewhere, and not on the tab digits', () => { + withPlatform('darwin', () => { + const cmd7 = fakeEvent({ key: '7', code: 'Digit7', metaKey: true }) + expect(matchesShortcut(cmd7, {}, 'global.toggleTimelinePanel')).toBe(true) + expect(matchesShortcut(cmd7, {}, 'tabs.select7')).toBe(false) + const ctrl7 = fakeEvent({ key: '7', code: 'Digit7', ctrlKey: true }) + expect(matchesShortcut(ctrl7, {}, 'global.toggleTimelinePanel')).toBe(false) + }) + withPlatform('linux', () => { + const ctrl7 = fakeEvent({ key: '7', code: 'Digit7', ctrlKey: true }) + expect(matchesShortcut(ctrl7, {}, 'global.toggleTimelinePanel')).toBe(true) + const alt7 = fakeEvent({ key: '7', code: 'Digit7', altKey: true }) + expect(matchesShortcut(alt7, {}, 'global.toggleTimelinePanel')).toBe(false) + }) + }) + + it('can be rebound or unbound like its neighbours', () => { + withPlatform('darwin', () => { + const unbound = { 'global.toggleTimelinePanel': '', 'vim.leaderTimeline': '' } + expect(normalizeKeymapOverrides(unbound)).toEqual(unbound) + expect(getKeymapDisplay(unbound, 'global.toggleTimelinePanel')).toBe('') + const cmd7 = fakeEvent({ key: '7', code: 'Digit7', metaKey: true }) + expect(matchesShortcut(cmd7, unbound, 'global.toggleTimelinePanel')).toBe(false) + const n = fakeEvent({ key: 'n', code: 'KeyN' }) + expect(matchesSequenceToken(n, {}, 'vim.leaderTimeline')).toBe(true) + expect(matchesSequenceToken(n, unbound, 'vim.leaderTimeline')).toBe(false) + const rebound = { 'vim.leaderTimeline': 'T' } + const shiftT = fakeEvent({ key: 'T', code: 'KeyT', shiftKey: true }) + expect(matchesSequenceToken(shiftT, rebound, 'vim.leaderTimeline')).toBe(true) + }) + }) +}) diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 6ab45817a..be80026a8 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -22,6 +22,7 @@ export type KeymapId = | "global.toggleConnections" | "global.toggleOutlinePanel" | "global.toggleCommentsPanel" + | "global.toggleTimelinePanel" | "global.addComment" | "global.focusPaneLeft" | "global.focusPaneRight" @@ -78,6 +79,7 @@ export type KeymapId = | "vim.leaderWeeklyNote" | "vim.leaderMonthlyNote" | "vim.leaderCalendar" + | "vim.leaderTimeline" | "vim.leaderUnresolvedLinks" | "vim.panePrefix" | "vim.paneFocusLeft" @@ -326,6 +328,17 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ description: "Toggle the comments panel in the active pane.", defaultBinding: "Mod+Shift+C", }, + { + id: "global.toggleTimelinePanel", + kind: "shortcut", + scope: "app", + group: "global", + title: "Toggle timeline panel", + // 7 because 1 to 6 are taken (sidebar, connections, outline, and the + // three pane modes): the next free digit beside its neighbours. (#1007) + description: "Toggle the Timeline panel in the active pane: open tasks in date order.", + defaultBinding: "Mod+7", + }, { id: "global.addComment", kind: "shortcut", @@ -840,6 +853,19 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderTimeline", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader: toggle timeline", + // `n` for "next": what is coming up. Unbound at the leader before this. + description: + "Toggle the Timeline panel and move the keyboard into it: open tasks in date order, overdue and milestones marked.", + defaultBinding: "n", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.leaderUnresolvedLinks", kind: "sequence", diff --git a/packages/app-core/src/lib/pane-nav.ts b/packages/app-core/src/lib/pane-nav.ts index 76c03330a..7bc65dc43 100644 --- a/packages/app-core/src/lib/pane-nav.ts +++ b/packages/app-core/src/lib/pane-nav.ts @@ -128,8 +128,9 @@ function resolveNeighborPanel( * * Row-list panels (sidebar / note list / connections / outline) don't take DOM * focus: their keys are handled centrally off `focusedPanel`, so we only blur - * whatever held focus and scroll their cursor row back into view. The comments - * and calendar panels own their keyboard handling, so they take real DOM focus. + * whatever held focus and scroll their cursor row back into view. The comments, + * calendar and timeline panels own their keyboard handling, so they take real + * DOM focus. */ export function focusPanel(panel: Panel, direction?: PaneDirection): void { const state = useStore.getState() @@ -146,7 +147,7 @@ export function focusPanel(panel: Panel, direction?: PaneDirection): void { ;(document.activeElement as HTMLElement | null)?.blur() if (panel === 'tasks' || panel === 'tags') return requestAnimationFrame(() => { - if (panel === 'comments' || panel === 'calendar') { + if (panel === 'comments' || panel === 'calendar' || panel === 'timeline') { document .querySelector<HTMLElement>(`[data-${panel}-panel]`) ?.focus({ preventScroll: true }) diff --git a/packages/app-core/src/lib/pane-panels.test.ts b/packages/app-core/src/lib/pane-panels.test.ts index 723a4c06c..354dd4cbf 100644 --- a/packages/app-core/src/lib/pane-panels.test.ts +++ b/packages/app-core/src/lib/pane-panels.test.ts @@ -141,3 +141,26 @@ describe('per-note panels in the workspace snapshot (#794)', () => { expect(prunePanePanels(live, () => true)).toBe(live) }) }) + +describe('the Timeline panel in per-note memory (#1007)', () => { + const panes = new Set(['pane-a']) + + it('remembers an open Timeline on a note and nothing when it is closed', () => { + const open = { ...PANE_PANELS_CLOSED, timeline: true } + const byPath = panePanelsWithPath({}, 'inbox/A.md', open) + expect(panePanelsForPath(byPath, 'inbox/A.md').timeline).toBe(true) + expect(panePanelsWithPath(byPath, 'inbox/A.md', PANE_PANELS_CLOSED)).toEqual({}) + }) + + it('survives the workspace snapshot, spelled as a single flag', () => { + const live = { 'pane-a': { 'inbox/A.md': { ...PANE_PANELS_CLOSED, timeline: true } } } + const snapshot = panePanelsForSnapshot(live, panes) + expect(snapshot).toEqual({ 'pane-a': { 'inbox/A.md': { timeline: true } } }) + expect(panePanelsFromSnapshot(JSON.parse(JSON.stringify(snapshot)), panes)).toEqual(live) + }) + + it('reads a snapshot written before the Timeline existed as closed', () => { + const restored = panePanelsFromSnapshot({ 'pane-a': { a: { outline: true } } }, panes) + expect(restored['pane-a'].a.timeline).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/pane-panels.ts b/packages/app-core/src/lib/pane-panels.ts index 75fa5443b..84f7d7ba8 100644 --- a/packages/app-core/src/lib/pane-panels.ts +++ b/packages/app-core/src/lib/pane-panels.ts @@ -1,8 +1,9 @@ import { CALENDAR_PANEL_CLOSED, type CalendarPanelState } from './calendar-panel-auto' /** - * Which right-hand panels an editor pane shows: Connections, Outline, Comments - * and the Calendar (whose open carries the provenance bit from #502). + * Which right-hand panels an editor pane shows: Connections, Outline, Comments, + * the Calendar (whose open carries the provenance bit from #502) and the + * Timeline (#1007). * * Panels have always been sticky per pane, Obsidian-style: open the Outline * and it stays while you browse. With "Keep panels when switching notes" off, @@ -16,6 +17,7 @@ export interface PanePanelsState { outline: boolean comments: boolean calendar: CalendarPanelState + timeline: boolean /** * The user closed the calendar on this note. Only written per note: there it * keeps the auto-open for daily and weekly notes from reopening a calendar @@ -33,6 +35,7 @@ export const PANE_PANELS_CLOSED: PanePanelsState = { outline: false, comments: false, calendar: CALENDAR_PANEL_CLOSED, + timeline: false, calendarDismissed: false } @@ -43,6 +46,7 @@ export function samePanePanels(a: PanePanelsState, b: PanePanelsState): boolean a.comments === b.comments && a.calendar.open === b.calendar.open && a.calendar.auto === b.calendar.auto && + a.timeline === b.timeline && a.calendarDismissed === b.calendarDismissed ) } @@ -105,6 +109,7 @@ export interface PanePanelsSnapshotEntry { outline?: true comments?: true calendar?: { open: true; auto?: true } + timeline?: true calendarDismissed?: true } @@ -136,6 +141,7 @@ export function panePanelsForSnapshot( ...(panels.calendar.open && { calendar: panels.calendar.auto ? { open: true, auto: true } : { open: true } }), + ...(panels.timeline && { timeline: true }), ...(panels.calendarDismissed && { calendarDismissed: true }) } ]) @@ -169,6 +175,7 @@ export function panePanelsFromSnapshot( outline: value.outline === true, comments: value.comments === true, calendar: { open: calendar.open === true, auto: calendar.open === true && calendar.auto === true }, + timeline: value.timeline === true, calendarDismissed: value.calendarDismissed === true }) } diff --git a/packages/app-core/src/lib/side-panel-fit.ts b/packages/app-core/src/lib/side-panel-fit.ts index a256bbc96..d49c47b8e 100644 --- a/packages/app-core/src/lib/side-panel-fit.ts +++ b/packages/app-core/src/lib/side-panel-fit.ts @@ -1,8 +1,8 @@ /** * How the right-hand panels of an editor pane share its width with the note. * - * Connections, Comments, Outline and Calendar each have a width the user - * chose (200 to 640 px) and can all be open at once, and nothing used to + * Connections, Comments, Outline, Calendar and Timeline each have a width the + * user chose (200 to 640 px) and can all be open at once, and nothing used to * reserve room for the note itself. In a full-width window that is fine. In a * split pane two panels left the note a column a few characters wide, and a * third left it none. (#805) @@ -13,7 +13,7 @@ * longest ago are tucked away (they stay open, a rail lists them, and their * shortcut brings one forward) until the rest fits. */ -export type SidePanelId = 'connections' | 'comments' | 'outline' | 'calendar' +export type SidePanelId = 'connections' | 'comments' | 'outline' | 'calendar' | 'timeline' /** Below this an editor column stops being readable. */ export const MIN_NOTE_WIDTH = 320 diff --git a/packages/app-core/src/lib/task-priority.ts b/packages/app-core/src/lib/task-priority.ts new file mode 100644 index 000000000..1e2e3b245 --- /dev/null +++ b/packages/app-core/src/lib/task-priority.ts @@ -0,0 +1,18 @@ +import type { VaultTask } from '@shared/tasks' + +/** How a task's priority reads beside it: the token as it is written on the + * line. Shared by the Tasks list and the Timeline panel so a priority looks + * the same wherever the task turns up. */ +export function priorityLabel(p: VaultTask['priority']): string { + if (p === 'high') return '!high' + if (p === 'med') return '!med' + if (p === 'low') return '!low' + return '' +} + +export function priorityClass(p: VaultTask['priority']): string { + if (p === 'high') return 'text-rose-400' + if (p === 'med') return 'text-amber-400' + if (p === 'low') return 'text-sky-400' + return 'text-current/50' +} diff --git a/packages/app-core/src/lib/task-tail.ts b/packages/app-core/src/lib/task-tail.ts new file mode 100644 index 000000000..f840a4ffa --- /dev/null +++ b/packages/app-core/src/lib/task-tail.ts @@ -0,0 +1,16 @@ +import type { VaultTask } from '@shared/tasks' + +// Every task state, so the "lossless" promise below actually holds for a `[>]` +// or `[/]` task too: an unmatched prefix falls back to the token-stripped +// `content`, which an inline edit would then write back over the real line. +// (#512) +const TASK_PREFIX_RE = /^\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[[ xX>/-]\]\s?/ + +/** The raw text after the checkbox (content + any `due:`/`!priority` tokens): + * what an inline edit field prefills with so editing is lossless. Shared by + * the calendar panel and the Timeline panel, the two surfaces that edit a + * task in place. */ +export function taskTail(task: VaultTask): string { + const m = task.rawText.match(TASK_PREFIX_RE) + return m ? task.rawText.slice(m[0].length) : task.content +} diff --git a/packages/app-core/src/lib/timeline-rail.test.ts b/packages/app-core/src/lib/timeline-rail.test.ts new file mode 100644 index 000000000..7e67f7326 --- /dev/null +++ b/packages/app-core/src/lib/timeline-rail.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest' +import { inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' +import { + buildTimelineRail, + daysBetween, + isMilestoneTask, + normalizeTimelineRange, + timelineDayLabel, + timelineRangeEnd, + type TimelineEntry +} from './timeline-rail' +import { filterTasks, noteTagsByPath } from './tasks-filter' + +// Wednesday 28 October 2026, local time. +const TODAY = new Date(2026, 9, 28, 15, 30) + +function task(id: string, overrides?: Partial<VaultTask>): VaultTask { + return { + id, + sourcePath: `${id}.md`, + noteTitle: id, + noteFolder: 'inbox', + lineNumber: 0, + taskIndex: 0, + rawText: `- [ ] ${id}`, + content: id, + checked: false, + forwarded: false, + cancelled: false, + inProgress: false, + waiting: false, + tags: [], + ...overrides + } +} + +/** The rail as a list of short strings, which reads like the panel does. */ +function shape(entries: TimelineEntry[]): string[] { + return entries.map((entry) => { + if (entry.kind === 'quiet') return `quiet ${entry.days}` + const rows = entry.rows.map((r) => r.task.id).join(',') + if (entry.kind === 'overdue') return `overdue ${rows}` + return `${entry.today ? 'today ' : ''}${entry.milestone ? 'milestone ' : ''}${entry.iso} ${rows}`.trim() + }) +} + +describe('buildTimelineRail', () => { + it('lays out overdue, today, and each later day with work, folding quiet days', () => { + const rail = buildTimelineRail( + [ + task('later', { due: '2026-11-02' }), + task('old', { due: '2026-10-20' }), + task('yesterday', { due: '2026-10-27' }), + task('today', { due: '2026-10-28' }), + task('tomorrow', { due: '2026-10-29' }), + task('undated') + ], + { today: TODAY, range: 'two-weeks' } + ) + expect(shape(rail.entries)).toEqual([ + 'overdue old,yesterday', + 'today 2026-10-28 today', + '2026-10-29 tomorrow', + 'quiet 3', + '2026-11-02 later' + ]) + expect(rail.openCount).toBe(5) + expect(rail.overdueCount).toBe(2) + expect(rail.undatedCount).toBe(1) + expect(rail.rows.map((r) => r.task.id)).toEqual(['old', 'yesterday', 'today', 'tomorrow', 'later']) + }) + + it('marks how late each overdue task is, in whole days', () => { + const rail = buildTimelineRail( + [task('a', { due: '2026-10-27' }), task('b', { due: '2026-09-28' }), task('c', { due: '2026-10-28' })], + { today: TODAY, range: 'two-weeks' } + ) + const late = Object.fromEntries(rail.rows.map((r) => [r.task.id, r.lateDays])) + expect(late).toEqual({ b: 30, a: 1, c: 0 }) + }) + + it('always draws today, empty or not, and no quiet line before the first later day when it is tomorrow', () => { + const empty = buildTimelineRail([], { today: TODAY, range: 'two-weeks' }) + expect(shape(empty.entries)).toEqual(['today 2026-10-28']) + expect(empty.openCount).toBe(0) + + const next = buildTimelineRail([task('t', { due: '2026-10-29' })], { today: TODAY, range: 'two-weeks' }) + expect(shape(next.entries)).toEqual(['today 2026-10-28', '2026-10-29 t']) + }) + + it('folds a single empty day too, and counts it', () => { + const rail = buildTimelineRail([task('t', { due: '2026-10-30' })], { today: TODAY, range: 'two-weeks' }) + expect(shape(rail.entries)).toEqual(['today 2026-10-28', 'quiet 1', '2026-10-30 t']) + }) + + it('takes which tasks count from the Tasks groups: no done, cancelled, waiting or forwarded', () => { + const rail = buildTimelineRail( + [ + task('done', { due: '2026-10-29', checked: true }), + task('cancelled', { due: '2026-10-29', cancelled: true }), + task('waiting', { due: '2026-10-29', waiting: true }), + task('forwarded', { due: '2026-10-29', forwarded: true }), + task('doing', { due: '2026-10-29', inProgress: true, rawText: '- [/] doing' }), + task('waiting-undated', { waiting: true }) + ], + { today: TODAY, range: 'two-weeks' } + ) + expect(rail.rows.map((r) => r.task.id)).toEqual(['doing']) + // The Tasks list keeps @waiting in its own group, not under No due date. + expect(rail.undatedCount).toBe(0) + }) + + it('takes a daily note’s inferred date, as the calendar panel does', () => { + const daily = task('from-daily', { sourcePath: 'Daily/2026-10-30.md' }) + const inferred = inferDailyTaskDueDates([daily], new Map([['Daily/2026-10-30.md', '2026-10-30']])) + const rail = buildTimelineRail(inferred, { today: TODAY, range: 'two-weeks' }) + expect(shape(rail.entries)).toEqual(['today 2026-10-28', 'quiet 1', '2026-10-30 from-daily']) + expect(rail.undatedCount).toBe(0) + }) + + it('cuts the range at two weeks, a month, or not at all, and counts what lies past it', () => { + const tasks = [ + task('in-two-weeks', { due: '2026-11-10' }), + task('past-two-weeks', { due: '2026-11-11' }), + task('in-month', { due: '2026-11-28' }), + task('past-month', { due: '2026-11-29' }), + task('next-year', { due: '2027-03-01' }), + task('overdue', { due: '2025-01-01' }) + ] + const twoWeeks = buildTimelineRail(tasks, { today: TODAY, range: 'two-weeks' }) + expect(twoWeeks.rangeEnd).toBe('2026-11-10') + expect(twoWeeks.rows.map((r) => r.task.id)).toEqual(['overdue', 'in-two-weeks']) + expect(twoWeeks.laterCount).toBe(4) + + const month = buildTimelineRail(tasks, { today: TODAY, range: 'month' }) + expect(month.rangeEnd).toBe('2026-11-28') + expect(month.rows.map((r) => r.task.id)).toEqual([ + 'overdue', + 'in-two-weeks', + 'past-two-weeks', + 'in-month' + ]) + expect(month.laterCount).toBe(2) + + const all = buildTimelineRail(tasks, { today: TODAY, range: 'all' }) + expect(all.rangeEnd).toBeNull() + expect(all.rows).toHaveLength(6) + expect(all.laterCount).toBe(0) + }) + + it('draws a #milestone day as a milestone and leads the day with it', () => { + const rail = buildTimelineRail( + [ + task('prep', { due: '2026-11-02' }), + task('launch', { due: '2026-11-02', tags: ['milestone'] }), + task('plain', { due: '2026-11-03' }) + ], + { today: TODAY, range: 'two-weeks' } + ) + expect(shape(rail.entries)).toEqual([ + 'today 2026-10-28', + 'quiet 4', + 'milestone 2026-11-02 launch,prep', + '2026-11-03 plain' + ]) + expect(rail.rows.find((r) => r.task.id === 'launch')?.milestone).toBe(true) + expect(rail.rows.find((r) => r.task.id === 'prep')?.milestone).toBe(false) + }) + + it('counts the open tasks before each milestone since the previous one, overdue included for the first', () => { + const rail = buildTimelineRail( + [ + task('late', { due: '2026-10-26' }), + task('today', { due: '2026-10-28' }), + task('alpha', { due: '2026-10-30', tags: ['milestone'] }), + task('same-day-as-alpha', { due: '2026-10-30' }), + task('between', { due: '2026-11-02' }), + task('beta', { due: '2026-11-05', tags: ['milestone'] }), + task('gamma', { due: '2026-11-05', tags: ['milestone'] }), + task('after', { due: '2026-11-06' }) + ], + { today: TODAY, range: 'two-weeks' } + ) + const before = Object.fromEntries( + rail.rows.filter((r) => r.milestone).map((r) => [r.task.id, r.before]) + ) + // alpha: late + today. beta: alpha's day onward, so its same-day task and + // `between`. gamma shares beta's day: nothing new stands before it. + expect(before).toEqual({ alpha: 2, beta: 2, gamma: 0 }) + expect(rail.rows.find((r) => r.task.id === 'after')?.before).toBeNull() + }) + + it('counts over what the filter leaves, so the number matches the rows above the diamond', () => { + const tasks = [ + task('a', { due: '2026-10-29', fields: { project: 'alpha' }, content: 'a' }), + task('b', { due: '2026-10-29', content: 'b' }), + task('ship', { due: '2026-10-31', tags: ['milestone'], fields: { project: 'alpha' }, content: 'ship' }) + ] + const rail = buildTimelineRail(filterTasks(tasks, '@project:alpha'), { + today: TODAY, + range: 'two-weeks' + }) + expect(rail.rows.map((r) => r.task.id)).toEqual(['a', 'ship']) + expect(rail.rows.find((r) => r.task.id === 'ship')?.before).toBe(1) + }) + + it('counts undated tasks that the filter leaves', () => { + const rail = buildTimelineRail( + filterTasks([task('alpha one'), task('beta two'), task('alpha dated', { due: '2026-10-29' })], 'alpha'), + { today: TODAY, range: 'two-weeks' } + ) + expect(rail.undatedCount).toBe(1) + expect(rail.openCount).toBe(1) + }) +}) + +describe('isMilestoneTask', () => { + it('reads the tag on a checkbox line, whatever its case', () => { + expect(isMilestoneTask(task('a', { tags: ['milestone'] }))).toBe(true) + expect(isMilestoneTask(task('c', { tags: ['MileStone'] }))).toBe(true) + expect(isMilestoneTask(task('b', { tags: ['milestones', 'milestone-ish'] }))).toBe(false) + }) + + it('reads a whole-note task’s body tags, but not a checkbox task’s note', () => { + const notes = noteTagsByPath([ + { path: 'file.md', tags: ['task', 'Milestone'] }, + { path: 'inline.md', tags: ['milestone'] } + ]) + expect(isMilestoneTask(task('file', { kind: 'file' }), notes)).toBe(true) + expect(isMilestoneTask(task('inline'), notes)).toBe(false) + }) +}) + +describe('timeline ranges and labels', () => { + it('ends two weeks after 13 more days and a month on the same date next month', () => { + expect(timelineRangeEnd(TODAY, 'two-weeks')).toBe('2026-11-10') + expect(timelineRangeEnd(TODAY, 'month')).toBe('2026-11-28') + expect(timelineRangeEnd(new Date(2027, 0, 31), 'month')).toBe('2027-02-28') + expect(timelineRangeEnd(new Date(2028, 0, 31), 'month')).toBe('2028-02-29') + expect(timelineRangeEnd(TODAY, 'all')).toBeNull() + }) + + it('normalizes a stored range, falling back to two weeks', () => { + expect(normalizeTimelineRange('month')).toBe('month') + expect(normalizeTimelineRange('all')).toBe('all') + expect(normalizeTimelineRange('year')).toBe('two-weeks') + expect(normalizeTimelineRange(undefined)).toBe('two-weeks') + }) + + it('counts days across a daylight-saving change as whole days', () => { + expect(daysBetween('2026-10-24', '2026-10-26')).toBe(2) + expect(daysBetween('2026-03-07', '2026-03-09')).toBe(2) + expect(daysBetween('2026-10-31', '2026-11-02')).toBe(2) + }) + + it('labels a day by weekday and date, adding the month and year only when they differ', () => { + const weekday = (d: Date): string => d.toLocaleDateString(undefined, { weekday: 'short' }) + const month = (d: Date): string => d.toLocaleDateString(undefined, { month: 'short' }) + expect(timelineDayLabel('2026-10-28', TODAY)).toBe(`${weekday(TODAY)} 28`) + const nov2 = new Date(2026, 10, 2) + expect(timelineDayLabel('2026-11-02', TODAY)).toBe(`${weekday(nov2)} 2 ${month(nov2)}`) + const jan4 = new Date(2027, 0, 4) + expect(timelineDayLabel('2027-01-04', TODAY)).toBe(`${weekday(jan4)} 4 ${month(jan4)} 2027`) + }) +}) diff --git a/packages/app-core/src/lib/timeline-rail.ts b/packages/app-core/src/lib/timeline-rail.ts new file mode 100644 index 000000000..a3dc21392 --- /dev/null +++ b/packages/app-core/src/lib/timeline-rail.ts @@ -0,0 +1,217 @@ +import type { VaultTask } from '@shared/tasks' +import { groupTasks, isOverdue, toIsoDateLocal } from '@shared/tasks' +import type { NoteTagsByPath } from './tasks-filter' + +/** + * The Timeline panel's rail (#1007): the vault's open, dated tasks laid out + * down one line, overdue on top, then today, then each later day that has + * work, with the empty stretches between them folded into one quiet line. + * + * Which tasks count, what their date is and whether they are late all come + * from the Tasks view, never from a rule of the rail's own: the input is the + * list the Tasks surfaces see (display filter, the daily-note date inference, + * the user's filter query), `groupTasks` decides which of them are live + * Today / Upcoming work, and `isOverdue` decides lateness. So a waiting or + * forwarded task stays where the Tasks list keeps it, in its own group, and + * the undated count is the list's No due date group. + */ + +export type TimelineRange = 'two-weeks' | 'month' | 'all' +export const TIMELINE_RANGES: readonly TimelineRange[] = ['two-weeks', 'month', 'all'] +export const DEFAULT_TIMELINE_RANGE: TimelineRange = 'two-weeks' + +export function normalizeTimelineRange(value: unknown): TimelineRange { + return TIMELINE_RANGES.includes(value as TimelineRange) + ? (value as TimelineRange) + : DEFAULT_TIMELINE_RANGE +} + +/** The tag that turns a task into a milestone on the rail. Nowhere else in the + * app treats it as special: it is an ordinary tag everywhere but here. */ +export const MILESTONE_TAG = 'milestone' + +/** + * A task tagged `#milestone`. A whole-note task also counts when the tag is + * written in its note's body, the same widening the Tasks filter gives a file + * task (#1008); a checkbox task keeps to its own line. + */ +export function isMilestoneTask(task: VaultTask, noteTags?: NoteTagsByPath | null): boolean { + if (task.tags.some((tag) => tag.toLowerCase() === MILESTONE_TAG)) return true + if (task.kind !== 'file' || !noteTags) return false + return noteTags.get(task.sourcePath)?.some((tag) => tag.toLowerCase() === MILESTONE_TAG) ?? false +} + +export interface TimelineRow { + task: VaultTask + milestone: boolean + /** Whole days past the due date; 0 unless the task is overdue. */ + lateDays: number + /** For a milestone, the open tasks due before it since the previous + * milestone (or from the top of the rail for the first); null otherwise. */ + before: number | null +} + +export type TimelineEntry = + | { kind: 'overdue'; rows: TimelineRow[] } + | { kind: 'day'; iso: string; today: boolean; milestone: boolean; rows: TimelineRow[] } + | { kind: 'quiet'; days: number; afterIso: string } + +export interface TimelineRail { + entries: TimelineEntry[] + /** Every row in the order drawn, which is the order the cursor walks. */ + rows: TimelineRow[] + /** Rows on the rail: the overdue ones plus the range. */ + openCount: number + overdueCount: number + /** Open tasks with no date, written or inferred: the Tasks list's No due date. */ + undatedCount: number + /** Dated open tasks past the end of the range. */ + laterCount: number + /** Last day the range shows, or null for All. */ + rangeEnd: string | null +} + +function localDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + +function parseIso(iso: string): Date { + const [y, m, d] = iso.split('-').map(Number) + return new Date(y, m - 1, d) +} + +/** Calendar days from `a` to `b`. Rounded, so a daylight-saving change in + * between (a 23 or 25 hour day) still counts as one day. */ +export function daysBetween(a: string, b: string): number { + return Math.round((parseIso(b).getTime() - parseIso(a).getTime()) / 86_400_000) +} + +/** + * The last day a range shows. Two weeks is today and the 13 days after it. + * A month runs to the same date next month, or to that month's last day when + * it is shorter (Jan 31 runs to Feb 28). + */ +export function timelineRangeEnd(today: Date, range: TimelineRange): string | null { + const day = localDay(today) + if (range === 'all') return null + if (range === 'two-weeks') { + return toIsoDateLocal(new Date(day.getFullYear(), day.getMonth(), day.getDate() + 13)) + } + const lastOfNext = new Date(day.getFullYear(), day.getMonth() + 2, 0).getDate() + return toIsoDateLocal( + new Date(day.getFullYear(), day.getMonth() + 1, Math.min(day.getDate(), lastOfNext)) + ) +} + +function byDueThenNote(a: VaultTask, b: VaultTask): number { + const ad = a.due ?? '' + const bd = b.due ?? '' + if (ad !== bd) return ad < bd ? -1 : 1 + if (a.sourcePath !== b.sourcePath) return a.sourcePath < b.sourcePath ? -1 : 1 + return a.taskIndex - b.taskIndex +} + +export function buildTimelineRail( + tasks: VaultTask[], + options: { today: Date; range: TimelineRange; noteTags?: NoteTagsByPath | null } +): TimelineRail { + const today = localDay(options.today) + const todayIso = toIsoDateLocal(today) + const rangeEnd = timelineRangeEnd(today, options.range) + const groups = groupTasks(tasks, today) + + const row = (task: VaultTask): TimelineRow => ({ + task, + milestone: isMilestoneTask(task, options.noteTags), + lateDays: isOverdue(task, today) && task.due ? daysBetween(task.due, todayIso) : 0, + before: null + }) + // Milestones lead their day; the rest keep the order the Tasks list gives + // them (priority first in Today, note order in Upcoming). + const milestonesFirst = (rows: TimelineRow[]): TimelineRow[] => [ + ...rows.filter((r) => r.milestone), + ...rows.filter((r) => !r.milestone) + ] + + // Oldest first, so the rail reads top to bottom in date order all the way. + const overdue = groups.today + .filter((task) => isOverdue(task, today)) + .sort(byDueThenNote) + .map(row) + + const byDay = new Map<string, TimelineRow[]>() + let laterCount = 0 + for (const task of [...groups.today, ...groups.upcoming]) { + if (!task.due || isOverdue(task, today)) continue + if (rangeEnd && task.due > rangeEnd) { + laterCount += 1 + continue + } + const list = byDay.get(task.due) + if (list) list.push(row(task)) + else byDay.set(task.due, [row(task)]) + } + + const entries: TimelineEntry[] = [] + if (overdue.length > 0) entries.push({ kind: 'overdue', rows: milestonesFirst(overdue) }) + const todayRows = milestonesFirst(byDay.get(todayIso) ?? []) + entries.push({ + kind: 'day', + iso: todayIso, + today: true, + milestone: todayRows.some((r) => r.milestone), + rows: todayRows + }) + let previousIso = todayIso + for (const iso of [...byDay.keys()].filter((d) => d > todayIso).sort()) { + const gap = daysBetween(previousIso, iso) - 1 + if (gap > 0) entries.push({ kind: 'quiet', days: gap, afterIso: previousIso }) + const rows = milestonesFirst(byDay.get(iso) ?? []) + entries.push({ kind: 'day', iso, today: false, milestone: rows.some((r) => r.milestone), rows }) + previousIso = iso + } + + const rows = entries.flatMap((entry) => (entry.kind === 'quiet' ? [] : entry.rows)) + + // "N tasks before it": the open tasks due on or after the previous + // milestone's day and before this one's, so each task is counted toward + // exactly one milestone and the numbers add up to the work on the rail. + // The first milestone counts from the top of the rail, overdue included: + // late work still stands between today and the milestone. + let previousMilestoneDue: string | null = null + for (const r of rows) { + if (!r.milestone || !r.task.due) continue + const due = r.task.due + const since = previousMilestoneDue + r.before = rows.filter( + (other) => + !other.milestone && + !!other.task.due && + other.task.due < due && + (since === null || other.task.due >= since) + ).length + previousMilestoneDue = due + } + + return { + entries, + rows, + openCount: rows.length, + overdueCount: overdue.length, + undatedCount: groups.undated.length, + laterCount, + rangeEnd + } +} + +/** "Wed 28", with the month when it is not this month's: "Mon 2 Nov". */ +export function timelineDayLabel(iso: string, today: Date): string { + const date = parseIso(iso) + const weekday = date.toLocaleDateString(undefined, { weekday: 'short' }) + const sameMonth = + date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() + if (sameMonth) return `${weekday} ${date.getDate()}` + const month = date.toLocaleDateString(undefined, { month: 'short' }) + const year = date.getFullYear() !== today.getFullYear() ? ` ${date.getFullYear()}` : '' + return `${weekday} ${date.getDate()} ${month}${year}` +} diff --git a/packages/app-core/src/lib/use-pane-panels.ts b/packages/app-core/src/lib/use-pane-panels.ts index 69d7e8e73..4f97e419f 100644 --- a/packages/app-core/src/lib/use-pane-panels.ts +++ b/packages/app-core/src/lib/use-pane-panels.ts @@ -23,6 +23,7 @@ export interface PanePanels extends Omit<PanePanelsState, 'calendarDismissed'> { setOutlineOpen: (next: SetStateAction<boolean>) => void setCommentsOpen: (next: SetStateAction<boolean>) => void setCalendarPanel: (next: SetStateAction<CalendarPanelState>) => void + setTimelineOpen: (next: SetStateAction<boolean>) => void } /** @@ -94,6 +95,12 @@ export function usePanePanels(paneId: string, path: string | null): PanePanels { [update] ) + const setTimelineOpen = useCallback( + (next: SetStateAction<boolean>) => + update((p) => ({ ...p, timeline: resolve(next, p.timeline) })), + [update] + ) + const { calendarDismissed, ...shown } = panels return { ...shown, @@ -101,6 +108,7 @@ export function usePanePanels(paneId: string, path: string | null): PanePanels { setConnectionsOpen, setOutlineOpen, setCommentsOpen, - setCalendarPanel + setCalendarPanel, + setTimelineOpen } } diff --git a/packages/app-core/src/lib/vim-nav.test.ts b/packages/app-core/src/lib/vim-nav.test.ts index e23259c05..383b31de6 100644 --- a/packages/app-core/src/lib/vim-nav.test.ts +++ b/packages/app-core/src/lib/vim-nav.test.ts @@ -202,6 +202,29 @@ describe('getVisiblePanels — the focus cycle (#285, #477)', () => { document.body.innerHTML = '' }) + it('puts the Timeline right of the calendar, as it renders, and reads it off the DOM (#1007)', () => { + expect( + getVisiblePanels(visibility({ outlineOpen: true, calendarOpen: true, timelineOpen: true })) + ).toEqual(['sidebar', 'notelist', 'editor', 'outline', 'calendar', 'timeline']) + const panels = getVisiblePanels(visibility({ timelineOpen: true })) + expect(resolveNextPanel('editor', 'right', panels)).toBe('timeline') + expect(resolveNextPanel('timeline', 'right', panels)).toBe('timeline') + expect(resolveNextPanel('timeline', 'left', panels)).toBe('editor') + document.body.innerHTML = ` + <div data-calendar-panel></div> + <section data-timeline-panel></section> + ` + expect( + getVisiblePanelsNow({ + sidebarOpen: false, + noteListOpen: false, + unifiedSidebar: true, + tasksViewOpen: false + }) + ).toEqual(['editor', 'calendar', 'timeline']) + document.body.innerHTML = '' + }) + it('walks every open panel in order, so no panel is a dead end (#477)', () => { const panels = getVisiblePanels( visibility({ connectionsOpen: true, commentsOpen: true, outlineOpen: true, calendarOpen: true }) diff --git a/packages/app-core/src/lib/vim-nav.ts b/packages/app-core/src/lib/vim-nav.ts index 057a2f8e3..adef18ede 100644 --- a/packages/app-core/src/lib/vim-nav.ts +++ b/packages/app-core/src/lib/vim-nav.ts @@ -50,6 +50,7 @@ export type Panel = | 'comments' | 'outline' | 'calendar' + | 'timeline' | 'hoverpreview' | 'tasks' | 'tags' @@ -63,6 +64,7 @@ export interface PanelVisibility { commentsOpen: boolean outlineOpen: boolean calendarOpen: boolean + timelineOpen?: boolean tasksViewOpen: boolean atlasViewOpen?: boolean tagsViewOpen?: boolean @@ -70,7 +72,8 @@ export interface PanelVisibility { /** Every panel on screen, ordered left to right — the focus order both pane * navigations walk. The editor-pane side panels follow the order EditorPane - * renders them in: connections, comments, outline, calendar. (#285, #477) */ + * renders them in: connections, comments, outline, calendar, timeline. + * (#285, #477, #1007) */ export function getVisiblePanels(visibility: PanelVisibility): Panel[] { const panels: Panel[] = [] if (visibility.sidebarOpen) panels.push('sidebar') @@ -88,6 +91,7 @@ export function getVisiblePanels(visibility: PanelVisibility): Panel[] { if (visibility.commentsOpen) panels.push('comments') if (visibility.outlineOpen) panels.push('outline') if (visibility.calendarOpen) panels.push('calendar') + if (visibility.timelineOpen) panels.push('timeline') return panels } @@ -98,7 +102,8 @@ const PANEL_MARKERS = { connections: '[data-connections-panel]', comments: '[data-comments-panel]', outline: '[data-outline-panel]', - calendar: '[data-calendar-panel]' + calendar: '[data-calendar-panel]', + timeline: '[data-timeline-panel]' } as const /** @@ -128,7 +133,8 @@ export function getVisiblePanelsNow(state: { connectionsOpen: onScreen(PANEL_MARKERS.connections), commentsOpen: onScreen(PANEL_MARKERS.comments), outlineOpen: onScreen(PANEL_MARKERS.outline), - calendarOpen: onScreen(PANEL_MARKERS.calendar) + calendarOpen: onScreen(PANEL_MARKERS.calendar), + timelineOpen: onScreen(PANEL_MARKERS.timeline) }) } diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index d6ce0d25b..6baf74b54 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -1924,6 +1924,44 @@ describe('tasksListOrder (#989)', () => { }) }) +describe('timeline panel state (#1007)', () => { + it('reaches two weeks by default and round-trips the range', async () => { + installZen() + const { useStore } = await loadStore() + expect(useStore.getState().timelineRange).toBe('two-weeks') + expect(useStore.getState().panelWidths.timeline).toBe(290) + + useStore.getState().setTimelineRange('month') + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.timelineRange).toBe('month') + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().timelineRange).toBe('month') + }) + + it('normalizes an unknown stored range, and keeps the filter out of the saved prefs', async () => { + installZen() + localStorage.setItem('zen:prefs:v2', JSON.stringify({ timelineRange: 'year' })) + const { useStore } = await loadStore() + expect(useStore.getState().timelineRange).toBe('two-weeks') + useStore.getState().setTimelineFilter('#milestone') + useStore.getState().setTimelineRange('all') + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.timelineFilter).toBeUndefined() + expect(saved.timelineRange).toBe('all') + }) + + it('remembers the panel width like its neighbours', async () => { + installZen() + const { useStore } = await loadStore() + useStore.getState().setPanelWidth('timeline', 1000) + expect(useStore.getState().panelWidths.timeline).toBe(640) + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.panelWidths.timeline).toBe(640) + }) +}) + describe('kanbanCardSort (#889)', () => { it('defaults to the manual order and round-trips due-date ordering', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 7c0f913ec..978e86db6 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -264,6 +264,11 @@ import { type SavedTaskFilters } from './lib/saved-task-filters' import { normalizeIgnoredKeys } from './lib/ignored-keys' +import { + DEFAULT_TIMELINE_RANGE, + normalizeTimelineRange, + type TimelineRange +} from './lib/timeline-rail' import { normalizeApplicationSchemes } from '@shared/application-links' import { normalizeEditorTabSize } from './lib/editor-tab-size' import { recentNoteToggleTarget } from './lib/recent-note-toggle' @@ -522,12 +527,13 @@ export interface PeriodicNoteOpenOptions { const VALID_CALENDAR_WEEK_STARTS: CalendarWeekStart[] = ['monday', 'sunday', 'locale'] /** The editor-pane right-side panels whose width the user can drag-resize. */ -export type RightPanelId = 'outline' | 'connections' | 'comments' | 'calendar' +export type RightPanelId = 'outline' | 'connections' | 'comments' | 'calendar' | 'timeline' export interface PanelWidths { outline: number connections: number comments: number calendar: number + timeline: number } export const MIN_RIGHT_PANEL_WIDTH = 200 export const MAX_RIGHT_PANEL_WIDTH = 640 @@ -535,7 +541,8 @@ export const DEFAULT_PANEL_WIDTHS: PanelWidths = { outline: 260, connections: 288, comments: 360, - calendar: 280 + calendar: 280, + timeline: 290 } function clampPanelWidth(px: number): number { @@ -550,7 +557,8 @@ function normalizePanelWidths(value: unknown): PanelWidths { outline: pick('outline'), connections: pick('connections'), comments: pick('comments'), - calendar: pick('calendar') + calendar: pick('calendar'), + timeline: pick('timeline') } } @@ -795,6 +803,8 @@ interface Prefs { kanbanCardSort: KanbanCardSort /** Tasks list groups in their notes' line order or by priority. (#989) */ tasksListOrder: TasksListOrder + /** How far ahead the Timeline panel reaches. (#1007) */ + timelineRange: TimelineRange /** Display-only Kanban column title overrides. Keyed by `${groupBy}:${columnId}`. */ kanbanColumnTitles: Record<string, string> /** Manual Kanban column arrangement per board. Keyed by groupBy → ordered @@ -1233,6 +1243,7 @@ export const DEFAULT_PREFS: Prefs = { kanbanGroupBy: 'status', kanbanCardSort: 'manual', tasksListOrder: 'note', + timelineRange: DEFAULT_TIMELINE_RANGE, kanbanColumnTitles: {}, kanbanColumnOrder: {}, kanbanCardOrder: {}, @@ -1604,6 +1615,7 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { kanbanGroupBy: normalizeKanbanGroupBy(p.kanbanGroupBy), kanbanCardSort: p.kanbanCardSort === 'due' ? 'due' : 'manual', tasksListOrder: p.tasksListOrder === 'priority' ? 'priority' : 'note', + timelineRange: normalizeTimelineRange(p.timelineRange), kanbanColumnTitles: normalizeKanbanColumnTitles(p.kanbanColumnTitles), kanbanColumnOrder: normalizeKanbanColumnOrder(p.kanbanColumnOrder), kanbanCardOrder: normalizeKanbanCardOrder(p.kanbanCardOrder), @@ -2564,6 +2576,7 @@ function collectPrefs(s: { kanbanGroupBy: KanbanGroupBy kanbanCardSort: KanbanCardSort tasksListOrder: TasksListOrder + timelineRange: TimelineRange kanbanColumnTitles: Record<string, string> kanbanColumnOrder: Record<string, string[]> kanbanCardOrder: Record<string, string[]> @@ -2680,6 +2693,7 @@ function collectPrefs(s: { kanbanGroupBy: s.kanbanGroupBy, kanbanCardSort: s.kanbanCardSort, tasksListOrder: s.tasksListOrder, + timelineRange: s.timelineRange, kanbanColumnTitles: s.kanbanColumnTitles, kanbanColumnOrder: s.kanbanColumnOrder, kanbanCardOrder: s.kanbanCardOrder, @@ -2973,12 +2987,12 @@ function hasTasksViewOpen(state: { paneLayout: PaneLayout }): boolean { /** True when a surface backed by `vaultTasks` is on screen and therefore needs * the shared task cache kept fresh on note edits. Tasks tabs are in the pane - * tree; Home and calendar expose the navigation markers also read by VimNav. */ + * tree; Home, the calendar and the Timeline expose DOM markers. */ function tasksSurfaceVisible(state: { paneLayout: PaneLayout }): boolean { if (hasTasksViewOpen(state)) return true return ( typeof document !== 'undefined' && - document.querySelector('[data-calendar-panel], [data-home-nav]') !== null + document.querySelector('[data-calendar-panel], [data-timeline-panel], [data-home-nav]') !== null ) } @@ -3353,6 +3367,12 @@ interface Store { kanbanCardSort: KanbanCardSort /** Tasks list groups in note order or by priority. (#989) */ tasksListOrder: TasksListOrder + /** How far ahead the Timeline panel reaches: two weeks, a month, or every + * dated task. Portable (`timeline_range` under [view]). (#1007) */ + timelineRange: TimelineRange + /** The Timeline panel's filter, in the Tasks filter syntax. Session state: + * shared by every pane's Timeline, cleared on restart. (#1007) */ + timelineFilter: string /** Display-only column title overrides for the Tasks Kanban view. */ kanbanColumnTitles: Record<string, string> /** Manual column arrangement per board (groupBy → ordered column ids). */ @@ -3623,6 +3643,8 @@ interface Store { setKanbanGroupBy: (group: KanbanGroupBy) => void setKanbanCardSort: (sort: KanbanCardSort) => void setTasksListOrder: (order: TasksListOrder) => void + setTimelineRange: (range: TimelineRange) => void + setTimelineFilter: (query: string) => void setKanbanColumnTitle: ( group: KanbanGroupBy, columnId: string, @@ -6194,6 +6216,8 @@ export const useStore = create<Store>((set, get) => { kanbanGroupBy: loadPrefs().kanbanGroupBy, kanbanCardSort: loadPrefs().kanbanCardSort, tasksListOrder: loadPrefs().tasksListOrder, + timelineRange: loadPrefs().timelineRange, + timelineFilter: '', kanbanColumnTitles: loadPrefs().kanbanColumnTitles, kanbanColumnOrder: loadPrefs().kanbanColumnOrder, kanbanCardOrder: loadPrefs().kanbanCardOrder, @@ -7696,6 +7720,11 @@ export const useStore = create<Store>((set, get) => { set({ tasksListOrder: order }) savePrefs(collectPrefs(get())) }, + setTimelineRange: (range) => { + set({ timelineRange: normalizeTimelineRange(range) }) + savePrefs(collectPrefs(get())) + }, + setTimelineFilter: (query) => set({ timelineFilter: query }), setKanbanColumnTitle: (group, columnId, title) => { const key = `${group}:${columnId}` const normalized = typeof title === 'string' ? normalizeKanbanColumnTitle(title) : null diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index e73ee1379..3f52ddcb8 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -104,6 +104,7 @@ export const PORTABLE_PREF_KEYS = [ 'kanbanGroupBy', 'kanbanCardSort', 'tasksListOrder', + 'timelineRange', 'kanbanFolderRoot', 'kanbanColumnTitles', 'kanbanStatuses', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index c61502451..95db50da6 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -170,6 +170,7 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { kanbanGroupBy: 'status', kanbanCardSort: 'manual', tasksListOrder: 'note', + timelineRange: 'two-weeks', kanbanFolderRoot: '', kanbanColumnTitles: {}, kanbanStatuses: [], diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 53d53bb53..daedb61c8 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -54,6 +54,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "global.toggleConnections", group: "global", defaultBinding: "Mod+2", title: "Toggle connections panel" }, { id: "global.toggleOutlinePanel", group: "global", defaultBinding: "Mod+3", title: "Toggle outline panel" }, { id: "global.toggleCommentsPanel", group: "global", defaultBinding: "Shift+Mod+C", title: "Toggle comments panel" }, + { id: "global.toggleTimelinePanel", group: "global", defaultBinding: "Mod+7", title: "Toggle timeline panel" }, { id: "global.addComment", group: "global", defaultBinding: "Alt+Mod+M", title: "Add comment to selection" }, { id: "global.focusPaneLeft", group: "global", defaultBinding: "Alt+H", title: "Focus pane left" }, { id: "global.focusPaneDown", group: "global", defaultBinding: "Alt+J", title: "Focus pane down" }, @@ -110,6 +111,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderWeeklyNote", group: "vim", defaultBinding: "w", title: "Leader: this week's note" }, { id: "vim.leaderMonthlyNote", group: "vim", defaultBinding: "m", title: "Leader: this month's note" }, { id: "vim.leaderCalendar", group: "vim", defaultBinding: "c", title: "Leader: toggle calendar" }, + { id: "vim.leaderTimeline", group: "vim", defaultBinding: "n", title: "Leader: toggle timeline" }, { id: "vim.leaderUnresolvedLinks", group: "vim", defaultBinding: "u", title: "Leader: unresolved links" }, { id: "vim.panePrefix", group: "vim", defaultBinding: "Ctrl+W", title: "Pane command prefix" }, { id: "vim.paneFocusLeft", group: "vim", defaultBinding: "h", title: "Pane: focus left" }, From 28f7ed994138b8956af433dcd44c7d43ef030ede Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 12:39:44 -0500 Subject: [PATCH 17/34] Feat(keymap): every palette command can take a key (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #971 asked for Move Note to Trash, Archive, Restore and Favorite on a key. None of them had a slot: the keymap table binds the app's built-in actions, 141 of them, while the palette has 187 commands, and a "note.trash" line under [keymaps] in config.toml was dropped by the normalizer without a word, although the manual said every shortcut was remappable. Commands now get keys of their own, in a map of their own: commandKeymaps, command id to binding, beside keymapOverrides and never mixed with it. One id lives in both worlds, nav.back (the `h` back-out action and the Go Back command), which is why the two cannot share a map. Every command with a fixed id is bindable and starts unbound; the ones the palette makes per vault (a saved Tasks filter, a workflow, a remote profile, an assistant's edit) are left out, since their ids come and go. A generated catalog in shared-domain lists the bindable ids with a stable title (bindTitle for the toggles whose title flips with state), guarded against drift by a test that rebuilds it from buildCommands(). A binding is a chord in the keymap table's format ("Mod+Backspace", or a function key; a bare letter would eat typing), or, with Vim mode on, a leader sequence stored as "Leader x t" so it follows a rebound leader. Precedence: a key the user chose wins over every default. The chord listener is on window in the capture phase, installed at import time right after the ignored-keys guard, ahead of VimNav, App's own shortcuts and CodeMirror: a command on Mod+Backspace trashes the note from the editor, where that key would delete to the line start, and one on Mod+L takes the key from Toggle checkbox. It stands down for What's New, Settings (and every recorder), the palettes, dialogs, menus, an open completion list and hint mode, and only listens in the main window, not quick capture or a floating note. A held chord runs once. Leader sequences are the exception: they run in VimNav after every built-in leader key, because built-in leader keys always win, and they show in the which-key hints. With Vim off they are inert. When a command's when() says no, nothing runs and a toast says why. The note family carries its reason ("Restore from Trash only works on a note in the Trash"), the three shared guards (an open note, a note in the editor, an open tab) get theirs in one place, and the rest say "isn't available here". Settings, Keymap gains a fifth group, Commands: title, category and id per row, found by any of those or the keywords, with the same Change, Unbind and Reset. A command that already shows a leader chord keeps it ("also Space l s"). The recorder moved out of SettingsModal into KeymapRecorder and onto ui/Modal; for a command, Enter saves, Escape cancels and Backspace clears, since its binding is never one of those keys. Conflicts are checked both ways: a key another of the user's own bindings holds is refused, a built-in default is allowed with a warning naming the action that loses it, and a sequence starting on a built-in leader key is refused (Space t x, since Space t is the template picker). Action rows show "Taken by command" and the like. Settings search finds a command row by its title. In the palette, Alt+Enter on a row opens that same recorder over the palette rather than jumping to Settings: the query and row stay put, Escape comes back to them, the row shows its key at once, and nothing is two Escapes away. The footer names the key. A bound key shows on its row ahead of any key the command already had, a sequence only with Vim on. Themes…, Switch Vault… and Run Workflow… gained a run() that opens the palette on their list, so they work from a key and from the ex line. On the ex line, `:bind <keys> <command.id>` takes Vim notation (<C-BS>, <D-BS>, <leader>xa, <Space>xa) or the Settings spelling, with the same refusals as the recorder. `:unbind <action.id>` keeps working, and `:unbind command <command.id>` takes a command's key away; a bare id that names no action but a command means the command, so nav.back stays the action and `:unbind command nav.back` is the command. Tests: parsing, normalizing (unknown ids, nav.back, leader refusals, order), conflicts both ways, chord and leader matching, the recorder steps, :bind and :unbind parsing and effects, dispatch with every bail-out and the when() toast, VimNav sequences and hints with Vim on and off, the palette's Alt+Enter flow, the Settings Commands group, and the catalog drift guard. --- packages/app-core/src/App.tsx | 7 + .../components/CommandPalette-bind.test.ts | 133 ++++ .../src/components/CommandPalette.tsx | 92 ++- packages/app-core/src/components/Editor.tsx | 46 +- .../src/components/KeymapRecorder.tsx | 493 ++++++++++++ .../src/components/SettingsModal.test.ts | 97 +++ .../app-core/src/components/SettingsModal.tsx | 507 +++++++----- .../components/VimNav-command-leader.test.ts | 123 +++ packages/app-core/src/components/VimNav.tsx | 80 +- .../src/lib/command-keymap-dispatch.test.ts | 262 +++++++ .../src/lib/command-keymap-dispatch.ts | 157 ++++ .../app-core/src/lib/command-keymap-ex.ts | 123 +++ .../app-core/src/lib/command-keymaps.test.ts | 411 ++++++++++ packages/app-core/src/lib/command-keymaps.ts | 726 ++++++++++++++++++ .../app-core/src/lib/command-palette-mode.ts | 5 +- .../app-core/src/lib/commands-catalog.test.ts | 96 +++ packages/app-core/src/lib/commands.test.ts | 36 + packages/app-core/src/lib/commands.ts | 218 ++++-- packages/app-core/src/store.ts | 35 +- .../shared-domain/src/commands-catalog.ts | 225 ++++++ 20 files changed, 3573 insertions(+), 299 deletions(-) create mode 100644 packages/app-core/src/components/CommandPalette-bind.test.ts create mode 100644 packages/app-core/src/components/KeymapRecorder.tsx create mode 100644 packages/app-core/src/components/VimNav-command-leader.test.ts create mode 100644 packages/app-core/src/lib/command-keymap-dispatch.test.ts create mode 100644 packages/app-core/src/lib/command-keymap-dispatch.ts create mode 100644 packages/app-core/src/lib/command-keymap-ex.ts create mode 100644 packages/app-core/src/lib/command-keymaps.test.ts create mode 100644 packages/app-core/src/lib/command-keymaps.ts create mode 100644 packages/app-core/src/lib/commands-catalog.test.ts create mode 100644 packages/shared-domain/src/commands-catalog.ts diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 979dd9d46..2d35a9a6b 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -71,12 +71,19 @@ import { ensureCloudAutoSyncStarted, stopCloudAutoSync } from './lib/cloud-auto- import { loadKeepRunningInTray, startTraySyncStatusReporter } from './lib/tray-runtime' import { installHarperRuntime } from './lib/harper-runtime' import { installIgnoredKeysGuard } from './lib/ignored-keys' +import { installCommandKeymapDispatcher } from './lib/command-keymap-dispatch' // The ignored-keys guard (#732) has to be on `window` before any component // registers a capture listener there, so it is installed at import time, // not from an effect: children's effects run before App's, and VimNav's // would otherwise see the stray key first. Reads the live list on each key. installIgnoredKeysGuard(() => useStore.getState().ignoredKeys) +// Keys the user gave command palette commands (#971) go next, for the same +// reason: a binding the user chose wins over every default, VimNav's and the +// editor's included, so its listener has to be ahead of theirs. Leader +// sequences are not handled here but in VimNav, after the built-in leader +// keys. See lib/command-keymap-dispatch.ts. +installCommandKeymapDispatcher(() => useStore.getState()) let editorModulePromise: Promise<typeof import('./components/Editor')> | null = null const EDITOR_MODULE_WARMUP_GRACE_MS = 40 diff --git a/packages/app-core/src/components/CommandPalette-bind.test.ts b/packages/app-core/src/components/CommandPalette-bind.test.ts new file mode 100644 index 000000000..d3509f108 --- /dev/null +++ b/packages/app-core/src/components/CommandPalette-bind.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' +import { useToastStore } from '../lib/toast' +import { CommandPalette } from './CommandPalette' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// Alt+Enter on a palette row records a key for that command in place (#971). + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + platformSync: () => 'darwin', + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false, + supportsCliInstall: false + }) + } + }) +} + +function setQuery(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function press(target: EventTarget, init: KeyboardEventInit & { key: string; code: string }): void { + target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })) +} + +describe('Alt+Enter in the command palette', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + let originalScrollIntoView: PropertyDescriptor | undefined + + beforeEach(() => { + installZen() + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + useStore.setState({ commandPaletteOpen: true, commandKeymaps: {} }) + useToastStore.setState({ toasts: [] }) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + } else { + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView + } + useStore.setState(originalState, true) + }) + + async function openOn(query: string): Promise<HTMLInputElement> { + await act(async () => root.render(createElement(CommandPalette))) + const input = document.querySelector<HTMLInputElement>('input[placeholder="Type a command…"]')! + await act(async () => setQuery(input, query)) + return input + } + + it('names the key in the footer', async () => { + await openOn('') + expect(document.querySelector('[data-palette-bind-hint]')?.textContent).toBe('⌥↵ set key') + }) + + it('records a key for the highlighted command, saves it on Enter, and shows it on the row', async () => { + const input = await openOn('New Drawing') + expect(document.querySelector('[data-cmd-idx="0"]')?.textContent).toContain('New Drawing') + + await act(async () => press(input, { key: 'Enter', code: 'Enter', altKey: true })) + const recorder = document.querySelector('[data-keymap-recorder="command"]') + expect(recorder?.textContent).toContain('New Drawing') + // Alt+Enter did not run the command or close the palette. + expect(useStore.getState().commandPaletteOpen).toBe(true) + + await act(async () => press(window, { key: 'd', code: 'KeyD', metaKey: true, altKey: true })) + expect(document.querySelector('[data-keymap-recorder-binding]')?.textContent).toBe('⌥⌘D') + + await act(async () => press(window, { key: 'Enter', code: 'Enter' })) + expect(useStore.getState().commandKeymaps['drawing.new']).toBe('Alt+Mod+D') + expect(document.querySelector('[data-keymap-recorder]')).toBeNull() + expect(useStore.getState().commandPaletteOpen).toBe(true) + expect(document.querySelector('[data-cmd-idx="0"]')?.textContent).toContain('⌥⌘D') + }) + + it('Escape leaves the recorder without saving and keeps the palette open', async () => { + const input = await openOn('New Drawing') + await act(async () => press(input, { key: 'Enter', code: 'Enter', altKey: true })) + await act(async () => press(window, { key: 'd', code: 'KeyD', metaKey: true, altKey: true })) + await act(async () => press(window, { key: 'Escape', code: 'Escape' })) + expect(document.querySelector('[data-keymap-recorder]')).toBeNull() + expect(useStore.getState().commandKeymaps['drawing.new']).toBeUndefined() + expect(useStore.getState().commandPaletteOpen).toBe(true) + }) + + it('refuses a key another of your bindings holds', async () => { + useStore.setState({ commandKeymaps: { 'note.new.quick': 'Alt+Mod+D' } }) + const input = await openOn('New Drawing') + await act(async () => press(input, { key: 'Enter', code: 'Enter', altKey: true })) + await act(async () => press(window, { key: 'd', code: 'KeyD', metaKey: true, altKey: true })) + expect( + document.querySelector('[data-keymap-recorder-notice="danger"]')?.textContent + ).toContain('the command New Quick Note') + expect(document.querySelector<HTMLButtonElement>('[data-keymap-recorder-save]')?.disabled).toBe(true) + await act(async () => press(window, { key: 'Enter', code: 'Enter' })) + expect(useStore.getState().commandKeymaps['drawing.new']).toBeUndefined() + expect(document.querySelector('[data-keymap-recorder]')).not.toBeNull() + }) + + it('plain Enter still runs the highlighted command', async () => { + const newDrawing = vi.fn() + useStore.setState({ newDrawing } as never) + const input = await openOn('New Drawing') + await act(async () => press(input, { key: 'Enter', code: 'Enter' })) + expect(newDrawing).toHaveBeenCalled() + expect(document.querySelector('[data-keymap-recorder]')).toBeNull() + }) +}) diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 066393582..f20abf198 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -36,6 +36,10 @@ import { import { useCloudSyncStatusStore } from '../lib/cloud-auto-sync' import { getPublishNoteRequest } from '../lib/publish-note-requests' import { Modal } from './ui/Modal' +import { KeymapRecorderModal } from './KeymapRecorder' +import { isBindableCommandId } from '../lib/command-keymaps' +import { formatKeyToken } from '../lib/keymaps' +import { useToastStore } from '../lib/toast' type Mode = 'main' | 'theme' | 'vault' | 'workflow' @@ -76,11 +80,30 @@ export function CommandPalette(): JSX.Element { const listRef = useRef<HTMLDivElement | null>(null) // Original theme snapshot captured when entering the theme picker. - // Used to revert if the user cancels with Escape or clicks outside. - const originalThemeRef = useRef<ThemeSnapshot | null>(null) + // Used to revert if the user cancels with Escape or clicks outside. A + // palette opened straight on the theme list (a key bound to Themes…) + // takes it as it opens. + const originalThemeRef = useRef<ThemeSnapshot | null>( + initialMode === 'theme' + ? { + id: useStore.getState().themeId, + family: useStore.getState().themeFamily, + mode: useStore.getState().themeMode + } + : null + ) const committedRef = useRef(false) - const allCommands = useMemo(() => buildCommands(), []) + const commandKeymaps = useStore((s) => s.commandKeymaps) + const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) + const setCommandKeymapBinding = useStore((s) => s.setCommandKeymapBinding) + // Alt+Enter on a row records a key for that command right here (#971). + const [recordingFor, setRecordingFor] = useState<Command | null>(null) + + // Rebuilt when a command gets a key, so its row shows it straight away. + // eslint-disable-next-line react-hooks/exhaustive-deps + const allCommands = useMemo(() => buildCommands(), [commandKeymaps]) // Recently-used commands surfaced at the top — only with an empty query, in // command mode. `allCommands` is already filtered by each command's `when` @@ -174,6 +197,8 @@ export function CommandPalette(): JSX.Element { void refreshLocalVaults() if (supportsRemoteWorkspace) void refreshRemoteWorkspaceProfiles() } + // The same re-read the picker does when drilled into from the list. + if (initialMode === 'workflow') void useStore.getState().loadWorkflowIndex() }, [initialMode, refreshLocalVaults, refreshRemoteWorkspaceProfiles, supportsRemoteWorkspace]) // Selection sync: // - Main mode: start at the top of the results on every query change. @@ -345,6 +370,27 @@ export function CommandPalette(): JSX.Element { } } + /** + * Alt+Enter: record a key for the highlighted command without leaving the + * palette. The recorder opens over it, Enter saves, Escape comes back to + * the same row and query, and the row shows its new key. Inline rather + * than a jump to Settings → Keymap, which would drop the query, land on a + * page of three hundred rows and take two Escapes to come back from; the + * recorder is the same one Settings uses, conflict checks and all. + */ + const startRecording = (cmd: Command): void => { + if (!isBindableCommandId(cmd.id)) { + useToastStore + .getState() + .addToast( + `${cmd.title} can't take a key: the palette makes it for what is in this vault, so it comes and goes`, + 'info' + ) + return + } + setRecordingFor(cmd) + } + const commitTheme = (theme: ThemeOption): void => { setTheme({ id: theme.id, family: theme.family, mode: theme.mode }) committedRef.current = true @@ -387,6 +433,7 @@ export function CommandPalette(): JSX.Element { : 'Pick a vault' return ( + <> <Modal size="md" layer="palette" onClose={() => closePalette()} closeOnEsc={false}> {canReturnToCommandList(mode, initialMode) && ( <div className="flex items-center gap-2 border-b border-paper-300/70 bg-paper-200/40 px-4 py-2 text-xs text-ink-500"> @@ -427,6 +474,16 @@ export function CommandPalette(): JSX.Element { e.preventDefault() e.stopPropagation() setActive((a) => Math.max(0, a - 1)) + } else if ( + e.key === 'Enter' && + e.altKey && + !e.metaKey && + !e.ctrlKey && + mode === 'main' + ) { + e.preventDefault() + const cmd = commandResults[active] + if (cmd) startRecording(cmd) } else if (e.key === 'Enter') { e.preventDefault() if (mode === 'main') { @@ -623,6 +680,11 @@ export function CommandPalette(): JSX.Element { ? 'keep theme' : 'switch'} </span> + {mode === 'main' && ( + <span data-palette-bind-hint=""> + <kbd className="rounded bg-paper-200 px-1">{formatKeyToken('Alt+Enter')}</kbd> set key + </span> + )} <span> <kbd className="rounded bg-paper-200 px-1">esc</kbd>{' '} {!canReturnToCommandList(mode, initialMode) @@ -633,5 +695,29 @@ export function CommandPalette(): JSX.Element { </span> </div> </Modal> + {recordingFor && ( + <KeymapRecorderModal + target={{ + kind: 'command', + id: recordingFor.id, + title: recordingFor.bindTitle ?? recordingFor.title, + category: recordingFor.category, + builtinShortcut: recordingFor.builtinShortcut || undefined + }} + overrides={keymapOverrides} + commandKeymaps={commandKeymaps} + vimMode={vimMode} + onClose={() => setRecordingFor(null)} + onSave={(binding) => { + setCommandKeymapBinding(recordingFor.id, binding) + setRecordingFor(null) + }} + onUnbind={() => { + setCommandKeymapBinding(recordingFor.id, '') + setRecordingFor(null) + }} + /> + )} + </> ) } diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index a802b318d..07eecd677 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -90,15 +90,13 @@ import { focusPaneInDirection, focusPaneOrEdgePanel } from "../lib/pane-nav"; import { requestPaneMode } from "../lib/pane-mode"; import { getKeymapBinding, - getKeymapDefinitions, - getKeymapDisplay, getSequenceTokens, - UNBOUND_BINDING, type KeymapId, type KeymapOverrides, } from "../lib/keymaps"; import { requestSettingsTarget } from "../lib/settings-navigation"; import { useToastStore } from "../lib/toast"; +import { runBindEx, runUnbindEx } from "../lib/command-keymap-ex"; import { navigateActiveBuffer, selectActiveBuffer, @@ -687,36 +685,25 @@ function registerVimCommands(): void { }, ); // `:unbind <action.id>` removes an action's key entirely, the ex twin of - // the Unbind button under Settings, Keymaps. Without an argument, or with - // an id the catalog does not know, it opens that page, where every id is - // listed, instead of guessing. + // the Unbind button under Settings, Keymaps; `:unbind command <id>` does + // the same for a command palette command (#971). Without an argument, or + // with an id nothing knows, it opens that page, where every id is listed, + // instead of guessing. Vim.defineEx( "unbind", "unbind", (_cm: unknown, params: { argString?: string } | undefined) => { - const arg = (params?.argString ?? "").trim(); - const state = useStore.getState(); - const definition = getKeymapDefinitions().find((d) => d.id === arg); - if (!definition) { - if (arg) { - useToastStore - .getState() - .addToast(`No keymap action is called "${arg}"`, "info"); - } - requestSettingsTarget("keymaps"); - state.setSettingsOpen(true); - return; - } - const before = getKeymapDisplay(state.keymapOverrides, definition.id); - state.setKeymapBinding(definition.id, UNBOUND_BINDING); - useToastStore - .getState() - .addToast( - before - ? `Unbound ${definition.title} (was ${before})` - : `${definition.title} is already unbound`, - "success", - ); + runUnbindEx(params?.argString ?? ""); + }, + ); + // `:bind <keys> <command.id>` gives a command palette command a key (#971): + // `:bind <C-BS> note.trash`, `:bind <leader>xa note.archive`, or the + // Settings spelling, `:bind Mod+Backspace note.trash`. + Vim.defineEx( + "bind", + "bind", + (_cm: unknown, params: { argString?: string } | undefined) => { + runBindEx(params?.argString ?? ""); }, ); Vim.defineEx("quit", "q", () => { @@ -1386,6 +1373,7 @@ const MANUAL_EX_NAMES = new Set([ "wq", "format", "unbind", + "bind", "tasks", "tag", "template", diff --git a/packages/app-core/src/components/KeymapRecorder.tsx b/packages/app-core/src/components/KeymapRecorder.tsx new file mode 100644 index 000000000..e6f8c406e --- /dev/null +++ b/packages/app-core/src/components/KeymapRecorder.tsx @@ -0,0 +1,493 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { + findKeymapConflict, + formatKeymapBinding, + getDefaultKeymapBinding, + getKeymapBinding, + getKeymapDisplay, + isMacPlatform, + isUnboundBinding, + sequenceTokenFromEvent, + shortcutBindingFromEvent, + UNBOUND_LABEL, + type KeymapDefinition, + type KeymapOverrides +} from '../lib/keymaps' +import { + commandBindingKind, + commandRecorderStep, + COMMAND_LEADER_TOKEN, + findActionCommandConflicts, + findCommandBindingConflicts, + formatCommandBinding, + leaderTokenOf, + MAX_COMMAND_SEQUENCE_KEYS, + parseCommandBinding, + type CommandKeymapConflict, + type CommandKeymaps +} from '../lib/command-keymaps' +import { Button } from './ui/Button' +import { Modal } from './ui/Modal' + +/** What a recorder is recording a key for: a built-in action, or a command. */ +export type KeymapRecorderTarget = + | { kind: 'action'; definition: KeymapDefinition } + | { + kind: 'command' + id: string + title: string + category: string + /** A key the command already has from elsewhere (a leader chord), shown + * so the new binding reads as an addition. */ + builtinShortcut?: string + } + +/** + * The key recorder behind every Change… in Settings → Keymap, and behind + * Alt+Enter in the command palette (#971). Keys are captured on the window + * in the capture phase while it is open; everything else that listens there + * stands down while Settings or the palette is open. + * + * An action's recorder takes any key, Enter and Escape included, so it is + * saved and cancelled with the buttons. A command's binding is never a bare + * Enter, Escape or Backspace, so those drive its recorder from the keyboard: + * Enter saves, Escape cancels, Backspace clears. + */ +export function KeymapRecorderModal({ + target, + overrides, + commandKeymaps, + vimMode, + onClose, + onSave, + onUnbind +}: { + target: KeymapRecorderTarget + overrides: KeymapOverrides + commandKeymaps: CommandKeymaps + vimMode: boolean + onClose: () => void + /** For an action, the binding as recorded (the caller maps its default + * back to "no override"); for a command, the canonical binding. */ + onSave: (binding: string) => void + onUnbind: () => void +}): JSX.Element { + return target.kind === 'action' ? ( + <ActionRecorder + definition={target.definition} + overrides={overrides} + commandKeymaps={commandKeymaps} + onClose={onClose} + onSave={onSave} + onUnbind={onUnbind} + /> + ) : ( + <CommandRecorder + target={target} + overrides={overrides} + commandKeymaps={commandKeymaps} + vimMode={vimMode} + onClose={onClose} + onSave={onSave} + onUnbind={onUnbind} + /> + ) +} + +function RecorderShell({ + title, + description, + display, + hint, + notices, + current, + secondary, + canUnbind, + canSave, + onUnbind, + onClose, + onSave, + recorderKind +}: { + title: string + description: string + display: string + hint: string + notices: JSX.Element | null + current: string + secondary: string + canUnbind: boolean + canSave: boolean + onUnbind: () => void + onClose: () => void + onSave: () => void + recorderKind: 'action' | 'command' +}): JSX.Element { + return ( + <Modal + size="sm" + layer="popover" + align="center" + onClose={onClose} + closeOnEsc={false} + initialFocus="panel" + data={{ 'data-keymap-recorder': recorderKind }} + > + <div className="border-b border-paper-300/60 px-5 py-4"> + <div className="text-base font-semibold text-ink-900">{title}</div> + <div className="mt-1 text-sm text-ink-500">{description}</div> + </div> + <div className="px-5 py-4"> + <div className="rounded-xl border border-paper-300/70 bg-paper-50/80 px-4 py-4"> + <div className="text-xs font-medium uppercase tracking-[0.16em] text-ink-500"> + Recording + </div> + <div + className="mt-2 text-2xl font-semibold text-ink-900" + data-keymap-recorder-binding="" + > + {display} + </div> + <div className="mt-2 text-xs leading-5 text-ink-500">{hint}</div> + </div> + {notices} + <div className="mt-3 text-xs text-ink-500">Current: {current}</div> + <div className="mt-1 text-xs text-ink-500">{secondary}</div> + </div> + <div className="flex items-center justify-between gap-3 border-t border-paper-300/60 px-5 py-3"> + <Button + size="sm" + variant="secondary" + onClick={onUnbind} + disabled={!canUnbind} + title="Save this with no key at all." + > + Unbind + </Button> + <div className="flex items-center gap-2"> + <Button size="sm" variant="secondary" onClick={onClose}> + Cancel + </Button> + <Button + size="sm" + variant="primary" + disabled={!canSave} + onClick={onSave} + data-keymap-recorder-save="" + > + Save + </Button> + </div> + </div> + </Modal> + ) +} + +function Notice({ + tone, + children +}: { + tone: 'danger' | 'warning' | 'info' + children: ReactNode +}): JSX.Element { + const toneClass = + tone === 'danger' + ? 'border-danger/35 bg-danger/10 text-danger' + : tone === 'warning' + ? 'border-warning/35 bg-warning/10 text-ink-800' + : 'border-paper-300/70 bg-paper-100/80 text-ink-700' + return ( + <div + className={`mt-3 flex items-start gap-2 rounded-xl border px-3 py-2.5 text-xs leading-5 ${toneClass}`} + data-keymap-recorder-notice={tone} + > + <span aria-hidden>{tone === 'info' ? 'ⓘ' : '⚠'}</span> + <span>{children}</span> + </div> + ) +} + +function ActionRecorder({ + definition, + overrides, + commandKeymaps, + onClose, + onSave, + onUnbind +}: { + definition: KeymapDefinition + overrides: KeymapOverrides + commandKeymaps: CommandKeymaps + onClose: () => void + onSave: (binding: string) => void + onUnbind: () => void +}): JSX.Element { + const currentBinding = getKeymapBinding(overrides, definition.id) + const [binding, setBinding] = useState(currentBinding) + const mac = isMacPlatform() + // Block saving a global shortcut that another action already owns (#298). + const conflict = binding ? findKeymapConflict(overrides, definition.id, binding) : null + // And one a command's key holds (#971): both are keys the user chose. A + // leader key that starts a command's sequence is allowed (built-in leader + // keys always win) but said out loud, since the command loses its keys. + const commandHits = binding + ? findActionCommandConflicts(definition.id, binding, { overrides, commandKeymaps }) + : [] + const leaderKey = definition.scope === 'leader' + const blockingCommand = leaderKey ? null : commandHits[0] ?? null + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + const target = event.target as HTMLElement | null + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return + const key = event.key + if (key === 'Backspace' || key === 'Delete') { + event.preventDefault() + event.stopPropagation() + if (definition.kind === 'shortcut') { + setBinding('') + return + } + setBinding((current) => { + const tokens = current.split(/\s+/).filter(Boolean) + tokens.pop() + return tokens.join(' ') + }) + return + } + + const next = + definition.kind === 'shortcut' ? shortcutBindingFromEvent(event) : sequenceTokenFromEvent(event) + if (!next) return + + event.preventDefault() + event.stopPropagation() + + if (definition.kind === 'shortcut') { + setBinding(next) + return + } + + setBinding((current) => { + const limit = definition.maxTokens ?? 2 + const tokens = current.split(/\s+/).filter(Boolean) + if (limit <= 1) return next + if (tokens.length >= limit) return next + return [...tokens, next].join(' ') + }) + } + + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [definition]) + + const notices = + conflict || blockingCommand || (leaderKey && commandHits.length > 0) ? ( + <> + {conflict && ( + <Notice tone="danger"> + Already used by <strong className="font-semibold">{conflict.title}</strong>. Two global + shortcuts can’t share a key. Reset or reassign that one first. + </Notice> + )} + {blockingCommand && ( + <Notice tone="danger"> + Already the key of the command{' '} + <strong className="font-semibold">{blockingCommand.title}</strong>. Two of your keys + can’t share one. Unbind or change that command first. + </Notice> + )} + {leaderKey && commandHits.length > 0 && ( + <Notice tone="warning"> + Your key sequence for{' '} + <strong className="font-semibold"> + {commandHits.map((hit) => hit.title).join(', ')} + </strong>{' '} + starts with this key. Built-in leader keys always win, so that command stops answering + its keys until you move one of them. + </Notice> + )} + </> + ) : null + + return ( + <RecorderShell + recorderKind="action" + title={definition.title} + description={definition.description} + display={binding ? formatKeymapBinding(binding, definition.kind) : 'Press a key…'} + hint={ + definition.kind === 'shortcut' + ? `Press the shortcut you want; Backspace clears it. ${mac ? 'Command' : 'Ctrl'}-style chords are saved in the app’s cross-platform format. Unbind leaves the action with no key at all.` + : `Press the sequence you want. Backspace removes the last token, and multi-step sequences stop at ${definition.maxTokens ?? 2} key${(definition.maxTokens ?? 2) === 1 ? '' : 's'}. Unbind leaves the action with no key at all.` + } + notices={notices} + current={ + isUnboundBinding(currentBinding) + ? UNBOUND_LABEL + : formatKeymapBinding(currentBinding, definition.kind) + } + secondary={`Default: ${formatKeymapBinding(getDefaultKeymapBinding(definition.id), definition.kind)}`} + canUnbind={!isUnboundBinding(currentBinding)} + canSave={!!binding && !conflict && !blockingCommand} + onUnbind={onUnbind} + onClose={onClose} + onSave={() => onSave(binding)} + /> + ) +} + +function describeBlocking(conflict: CommandKeymapConflict, sequence: boolean): JSX.Element { + if (sequence && conflict.kind === 'action') { + return ( + <> + The key right after the leader belongs to the built-in{' '} + <strong className="font-semibold">{conflict.title}</strong>. Built-in leader keys always + win, so pick another key after the leader. + </> + ) + } + if (sequence) { + return ( + <> + Overlaps the key sequence of <strong className="font-semibold">{conflict.title}</strong>: + one would start the other. Change one of them first. + </> + ) + } + return ( + <> + Already used by{' '} + <strong className="font-semibold"> + {conflict.kind === 'command' ? `the command ${conflict.title}` : conflict.title} + </strong> + . Two of your keys can’t share one. Change or unbind that one first. + </> + ) +} + +function CommandRecorder({ + target, + overrides, + commandKeymaps, + vimMode, + onClose, + onSave, + onUnbind +}: { + target: Extract<KeymapRecorderTarget, { kind: 'command' }> + overrides: KeymapOverrides + commandKeymaps: CommandKeymaps + vimMode: boolean + onClose: () => void + onSave: (binding: string) => void + onUnbind: () => void +}): JSX.Element { + const currentBinding = commandKeymaps[target.id] ?? '' + const [binding, setBinding] = useState(currentBinding) + const [hint, setHint] = useState<string | null>(null) + const mac = isMacPlatform() + const leaderToken = leaderTokenOf(overrides) + + const complete = binding !== '' && binding !== COMMAND_LEADER_TOKEN + const parsed = complete ? parseCommandBinding(binding, overrides) : null + const conflicts = complete + ? findCommandBindingConflicts(target.id, binding, { overrides, commandKeymaps }) + : { blocking: [], shadows: [] } + const sequence = commandBindingKind(binding) === 'sequence' + const canSave = + complete && !!parsed?.ok && conflicts.blocking.length === 0 && binding !== currentBinding + + // The latest values for the window listener, which is installed once. + const latest = { binding, canSave, onSave, onClose } + const latestRef = useLatest(latest) + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + const step = commandRecorderStep(latestRef.current.binding, event, { + leaderToken, + shortcutFromEvent: shortcutBindingFromEvent, + tokenFromEvent: sequenceTokenFromEvent + }) + // Modifiers on their own, and Tab between the buttons, pass through. + if (step.type === 'ignore' && !step.hint) return + event.preventDefault() + event.stopPropagation() + if (step.type === 'save') { + if (latestRef.current.canSave) latestRef.current.onSave(latestRef.current.binding) + return + } + if (step.type === 'cancel') { + latestRef.current.onClose() + return + } + if (step.type === 'ignore') { + setHint(step.hint ?? null) + return + } + setHint(null) + setBinding(step.binding) + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [leaderToken, latestRef]) + + const display = + binding === COMMAND_LEADER_TOKEN + ? `${formatCommandBinding(binding, overrides)} …` + : binding + ? formatCommandBinding(binding, overrides) + : 'Press a key…' + + const notices = ( + <> + {hint && <Notice tone="info">{hint}</Notice>} + {parsed && !parsed.ok && <Notice tone="danger">{parsed.error}</Notice>} + {conflicts.blocking.map((conflict) => ( + <Notice key={`b-${conflict.id}`} tone="danger"> + {describeBlocking(conflict, sequence)} + </Notice> + ))} + {conflicts.shadows.map((conflict) => ( + <Notice key={`s-${conflict.id}`} tone="warning"> + This is the default key of <strong className="font-semibold">{conflict.title}</strong>. + Your key wins, so {conflict.title} stops answering it until you move one of them. + </Notice> + ))} + {sequence && !vimMode && ( + <Notice tone="info"> + Leader sequences work with Vim mode on. With it off, this one does nothing until Vim + mode is back on. + </Notice> + )} + {sequence && !leaderToken && ( + <Notice tone="info">The leader key is unbound, so no sequence can start right now.</Notice> + )} + </> + ) + + const leaderName = leaderToken ? getKeymapDisplay(overrides, 'vim.leaderPrefix') : '' + return ( + <RecorderShell + recorderKind="command" + title={target.title} + description={`${target.category} command · ${target.id}${target.builtinShortcut ? ` · also ${target.builtinShortcut}` : ''}`} + display={display} + hint={`Press a chord with Ctrl, Alt or ${mac ? 'Cmd' : 'Meta'}, or a function key${leaderName ? `, or ${leaderName} and then up to ${MAX_COMMAND_SEQUENCE_KEYS} keys for a leader sequence` : ''}. A command’s key wins over the defaults, the editor’s own keys included. Enter saves, Esc cancels, Backspace clears.`} + notices={notices} + current={currentBinding ? formatCommandBinding(currentBinding, overrides) : UNBOUND_LABEL} + secondary="Default: Unbound" + canUnbind={currentBinding !== ''} + canSave={canSave} + onUnbind={onUnbind} + onClose={onClose} + onSave={() => onSave(binding)} + /> + ) +} + +function useLatest<T>(value: T): { current: T } { + const [ref] = useState(() => ({ current: value })) + ref.current = value + return ref +} diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index a233055f6..781f56c43 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -55,6 +55,8 @@ const mocks = vi.hoisted(() => { kanbanFolderRoot: "", kanbanStatuses: [] as string[], keymapOverrides: {} as Record<string, string>, + commandKeymaps: {} as Record<string, string>, + setCommandKeymapBinding: vi.fn(), lineNumberMode: "off", mathRenderer: "katex" as "katex" | "typst", monoFont: null, @@ -860,6 +862,101 @@ describe("SettingsModal date note directories", () => { const recorder = document.body.textContent ?? ""; expect(recorder).toContain("Current: Unbound"); }); + function pressOnWindow(init: KeyboardEventInit & { key: string; code: string }): void { + window.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }), + ); + } + + describe("the Commands group (#971)", () => { + beforeEach(() => { + mocks.state.commandKeymaps = {}; + (window.zen as unknown as { platformSync: () => string }).platformSync = () => + mocks.platform; + }); + + it("lists a palette command with its category and id, unbound, and records a key for it", async () => { + const row = await openKeymapRow("Move Note to Trash"); + expect(row.dataset.commandKeymapRow).toBe("note.trash"); + expect(row.textContent).toContain("Note"); + expect(row.textContent).toContain("note.trash"); + expect(row.textContent).toContain("Unbound"); + expect(rowButton(row, "Unbind").disabled).toBe(true); + expect(rowButton(row, "Reset").disabled).toBe(true); + + await act(async () => rowButton(row, "Change…").click()); + expect(document.querySelector('[data-keymap-recorder="command"]')).toBeTruthy(); + await act(async () => + pressOnWindow({ key: "Backspace", code: "Backspace", ctrlKey: true }), + ); + expect( + document.querySelector("[data-keymap-recorder-binding]")?.textContent, + ).toBe("Ctrl+Backspace"); + await act(async () => pressOnWindow({ key: "Enter", code: "Enter" })); + expect(mocks.state.setCommandKeymapBinding).toHaveBeenCalledWith( + "note.trash", + "Mod+Backspace", + ); + }); + + it("finds a command by its id in the filter", async () => { + const row = await openKeymapRow("Restore Note from Trash"); + expect(row.dataset.commandKeymapRow).toBe("note.restore"); + const filter = host.querySelector<HTMLInputElement>( + 'input[placeholder="Filter keymaps…"]', + )!; + await act(async () => changeInput(filter, "note.delete-permanently")); + expect( + [...host.querySelectorAll("[data-command-keymap-row]")].map( + (el) => (el as HTMLElement).dataset.commandKeymapRow, + ), + ).toEqual(["note.delete-permanently"]); + }); + + it("unbinds and resets a command from its row", async () => { + mocks.state.commandKeymaps = { "note.trash": "Mod+Backspace" }; + const row = await openKeymapRow("Move Note to Trash"); + expect(row.textContent).toContain("Ctrl+Backspace"); + await act(async () => rowButton(row, "Unbind").click()); + expect(mocks.state.setCommandKeymapBinding).toHaveBeenCalledWith("note.trash", ""); + await act(async () => rowButton(row, "Reset").click()); + expect(mocks.state.setCommandKeymapBinding).toHaveBeenCalledWith("note.trash", null); + }); + + it("marks clashes both ways: another command, a default it wins over, and the action that lost its key", async () => { + mocks.state.commandKeymaps = { + "note.trash": "Mod+L", + "note.archive": "Mod+L", + }; + const trash = await openKeymapRow("Move Note to Trash"); + expect(trash.textContent).toContain("Conflicts with Move Note to Archive"); + expect(trash.textContent).toContain("Wins over Toggle checkbox"); + const filter = host.querySelector<HTMLInputElement>( + 'input[placeholder="Filter keymaps…"]', + )!; + await act(async () => changeInput(filter, "Toggle checkbox")); + const action = host.querySelector<HTMLElement>( + '[data-keymap-row="editor.toggleCheckbox"]', + )!; + expect(action.textContent).toContain("Taken by command Move Note to Trash"); + }); + + it("marks a leader sequence inert while Vim mode is off", async () => { + mocks.state.vimMode = false; + mocks.state.commandKeymaps = { "note.archive": "Leader x a" }; + const row = await openKeymapRow("Move Note to Archive"); + expect(row.textContent).toContain("Space x a"); + expect(row.querySelector("[data-command-keymap-inert]")).toBeTruthy(); + }); + + it("lets Settings search find a command row by its title", async () => { + await act(async () => root.render(createElement(SettingsModal))); + expect(await openSearchHit("move note to trash", "Move Note to Trash")).toBe( + "command-keymap:note.trash", + ); + }); + }); + async function openTemplatesSection(): Promise<void> { await act(async () => { root.render(createElement(SettingsModal)); diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index b633095c0..d6e9c1c8d 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -57,13 +57,10 @@ import { } from "@shared/workflows/presets"; import { startWorkflowTutorial } from "../lib/workflow-tutorial-flow"; import type { LineNumberMode, WhichKeyHintMode } from "../store"; -import type { - KeymapDefinition, - KeymapId, - KeymapOverrides, -} from "../lib/keymaps"; +import type { KeymapId, KeymapOverrides } from "../lib/keymaps"; import { findKeymapConflict, + formatKeyToken, formatKeymapBinding, getDefaultKeymapBinding, getKeymapBinding, @@ -72,11 +69,23 @@ import { isMacPlatform, isUnboundBinding, keymapAvailableOnHost, - sequenceTokenFromEvent, shortcutBindingFromEvent, UNBOUND_BINDING, UNBOUND_LABEL, } from "../lib/keymaps"; +import { COMMAND_CATALOG } from "@shared/commands-catalog"; +import { + commandBindingKind, + findActionCommandConflicts, + findCommandBindingConflicts, + formatCommandBinding, + type CommandKeymaps, +} from "../lib/command-keymaps"; +import { buildCommands } from "../lib/commands"; +import { + KeymapRecorderModal, + type KeymapRecorderTarget, +} from "./KeymapRecorder"; import { resolveAuto, THEMES, @@ -692,6 +701,8 @@ export function SettingsModal(): JSX.Element { ); const keymapOverrides = useStore((s) => s.keymapOverrides); const setKeymapBinding = useStore((s) => s.setKeymapBinding); + const commandKeymaps = useStore((s) => s.commandKeymaps); + const setCommandKeymapBinding = useStore((s) => s.setCommandKeymapBinding); const resetAllKeymaps = useStore((s) => s.resetAllKeymaps); const whichKeyHints = useStore((s) => s.whichKeyHints); const setWhichKeyHints = useStore((s) => s.setWhichKeyHints); @@ -3514,6 +3525,14 @@ export function SettingsModal(): JSX.Element { "keyboard", ], }, + // One row per palette command (#971), found by its title or id and + // landing on the command's row in the Commands group. + ...COMMAND_CATALOG.map((entry) => ({ + id: `command-keymap:${entry.id}`, + title: entry.title, + description: `Give the ${entry.category} command a key · ${entry.id}`, + keywords: [entry.id], + })), ], content: ( <div @@ -3523,7 +3542,11 @@ export function SettingsModal(): JSX.Element { <KeymapSettings vimMode={vimMode} overrides={keymapOverrides} + commandKeymaps={commandKeymaps ?? {}} onSetBinding={(id, binding) => setKeymapBinding(id, binding)} + onSetCommandBinding={(id, binding) => + setCommandKeymapBinding(id, binding) + } onResetAll={resetAllKeymaps} /> </div> @@ -6405,19 +6428,56 @@ export function SettingsModal(): JSX.Element { ); } +/** The key a command already has from elsewhere (a leader chord, `:w`), read + * off the live command list. Empty where that list cannot be built. */ +function liveBuiltinShortcuts(): Map<string, string> { + try { + return new Map( + buildCommands({ includeUnavailable: true }).map((command) => [ + command.id, + command.builtinShortcut ?? "", + ]), + ); + } catch { + return new Map(); + } +} + +const KEYMAP_CHIP = + "rounded-full border px-2 py-0.5 text-2xs font-medium uppercase tracking-[0.14em]"; +const KEYMAP_ROW_BUTTON = + "rounded-xl border px-3 py-1.5 text-xs font-medium transition-colors"; +const KEYMAP_ROW_BUTTON_ON = + "border-paper-300/70 bg-paper-100/80 text-ink-700 hover:bg-paper-200"; +const KEYMAP_ROW_BUTTON_OFF = + "cursor-not-allowed border-paper-300/60 bg-paper-100/45 text-ink-400"; + function KeymapSettings({ vimMode, overrides, + commandKeymaps, onSetBinding, + onSetCommandBinding, onResetAll, }: { vimMode: boolean; overrides: KeymapOverrides; + commandKeymaps: CommandKeymaps; onSetBinding: (id: KeymapId, binding: string | null) => void; + onSetCommandBinding: (id: string, binding: string | null) => void; onResetAll: () => void; }): JSX.Element { const [query, setQuery] = useState(""); - const [recording, setRecording] = useState<KeymapDefinition | null>(null); + const [recording, setRecording] = useState<KeymapRecorderTarget | null>( + null, + ); + const conflictContext = { overrides, commandKeymaps }; + const builtinShortcuts = useMemo( + () => liveBuiltinShortcuts(), + // The composed chords follow the leader and Vim mode. + // eslint-disable-next-line react-hooks/exhaustive-deps + [overrides, vimMode], + ); const groups = useMemo(() => { const q = query.trim().toLowerCase(); @@ -6426,14 +6486,6 @@ function KeymapSettings({ .map((group) => { const items = group.items.filter((definition) => { if (!keymapAvailableOnHost(definition.id, capabilities)) return false; - if ( - definition.vimOnly && - !vimMode && - definition.id !== "global.searchNotesNonVim" - ) { - // Keep Vim-only bindings visible so users can prep their layout - // before turning Vim mode back on, but still let the filter work. - } if (!q) return true; const display = getKeymapDisplay(overrides, definition.id) || UNBOUND_LABEL; @@ -6451,9 +6503,24 @@ function KeymapSettings({ ): group is ReturnType<typeof getKeymapDefinitionsByGroup>[number] => !!group, ); - }, [overrides, query, vimMode]); + }, [overrides, query]); - const hasOverrides = Object.keys(overrides).length > 0; + // The fifth group (#971): every palette command with a fixed id. Found by + // its title, category, keywords or id, and by the key it has. + const commandRows = useMemo(() => { + const q = query.trim().toLowerCase(); + return COMMAND_CATALOG.filter((entry) => { + if (!q) return true; + const display = + formatCommandBinding(commandKeymaps[entry.id], overrides) || + UNBOUND_LABEL; + return [entry.title, entry.category, entry.keywords ?? "", entry.id, display] + .some((part) => part.toLowerCase().includes(q)); + }); + }, [commandKeymaps, overrides, query]); + + const hasOverrides = + Object.keys(overrides).length > 0 || Object.keys(commandKeymaps).length > 0; return ( <div className="flex h-full min-h-0 flex-col gap-4"> @@ -6467,9 +6534,9 @@ function KeymapSettings({ </div> <div className="mt-1 text-xs leading-5 text-ink-500"> Record a new key or sequence for the app’s keyboard-first - actions, or unbind one so no key triggers it. Standard - accessibility fallbacks like arrows, Enter, and Escape still - work. + actions, give any command in the palette a key of its own, or + unbind one so no key triggers it. Standard accessibility + fallbacks like arrows, Enter, and Escape still work. </div> </div> <div className="flex max-w-full flex-wrap items-center gap-2"> @@ -6512,6 +6579,13 @@ function KeymapSettings({ definition.id, current, ); + // A command's key on this action's key (#971), seen from + // the action's side. + const commandHits = findActionCommandConflicts( + definition.id, + current, + conflictContext, + ); const inactive = (definition.vimOnly && !vimMode) || (definition.nonVimOnly && vimMode); @@ -6520,6 +6594,7 @@ function KeymapSettings({ return ( <div key={definition.id} + data-keymap-row={definition.id} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 px-5 py-4" > <div className="min-w-0 flex-1 basis-56"> @@ -6528,23 +6603,57 @@ function KeymapSettings({ {definition.title} </span> {inactive && ( - <span className="rounded-full border border-paper-300/70 bg-paper-100/85 px-2 py-0.5 text-2xs font-medium uppercase tracking-[0.14em] text-ink-500"> + <span + className={`${KEYMAP_CHIP} border-paper-300/70 bg-paper-100/85 text-ink-500`} + > {definition.vimOnly ? "Vim only" : "Non-Vim only"} </span> )} {custom && ( - <span className="rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-2xs font-medium uppercase tracking-[0.14em] text-accent"> + <span + className={`${KEYMAP_CHIP} border-accent/25 bg-accent/10 text-accent`} + > Custom </span> )} {conflict && ( <span - className="rounded-full border border-danger/35 bg-danger/10 px-2 py-0.5 text-2xs font-medium uppercase tracking-[0.14em] text-danger" + className={`${KEYMAP_CHIP} border-danger/35 bg-danger/10 text-danger`} title={`Also bound to “${conflict.title}”. Two global shortcuts on one key means one is ignored. Reassign one to resolve.`} > Conflicts with {conflict.title} </span> )} + {commandHits.map((hit) => + definition.scope === "leader" ? ( + <span + key={hit.id} + data-keymap-conflict="blocks-command" + className={`${KEYMAP_CHIP} border-warning/35 bg-warning/10 text-ink-700`} + title={`Your key sequence for the command “${hit.title}” starts with this leader key. Built-in leader keys always win, so that command cannot be reached until one of them moves.`} + > + Blocks command {hit.title} + </span> + ) : custom ? ( + <span + key={hit.id} + data-keymap-conflict="command" + className={`${KEYMAP_CHIP} border-danger/35 bg-danger/10 text-danger`} + title={`Your key for the command “${hit.title}” is the same. The command wins; reassign one to resolve.`} + > + Conflicts with command {hit.title} + </span> + ) : ( + <span + key={hit.id} + data-keymap-conflict="taken-by-command" + className={`${KEYMAP_CHIP} border-warning/35 bg-warning/10 text-ink-700`} + title={`Your key for the command “${hit.title}” wins over this default, so this action does not answer it.`} + > + Taken by command {hit.title} + </span> + ), + )} </div> <div className="mt-1 text-xs leading-5 text-ink-500"> {definition.description} @@ -6565,8 +6674,10 @@ function KeymapSettings({ </span> <button type="button" - onClick={() => setRecording(definition)} - className="rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-1.5 text-xs font-medium text-ink-800 transition-colors hover:bg-paper-200" + onClick={() => + setRecording({ kind: "action", definition }) + } + className={`${KEYMAP_ROW_BUTTON} border-paper-300/70 bg-paper-100/80 text-ink-800 hover:bg-paper-200`} > Change… </button> @@ -6577,12 +6688,7 @@ function KeymapSettings({ onSetBinding(definition.id, UNBOUND_BINDING) } title="Remove the key entirely. Nothing triggers this action until it is rebound or reset." - className={[ - "rounded-xl border px-3 py-1.5 text-xs font-medium transition-colors", - unbound - ? "cursor-not-allowed border-paper-300/60 bg-paper-100/45 text-ink-400" - : "border-paper-300/70 bg-paper-100/80 text-ink-700 hover:bg-paper-200", - ].join(" ")} + className={`${KEYMAP_ROW_BUTTON} ${unbound ? KEYMAP_ROW_BUTTON_OFF : KEYMAP_ROW_BUTTON_ON}`} > Unbind </button> @@ -6590,12 +6696,146 @@ function KeymapSettings({ type="button" disabled={!custom} onClick={() => onSetBinding(definition.id, null)} + className={`${KEYMAP_ROW_BUTTON} ${custom ? KEYMAP_ROW_BUTTON_ON : KEYMAP_ROW_BUTTON_OFF}`} + > + Reset + </button> + </div> + </div> + ); + })} + </div> + </div> + ))} + {commandRows.length > 0 && ( + <div data-keymap-group="commands"> + <div className="px-5 pt-4 text-xs font-medium uppercase tracking-[0.18em] text-ink-500"> + Commands + </div> + <div className="px-5 pt-1 text-xs leading-5 text-ink-500"> + Every command in the palette can take a key: a chord with + Ctrl, Alt or {isMacPlatform() ? "Cmd" : "Meta"}, a function + key, or with Vim mode on a leader sequence. All start unbound.{" "} + {formatKeyToken("Alt+Enter")} on a palette row records one there + too. + </div> + <div className="pb-4"> + {commandRows.map((entry) => { + const binding = commandKeymaps[entry.id]; + const custom = binding !== undefined; + const kind = commandBindingKind(binding); + const unbound = !kind; + const { blocking, shadows } = findCommandBindingConflicts( + entry.id, + binding ?? "", + conflictContext, + ); + const inert = kind === "sequence" && !vimMode; + const builtin = builtinShortcuts.get(entry.id) ?? ""; + return ( + <div + key={entry.id} + data-command-keymap-row={entry.id} + {...settingsSearchTargetProps(`command-keymap:${entry.id}`)} + className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 px-5 py-4" + > + <div className="min-w-0 flex-1 basis-56"> + <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> + <span className="text-sm font-medium text-ink-900"> + {entry.title} + </span> + <span + className={`${KEYMAP_CHIP} border-paper-300/70 bg-paper-100/85 text-ink-500`} + > + {entry.category} + </span> + {inert && ( + <span + data-command-keymap-inert="" + className={`${KEYMAP_CHIP} border-paper-300/70 bg-paper-100/85 text-ink-500`} + title="A leader sequence runs through Vim mode's leader key, so it does nothing while Vim mode is off." + > + Inert with Vim off + </span> + )} + {custom && ( + <span + className={`${KEYMAP_CHIP} border-accent/25 bg-accent/10 text-accent`} + > + Custom + </span> + )} + {blocking.map((hit) => ( + <span + key={`b-${hit.id}`} + data-keymap-conflict="blocking" + className={`${KEYMAP_CHIP} border-danger/35 bg-danger/10 text-danger`} + title={`Also bound to “${hit.title}”. Reassign one to resolve.`} + > + Conflicts with {hit.title} + </span> + ))} + {shadows.map((hit) => ( + <span + key={`s-${hit.id}`} + data-keymap-conflict="shadows" + className={`${KEYMAP_CHIP} border-warning/35 bg-warning/10 text-ink-700`} + title={`This is the default key of “${hit.title}”. Your key wins, so that action does not answer it.`} + > + Wins over {hit.title} + </span> + ))} + </div> + <div className="mt-1 text-xs leading-5 text-ink-500"> + <code className="font-mono">{entry.id}</code> + {builtin && <> · also {builtin}</>} + </div> + </div> + <div className="flex max-w-full flex-wrap items-center gap-2"> + <span + data-command-keymap-binding="" className={[ - "rounded-xl border px-3 py-1.5 text-xs font-medium transition-colors", - custom - ? "border-paper-300/70 bg-paper-100/80 text-ink-700 hover:bg-paper-200" - : "cursor-not-allowed border-paper-300/60 bg-paper-100/45 text-ink-400", + "rounded-xl border px-3 py-1.5 text-xs font-medium", + unbound + ? "border-dashed border-paper-300/70 bg-paper-100/45 text-ink-500" + : "border-paper-300/70 bg-paper-100/85 text-ink-900", ].join(" ")} + > + {unbound + ? UNBOUND_LABEL + : formatCommandBinding(binding, overrides)} + </span> + <button + type="button" + onClick={() => + setRecording({ + kind: "command", + id: entry.id, + title: entry.title, + category: entry.category, + builtinShortcut: builtin || undefined, + }) + } + className={`${KEYMAP_ROW_BUTTON} border-paper-300/70 bg-paper-100/80 text-ink-800 hover:bg-paper-200`} + > + Change… + </button> + <button + type="button" + disabled={unbound} + onClick={() => + onSetCommandBinding(entry.id, UNBOUND_BINDING) + } + title="Remove the key entirely." + className={`${KEYMAP_ROW_BUTTON} ${unbound ? KEYMAP_ROW_BUTTON_OFF : KEYMAP_ROW_BUTTON_ON}`} + > + Unbind + </button> + <button + type="button" + disabled={!custom} + onClick={() => onSetCommandBinding(entry.id, null)} + className={`${KEYMAP_ROW_BUTTON} ${custom ? KEYMAP_ROW_BUTTON_ON : KEYMAP_ROW_BUTTON_OFF}`} > Reset </button> @@ -6605,8 +6845,8 @@ function KeymapSettings({ })} </div> </div> - ))} - {groups.length === 0 && ( + )} + {groups.length === 0 && commandRows.length === 0 && ( <div className="px-5 py-10 text-center text-sm text-ink-500"> No keymaps match your filter. </div> @@ -6616,21 +6856,30 @@ function KeymapSettings({ {recording && ( <KeymapRecorderModal - definition={recording} + target={recording} overrides={overrides} - currentBinding={getKeymapBinding(overrides, recording.id)} + commandKeymaps={commandKeymaps} + vimMode={vimMode} onClose={() => setRecording(null)} onSave={(binding) => { - onSetBinding( - recording.id, - binding === getDefaultKeymapBinding(recording.id) - ? null - : binding, - ); + if (recording.kind === "action") { + onSetBinding( + recording.definition.id, + binding === getDefaultKeymapBinding(recording.definition.id) + ? null + : binding, + ); + } else { + onSetCommandBinding(recording.id, binding); + } setRecording(null); }} onUnbind={() => { - onSetBinding(recording.id, UNBOUND_BINDING); + if (recording.kind === "action") { + onSetBinding(recording.definition.id, UNBOUND_BINDING); + } else { + onSetCommandBinding(recording.id, UNBOUND_BINDING); + } setRecording(null); }} /> @@ -6723,172 +6972,6 @@ function IgnoredKeysRow({ settingId }: { settingId?: string }): JSX.Element { ); } -function KeymapRecorderModal({ - definition, - overrides, - currentBinding, - onClose, - onSave, - onUnbind, -}: { - definition: KeymapDefinition; - overrides: KeymapOverrides; - currentBinding: string; - onClose: () => void; - onSave: (binding: string) => void; - onUnbind: () => void; -}): JSX.Element { - const [binding, setBinding] = useState(currentBinding); - const mac = isMacPlatform(); - // Block saving a global shortcut that another action already owns (#298). - const conflict = binding - ? findKeymapConflict(overrides, definition.id, binding) - : null; - - useEffect(() => { - const onKey = (event: KeyboardEvent): void => { - const target = event.target as HTMLElement | null; - if ( - target && - (target.tagName === "INPUT" || target.tagName === "TEXTAREA") - ) - return; - const key = event.key; - if (key === "Backspace" || key === "Delete") { - event.preventDefault(); - event.stopPropagation(); - if (definition.kind === "shortcut") { - setBinding(""); - return; - } - setBinding((current) => { - const tokens = current.split(/\s+/).filter(Boolean); - tokens.pop(); - return tokens.join(" "); - }); - return; - } - - const next = - definition.kind === "shortcut" - ? shortcutBindingFromEvent(event) - : sequenceTokenFromEvent(event); - if (!next) return; - - event.preventDefault(); - event.stopPropagation(); - - if (definition.kind === "shortcut") { - setBinding(next); - return; - } - - setBinding((current) => { - const limit = definition.maxTokens ?? 2; - const tokens = current.split(/\s+/).filter(Boolean); - if (limit <= 1) return next; - if (tokens.length >= limit) return next; - return [...tokens, next].join(" "); - }); - }; - - window.addEventListener("keydown", onKey, true); - return () => window.removeEventListener("keydown", onKey, true); - }, [definition]); - - const display = binding - ? formatKeymapBinding(binding, definition.kind) - : "Press a key…"; - - return createPortal( - <div className="fixed inset-0 z-toast flex items-center justify-center bg-black/35 px-4 backdrop-blur-sm"> - <div className="w-[min(440px,92vw)] overflow-hidden rounded-2xl border border-paper-300/70 bg-paper-100 shadow-float"> - <div className="border-b border-paper-300/60 px-5 py-4"> - <div className="text-base font-semibold text-ink-900"> - {definition.title} - </div> - <div className="mt-1 text-sm text-ink-500"> - {definition.description} - </div> - </div> - <div className="px-5 py-4"> - <div className="rounded-xl border border-paper-300/70 bg-paper-50/80 px-4 py-4"> - <div className="text-xs font-medium uppercase tracking-[0.16em] text-ink-500"> - Recording - </div> - <div className="mt-2 text-2xl font-semibold text-ink-900"> - {display} - </div> - <div className="mt-2 text-xs leading-5 text-ink-500"> - {definition.kind === "shortcut" - ? `Press the shortcut you want; Backspace clears it. ${mac ? "Command" : "Ctrl"}-style chords are saved in the app’s cross-platform format. Unbind leaves the action with no key at all.` - : `Press the sequence you want. Backspace removes the last token, and multi-step sequences stop at ${definition.maxTokens ?? 2} key${(definition.maxTokens ?? 2) === 1 ? "" : "s"}. Unbind leaves the action with no key at all.`} - </div> - </div> - {conflict && ( - <div className="mt-3 flex items-start gap-2 rounded-xl border border-danger/35 bg-danger/10 px-3 py-2.5 text-xs leading-5 text-danger"> - <span aria-hidden>⚠</span> - <span> - Already used by{" "} - <strong className="font-semibold">{conflict.title}</strong>. Two - global shortcuts can’t share a key. Reset or reassign that one - first. - </span> - </div> - )} - <div className="mt-3 text-xs text-ink-500"> - Current:{" "} - {isUnboundBinding(currentBinding) - ? UNBOUND_LABEL - : formatKeymapBinding(currentBinding, definition.kind)} - </div> - <div className="mt-1 text-xs text-ink-500"> - Default:{" "} - {formatKeymapBinding( - getDefaultKeymapBinding(definition.id), - definition.kind, - )} - </div> - </div> - <div className="flex items-center justify-between gap-3 border-t border-paper-300/60 px-5 py-3"> - <button - type="button" - onClick={onUnbind} - disabled={isUnboundBinding(currentBinding)} - title="Save this action with no key at all." - className={[ - "rounded-md border px-3 py-1.5 text-xs font-medium transition-colors", - isUnboundBinding(currentBinding) - ? "cursor-not-allowed border-paper-300/60 bg-paper-100/45 text-ink-400" - : "border-paper-300 bg-paper-100 text-ink-700 hover:bg-paper-200", - ].join(" ")} - > - Unbind - </button> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={onClose} - className="rounded-md border border-paper-300 bg-paper-100 px-3 py-1.5 text-xs font-medium text-ink-700 transition-colors hover:bg-paper-200" - > - Cancel - </button> - <Button - variant="primary" - size="sm" - disabled={!binding || !!conflict} - onClick={() => onSave(binding)} - > - Save - </Button> - </div> - </div> - </div> - </div>, - document.body, - ); -} - function Section({ title, description, diff --git a/packages/app-core/src/components/VimNav-command-leader.test.ts b/packages/app-core/src/components/VimNav-command-leader.test.ts new file mode 100644 index 000000000..57d94b35a --- /dev/null +++ b/packages/app-core/src/components/VimNav-command-leader.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' +import { useToastStore } from '../lib/toast' +import { VimNav } from './VimNav' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// A command's leader sequence runs through VimNav's leader, after every +// built-in leader key, and shows in the which-key hints (#971). + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + platformSync: () => 'darwin', + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false + }) + } + }) +} + +function press(key: string, code: string, init: KeyboardEventInit = {}): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init }) + act(() => { + document.body.dispatchEvent(event) + }) + return event +} + +const archivable = { folder: 'inbox', path: 'inbox/A.md', title: 'A', body: '' } as never + +describe('command leader sequences in VimNav', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + + beforeEach(() => { + installZen() + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + useToastStore.setState({ toasts: [] }) + useStore.setState({ + vimMode: true, + whichKeyHints: true, + whichKeyHintMode: 'sticky', + focusedPanel: 'sidebar', + activeNote: archivable, + commandKeymaps: { 'note.archive': 'Leader x a', 'note.trash': 'Leader x t' } + } as never) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + useStore.setState(originalState, true) + }) + + it('runs the command at the end of its sequence', async () => { + const archiveActive = vi.fn().mockResolvedValue(undefined) + useStore.setState({ archiveActive } as never) + act(() => root.render(createElement(VimNav))) + press(' ', 'Space') + expect(press('x', 'KeyX').defaultPrevented).toBe(true) + press('a', 'KeyA') + await vi.waitFor(() => expect(archiveActive).toHaveBeenCalled()) + }) + + it('lists the sequences in the leader hints, then the keys that follow', () => { + act(() => root.render(createElement(VimNav))) + press(' ', 'Space') + expect(document.body.textContent).toContain('2 commands') + press('x', 'KeyX') + expect(document.body.textContent).toContain('Leader · Commands') + expect(document.body.textContent).toContain('Move Note to Archive') + expect(document.body.textContent).toContain('Move Note to Trash') + }) + + it('leaves a built-in leader key alone even when a sequence would start there', () => { + // A hand-made state the loader would refuse: a command on `Space o x`. + useStore.setState({ commandKeymaps: { 'note.archive': 'Leader o x' } }) + act(() => root.render(createElement(VimNav))) + press(' ', 'Space') + press('o', 'KeyO') + // `Space o` is the buffer switcher, and it won. + expect(useStore.getState().bufferPaletteOpen).toBe(true) + }) + + it('says why when the command cannot run, and runs nothing', async () => { + const restoreActive = vi.fn() + useStore.setState({ restoreActive, commandKeymaps: { 'note.restore': 'Leader z' } } as never) + act(() => root.render(createElement(VimNav))) + press(' ', 'Space') + press('z', 'KeyZ') + await vi.waitFor(() => + expect(useToastStore.getState().toasts.map((t) => t.message)).toEqual([ + 'Restore from Trash only works on a note in the Trash' + ]) + ) + expect(restoreActive).not.toHaveBeenCalled() + }) + + it('is inert with Vim mode off', async () => { + const archiveActive = vi.fn() + useStore.setState({ vimMode: false, archiveActive } as never) + act(() => root.render(createElement(VimNav))) + press(' ', 'Space') + press('x', 'KeyX') + press('a', 'KeyA') + await Promise.resolve() + expect(archiveActive).not.toHaveBeenCalled() + expect(document.body.textContent).not.toContain('Leader') + }) +}) diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 7e839f18e..4ae5db229 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -36,6 +36,7 @@ import { findLeaf } from '../lib/pane-layout' import { boundedIndexCount, clampIndex, moveIndex } from '../lib/index-navigation' import { advanceSequence, + formatKeyToken, getKeymapBinding, getKeymapDisplay, getSequenceTokens, @@ -46,6 +47,8 @@ import { sequenceTokenFromEvent } from '../lib/keymaps' import { toggleWrap, wrapLink } from '../lib/cm-format' +import { commandLeaderHints, stepCommandLeader } from '../lib/command-keymaps' +import { runBoundCommand } from '../lib/command-keymap-dispatch' import { convertTableToDatabase } from '../lib/table-to-database' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, @@ -92,9 +95,14 @@ function escapeForAttr(value: string): string { // Tuned so a deliberate hold-to-pan clears it while a natural tap stays under it. const EXCALIDRAW_LEADER_TAP_MS = 250 +/** Where an armed leader stands: right after the leader, inside the note + * actions or search group, or partway through a command's own sequence. */ +type LeaderStage = 'leader' | 'leader-l' | 'leader-s' | 'leader-cmd' + export function VimNav(): JSX.Element | null { const vimMode = useStore((s) => s.vimMode) const keymapOverrides = useStore((s) => s.keymapOverrides) + const commandKeymaps = useStore((s) => s.commandKeymaps) // All control-flow flags are refs so the handler never stales. const ctrlWPending = useRef(false) const jumpTopPending = useRef(0) @@ -104,7 +112,10 @@ export function VimNav(): JSX.Element | null { // because `g` is shared with gg/gd, so it must NOT be consumed on the `g` press. const gTabPending = useRef(false) const bufferCount = useRef<{ n: number; at: number } | null>(null) - const leaderPending = useRef<'leader' | 'leader-l' | 'leader-s' | null>(null) + const leaderPending = useRef<LeaderStage | null>(null) + // The keys pressed after the leader so far in a command's sequence, while + // the stage is 'leader-cmd' (#971). + const commandLeaderPrefix = useRef<string[]>([]) const ctrlWTimer = useRef<ReturnType<typeof setTimeout>>() const jumpTopTimer = useRef<ReturnType<typeof setTimeout>>() const previousBufferTimer = useRef<ReturnType<typeof setTimeout>>() @@ -119,8 +130,9 @@ export function VimNav(): JSX.Element | null { // Hint mode needs a render (to mount HintOverlay), so it's state. const [hintActive, setHintActive] = useState(false) const [whichKeyState, setWhichKeyState] = useState<{ - stage: 'leader' | 'leader-l' | 'leader-s' + stage: LeaderStage allowEditorActions: boolean + commandPrefix?: string[] } | null>(null) const hintRef = useRef(false) const setHint = useCallback((v: boolean) => { @@ -239,17 +251,20 @@ export function VimNav(): JSX.Element | null { window.zen.getCapabilities().supportsRemoteWorkspace) const resetLeader = useCallback(() => { leaderPending.current = null + commandLeaderPrefix.current = [] if (leaderTimer.current) clearTimeout(leaderTimer.current) setWhichKeyState(null) }, []) const armLeader = useCallback( - (stage: 'leader' | 'leader-l' | 'leader-s', allowEditorActions: boolean) => { + (stage: LeaderStage, allowEditorActions: boolean, commandPrefix: string[] = []) => { leaderPending.current = stage - setWhichKeyState({ stage, allowEditorActions }) + commandLeaderPrefix.current = commandPrefix + setWhichKeyState({ stage, allowEditorActions, commandPrefix }) if (leaderTimer.current) clearTimeout(leaderTimer.current) if (!stickyWhichKeyHints) { leaderTimer.current = setTimeout(() => { leaderPending.current = null + commandLeaderPrefix.current = [] setWhichKeyState(null) }, whichKeyHintTimeoutMs) } @@ -297,6 +312,13 @@ export function VimNav(): JSX.Element | null { : []) ] } + if (whichKeyState.stage === 'leader-cmd') { + return commandLeaderHints(commandKeymaps, whichKeyState.commandPrefix ?? []).map((hint) => ({ + keyLabel: formatKeyToken(hint.key), + label: hint.label, + detail: hint.detail + })) + } if (whichKeyState.stage === 'leader-s') { return [ { @@ -443,6 +465,10 @@ export function VimNav(): JSX.Element | null { detail: 'Open the note-local leader group. `f` formats the current note.' }) } + // The sequences the user gave commands (#971), after the built-in keys. + for (const hint of commandLeaderHints(commandKeymaps, [])) { + items.push({ keyLabel: formatKeyToken(hint.key), label: hint.label, detail: hint.detail }) + } return items // An unbound leader action has no key to press, so it leaves the hints. })().filter((item) => !!item.keyLabel) @@ -1190,10 +1216,46 @@ export function VimNav(): JSX.Element | null { } return } + // A key the user gave a command's sequence (#971). Checked after + // every built-in key above: built-in leader keys always win. + { + const token = sequenceTokenFromEvent(e) + const step = token ? stepCommandLeader(state.commandKeymaps, [], token) : null + if (step) { + e.preventDefault() + e.stopImmediatePropagation() + if (step.kind === 'run') { + resetLeader() + void runBoundCommand(step.id) + } else { + armLeader('leader-cmd', editorNormalMode, step.prefix) + } + return + } + } // Any other key cancels leader and falls through to normal routing. resetLeader() } + if (leaderPending.current === 'leader-cmd') { + const token = sequenceTokenFromEvent(e) + const step = token + ? stepCommandLeader(state.commandKeymaps, commandLeaderPrefix.current, token) + : null + if (step) { + e.preventDefault() + e.stopImmediatePropagation() + if (step.kind === 'run') { + resetLeader() + void runBoundCommand(step.id) + } else { + armLeader('leader-cmd', editorNormalMode, step.prefix) + } + return + } + resetLeader() + } + if (leaderPending.current === 'leader-l') { if (matchesSequenceToken(e, overrides, 'vim.leaderFormatNote') && editorNormalMode) { e.preventDefault() @@ -2611,7 +2673,11 @@ export function VimNav(): JSX.Element | null { const noteActionsDisplay = getKeymapDisplay(keymapOverrides, 'vim.leaderNoteActions') const searchGroupDisplay = getKeymapDisplay(keymapOverrides, 'vim.leaderSearchGroup') const subPrefix = - whichKeyState.stage === 'leader-s' ? searchGroupDisplay : noteActionsDisplay + whichKeyState.stage === 'leader-cmd' + ? (whichKeyState.commandPrefix ?? []).map((key) => formatKeyToken(key)).join(' ') + : whichKeyState.stage === 'leader-s' + ? searchGroupDisplay + : noteActionsDisplay return ( <WhichKeyOverlay prefix={whichKeyState.stage === 'leader' ? leaderDisplay : `${leaderDisplay} ${subPrefix}`} @@ -2620,7 +2686,9 @@ export function VimNav(): JSX.Element | null { ? 'Leader' : whichKeyState.stage === 'leader-s' ? 'Leader · Search' - : 'Leader · Note Actions' + : whichKeyState.stage === 'leader-cmd' + ? 'Leader · Commands' + : 'Leader · Note Actions' } detail={ stickyWhichKeyHints diff --git a/packages/app-core/src/lib/command-keymap-dispatch.test.ts b/packages/app-core/src/lib/command-keymap-dispatch.test.ts new file mode 100644 index 000000000..94ff5e5dd --- /dev/null +++ b/packages/app-core/src/lib/command-keymap-dispatch.test.ts @@ -0,0 +1,262 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + platformSync: () => 'darwin', + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false, + supportsCliInstall: false + }) + } + }) +} + +async function load() { + vi.resetModules() + localStorage.clear() + return { + ...(await import('../store')), + ...(await import('./command-keymap-dispatch')), + ...(await import('./command-keymap-ex')), + ...(await import('./toast')) + } +} + +function chord(init: KeyboardEventInit & { key: string; code: string }): KeyboardEvent { + return new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) +} + +const cmdBackspace = (): KeyboardEvent => + chord({ key: 'Backspace', code: 'Backspace', metaKey: true }) + +const closedState = { + commandKeymaps: { 'note.trash': 'Mod+Backspace' }, + whatsNewOpen: false, + settingsOpen: false, + searchOpen: false, + vaultTextSearchOpen: false, + commandPaletteOpen: false, + bufferPaletteOpen: false, + templatePaletteOpen: false, + embedDrawingPaletteOpen: false, + outlinePaletteOpen: false +} + +const note = (folder: string, path: string) => + ({ folder, path, title: path.split('/').pop()?.replace(/\.md$/, ''), body: '' }) as never + +beforeEach(() => { + vi.restoreAllMocks() + installZen() + document.body.innerHTML = '' +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('a bound chord', () => { + it('runs its command and consumes the key', async () => { + const { handleCommandChordEvent } = await load() + const run = vi.fn() + const event = cmdBackspace() + expect(handleCommandChordEvent(event, () => closedState, run)).toBe(true) + expect(run).toHaveBeenCalledWith('note.trash') + expect(event.defaultPrevented).toBe(true) + }) + + it('leaves every other key alone', async () => { + const { handleCommandChordEvent } = await load() + const run = vi.fn() + const event = chord({ key: 'Backspace', code: 'Backspace' }) + expect(handleCommandChordEvent(event, () => closedState, run)).toBe(false) + expect(event.defaultPrevented).toBe(false) + expect( + handleCommandChordEvent(cmdBackspace(), () => ({ ...closedState, commandKeymaps: {} }), run) + ).toBe(false) + expect(run).not.toHaveBeenCalled() + }) + + it('runs once per press, not once per auto-repeat', async () => { + const { handleCommandChordEvent } = await load() + const run = vi.fn() + const held = chord({ key: 'Backspace', code: 'Backspace', metaKey: true, repeat: true }) + expect(handleCommandChordEvent(held, () => closedState, run)).toBe(true) + expect(held.defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + }) + + it.each([ + 'whatsNewOpen', + 'settingsOpen', + 'searchOpen', + 'vaultTextSearchOpen', + 'commandPaletteOpen', + 'bufferPaletteOpen', + 'templatePaletteOpen', + 'embedDrawingPaletteOpen', + 'outlinePaletteOpen' + ] as const)('stands down while %s, and lets the key through', async (flag) => { + const { handleCommandChordEvent } = await load() + const run = vi.fn() + const event = cmdBackspace() + expect(handleCommandChordEvent(event, () => ({ ...closedState, [flag]: true }), run)).toBe(false) + expect(event.defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + }) + + it.each([ + ['a dialog', '<div aria-modal="true"></div>'], + ['a context menu', '<div data-ctx-menu></div>'], + ['a prompt', '<div data-prompt-modal></div>'], + ['a completion list', '<div class="cm-tooltip-autocomplete"></div>'], + ['hint mode', '<div data-vim-hint-overlay></div>'] + ])('stands down while %s is open', async (_name, html) => { + const { handleCommandChordEvent } = await load() + document.body.innerHTML = html + const run = vi.fn() + expect(handleCommandChordEvent(cmdBackspace(), () => closedState, run)).toBe(false) + expect(run).not.toHaveBeenCalled() + }) + + it('is heard before a listener registered later on window, as VimNav and the editor are', async () => { + const { installCommandKeymapDispatcher, useStore } = await load() + useStore.setState({ commandKeymaps: { 'note.trash': 'Mod+Backspace' } }) + const runSpy = vi.fn() + useStore.setState({ trashActive: runSpy, activeNote: note('inbox', 'inbox/A.md') } as never) + const uninstall = installCommandKeymapDispatcher(() => useStore.getState()) + const later = vi.fn() + window.addEventListener('keydown', later, true) + try { + window.dispatchEvent(cmdBackspace()) + await Promise.resolve() + expect(later).not.toHaveBeenCalled() + await vi.waitFor(() => expect(runSpy).toHaveBeenCalled()) + } finally { + window.removeEventListener('keydown', later, true) + uninstall() + } + }) + + it('only listens in the main window, not quick capture or a floating note', async () => { + const { isMainAppWindow } = await load() + expect(isMainAppWindow('')).toBe(true) + expect(isMainAppWindow('?quickCapture=1')).toBe(false) + expect(isMainAppWindow('?floating=1¬e=inbox%2FA.md')).toBe(false) + expect(isMainAppWindow('?externalFile=%2Ftmp%2Fa.md')).toBe(false) + expect(isMainAppWindow('?exportNote=inbox%2FA.md')).toBe(false) + }) +}) + +describe('runBoundCommand', () => { + it('runs a command that can run here', async () => { + const { runBoundCommand, useStore } = await load() + const trashActive = vi.fn().mockResolvedValue(undefined) + useStore.setState({ activeNote: note('inbox', 'inbox/A.md'), trashActive } as never) + expect(await runBoundCommand('note.trash')).toBe('ran') + expect(trashActive).toHaveBeenCalled() + }) + + it("says why instead of running when the command's when() is false", async () => { + const { runBoundCommand, useStore, useToastStore } = await load() + const restoreActive = vi.fn() + useStore.setState({ activeNote: note('inbox', 'inbox/A.md'), restoreActive } as never) + expect(await runBoundCommand('note.restore')).toBe('unavailable') + expect(restoreActive).not.toHaveBeenCalled() + expect(useToastStore.getState().toasts.map((t) => t.message)).toEqual([ + 'Restore from Trash only works on a note in the Trash' + ]) + }) + + it('names what is missing for the shared guards, and falls back to a generic line', async () => { + const { runBoundCommand, useStore, useToastStore } = await load() + useStore.setState({ activeNote: null, selectedPath: null, closedTabStack: [] } as never) + await runBoundCommand('note.rename') + await runBoundCommand('tab.reopen') + await runBoundCommand('demo.remove') + expect(useToastStore.getState().toasts.map((t) => t.message)).toEqual([ + 'Rename Note… needs an open note', + 'No closed tab to reopen', + "Remove Demo Tour Notes isn't available here" + ]) + }) + + it('opens the palette on its list for a command the palette drills into', async () => { + const { runBoundCommand, useStore } = await load() + expect(await runBoundCommand('ui.themes')).toBe('ran') + expect(useStore.getState().commandPaletteOpen).toBe(true) + expect(useStore.getState().commandPaletteInitialMode).toBe('theme') + }) + + it('does nothing for an id the palette does not have', async () => { + const { runBoundCommand } = await load() + expect(await runBoundCommand('no.such.command')).toBe('missing') + }) +}) + +describe(':bind and :unbind', () => { + it(':bind gives a command a key in the store', async () => { + const { runBindEx, useStore, useToastStore } = await load() + runBindEx('<D-BS> note.trash') + expect(useStore.getState().commandKeymaps['note.trash']).toBe('Mod+Backspace') + runBindEx('<leader>xa note.archive') + expect(useStore.getState().commandKeymaps['note.archive']).toBe('Leader x a') + expect(useToastStore.getState().toasts.map((t) => t.type)).toEqual(['success', 'success']) + // Saved with the other preferences, so it survives a restart. + expect(JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}').commandKeymaps).toEqual({ + 'note.trash': 'Mod+Backspace', + 'note.archive': 'Leader x a' + }) + }) + + it(':bind refuses a key another binding holds, and a built-in leader key', async () => { + const { runBindEx, useStore, useToastStore } = await load() + useStore.setState({ commandKeymaps: { 'note.archive': 'Mod+Backspace' } }) + runBindEx('<D-BS> note.trash') + runBindEx('<leader>tx note.trash') + expect(useStore.getState().commandKeymaps['note.trash']).toBeUndefined() + expect(useToastStore.getState().toasts.map((t) => t.message)).toEqual([ + '⌘Backspace already belongs to the command Move Note to Archive: change that one first', + 'Space t x is refused: the key after the leader belongs to the built-in "Leader: new from template", and built-in leader keys always win' + ]) + }) + + it(':bind says when it takes over a default', async () => { + const { runBindEx, useStore, useToastStore } = await load() + runBindEx('<D-l> note.trash') + expect(useStore.getState().commandKeymaps['note.trash']).toBe('Mod+L') + expect(useToastStore.getState().toasts[0]?.message).toBe( + 'Move Note to Trash is on ⌘L; it wins over the default of Toggle checkbox' + ) + }) + + it(':unbind command takes the key away; :unbind nav.back is still the action', async () => { + const { runUnbindEx, useStore } = await load() + useStore.setState({ commandKeymaps: { 'nav.back': 'Alt+Mod+B', 'note.trash': 'Mod+Backspace' } }) + runUnbindEx('command note.trash') + expect(useStore.getState().commandKeymaps['note.trash']).toBe('') + runUnbindEx('nav.back') + expect(useStore.getState().keymapOverrides['nav.back']).toBe('') + expect(useStore.getState().commandKeymaps['nav.back']).toBe('Alt+Mod+B') + runUnbindEx('command nav.back') + expect(useStore.getState().commandKeymaps['nav.back']).toBe('') + }) + + it('bare :bind and :unbind open Settings on the Keymap page', async () => { + const { runBindEx, runUnbindEx, useStore } = await load() + const { consumeSettingsTarget } = await import('./settings-navigation') + runBindEx('') + expect(useStore.getState().settingsOpen).toBe(true) + expect(consumeSettingsTarget()).toBe('keymaps') + useStore.setState({ settingsOpen: false }) + runUnbindEx('command') + expect(useStore.getState().settingsOpen).toBe(true) + expect(consumeSettingsTarget()).toBe('keymaps') + }) +}) diff --git a/packages/app-core/src/lib/command-keymap-dispatch.ts b/packages/app-core/src/lib/command-keymap-dispatch.ts new file mode 100644 index 000000000..e1962c841 --- /dev/null +++ b/packages/app-core/src/lib/command-keymap-dispatch.ts @@ -0,0 +1,157 @@ +/** + * Running a command from the key the user gave it (#971). + * + * Precedence: a command's key is a binding the user chose, and an explicit + * binding wins over every default. So the chord listener goes on `window`, in + * the capture phase, before any component registers there (App installs it + * at import time, right after the ignored-keys guard): it sees the key before + * VimNav, before App's own shortcut handlers, and before CodeMirror. A + * command on Mod+Backspace trashes the note even from the editor, where + * Mod+Backspace would otherwise delete to the line start; a command on a + * built-in default such as Mod+L takes that key from Toggle checkbox until + * one of them moves. Settings refuses a key another of the user's own + * bindings holds; a config.toml that holds one anyway lets the command win, + * the first in palette order when two commands share a chord. + * + * Leader sequences are the exception: they run through VimNav's leader + * machinery, after every built-in leader key, because built-in leader keys + * always win. + */ +import { buildCommands, type Command } from './commands' +import { commandIdForChordEvent, type CommandKeymaps } from './command-keymaps' +import { isOverlayOrDialogOpen } from './overlay-open' +import { isWorkspaceTransitionPending } from './workspace-transition' +import { useToastStore } from './toast' + +/** The store fields the dispatcher reads. */ +export interface CommandKeymapDispatchState { + commandKeymaps: CommandKeymaps + whatsNewOpen: boolean + settingsOpen: boolean + searchOpen: boolean + vaultTextSearchOpen: boolean + commandPaletteOpen: boolean + bufferPaletteOpen: boolean + templatePaletteOpen: boolean + embedDrawingPaletteOpen: boolean + outlinePaletteOpen: boolean +} + +/** + * True while something else owns the keyboard: What's New, Settings (and + * with it every keymap recorder), a palette, a dialog, a menu, an open + * completion list, or hint mode. The same bail-outs the app's own shortcuts + * honor. The key then does what it would have done without the binding. + */ +export function commandKeymapsStandDown(state: CommandKeymapDispatchState): boolean { + if ( + state.whatsNewOpen || + state.settingsOpen || + state.searchOpen || + state.vaultTextSearchOpen || + state.commandPaletteOpen || + state.bufferPaletteOpen || + state.templatePaletteOpen || + state.embedDrawingPaletteOpen || + state.outlinePaletteOpen + ) { + return true + } + if (isOverlayOrDialogOpen()) return true + if (typeof document === 'undefined') return false + return !!( + document.querySelector('.cm-tooltip-autocomplete') || + document.querySelector('[data-vim-hint-overlay]') + ) +} + +/** The words a bound key shows when its command cannot run where it is. */ +export function unavailableMessage(command: Pick<Command, 'title' | 'unavailableReason'>): string { + return command.unavailableReason?.() ?? `${command.title} isn't available here` +} + +export type BoundCommandResult = 'ran' | 'unavailable' | 'missing' | 'busy' + +/** + * Run a command a key was bound to, rechecking its `when` now. When it says + * no, nothing runs and a short toast says why, since the key, unlike a + * palette row, cannot hide itself. + */ +export async function runBoundCommand(id: string): Promise<BoundCommandResult> { + if (isWorkspaceTransitionPending()) return 'busy' + const command = buildCommands({ includeUnavailable: true }).find((cmd) => cmd.id === id) + if (!command) return 'missing' + if (command.when && !command.when()) { + useToastStore.getState().addToast(unavailableMessage(command), 'info') + return 'unavailable' + } + try { + await command.run() + } catch (err) { + console.error('bound command failed', id, err) + } + return 'ran' +} + +/** + * The capture-phase keydown handler. Returns true when the key was a + * command's and has been consumed. + */ +export function handleCommandChordEvent( + event: KeyboardEvent, + getState: () => CommandKeymapDispatchState, + run: (id: string) => unknown = runBoundCommand +): boolean { + if (event.isComposing) return false + const state = getState() + const id = commandIdForChordEvent(event, state.commandKeymaps) + if (!id) return false + if (commandKeymapsStandDown(state)) return false + event.preventDefault() + event.stopImmediatePropagation() + // A held chord repeats; the command runs once per press, not once per + // repeat (holding Mod+Backspace must not trash note after note). + if (event.repeat) return true + void run(id) + return true +} + +/** + * Whether this renderer is the main window. The same bundle draws the quick + * capture window, floating note windows and the external-file window, which + * keep their own small set of keys: a command bound to Mod+Backspace must not + * trash a note from behind a quick capture being typed. + */ +export function isMainAppWindow(search: string): boolean { + const params = new URLSearchParams(search) + return ( + params.get('quickCapture') !== '1' && + params.get('floating') !== '1' && + params.get('externalFile') === null && + params.get('exportNote') === null + ) +} + +let installed: (() => void) | null = null + +/** + * Install the chord listener once on `window`, capture phase. Must run before + * any component registers its own window listener (see the top of this + * file), so App calls it at import time. Reads the live store on each key. + */ +export function installCommandKeymapDispatcher( + getState: () => CommandKeymapDispatchState +): () => void { + if (installed) return installed + if (typeof window === 'undefined') return () => {} + if (!isMainAppWindow(window.location.search)) return () => {} + const listener = (event: KeyboardEvent): void => { + handleCommandChordEvent(event, getState) + } + window.addEventListener('keydown', listener, true) + installed = () => { + window.removeEventListener('keydown', listener, true) + installed = null + } + return installed +} diff --git a/packages/app-core/src/lib/command-keymap-ex.ts b/packages/app-core/src/lib/command-keymap-ex.ts new file mode 100644 index 000000000..fbaa8f7e5 --- /dev/null +++ b/packages/app-core/src/lib/command-keymap-ex.ts @@ -0,0 +1,123 @@ +/** + * `:bind` and `:unbind` on the ex line (#971), apart from the editor so they + * can be tested without one. + * + * :bind <keys> <command.id> give a command a key + * :unbind <action.id> take a built-in action's key away + * :unbind command <command.id> take a command's key away + * + * Keys are written as Settings and config.toml write them (`Mod+Backspace`, + * `Space x t`) or in Vim's notation (`<C-BS>`, `<D-BS>`, `<leader>xt`, + * `<Space>xt`). The command id is always the last word. + */ +import { useStore } from '../store' +import { useToastStore } from './toast' +import { requestSettingsTarget } from './settings-navigation' +import { getKeymapDefinition, getKeymapDisplay, UNBOUND_BINDING } from './keymaps' +import { COMMAND_CATALOG } from '@shared/commands-catalog' +import { + commandBindingKind, + findCommandBindingConflicts, + formatCommandBinding, + parseBindExArgs, + parseUnbindExArgs +} from './command-keymaps' + +function openKeymapSettings(): void { + requestSettingsTarget('keymaps') + useStore.getState().setSettingsOpen(true) +} + +function toast(message: string, type: 'success' | 'info' = 'info'): void { + useToastStore.getState().addToast(message, type) +} + +function commandTitle(id: string): string { + return COMMAND_CATALOG.find((entry) => entry.id === id)?.title ?? id +} + +/** `:bind <keys> <command.id>`. Bare, or with an id and no keys, it opens + * Settings → Keymap, where the Commands group lists every id. */ +export function runBindEx(argString: string): void { + const state = useStore.getState() + const parsed = parseBindExArgs(argString, state.keymapOverrides) + if (parsed.kind === 'open') { + openKeymapSettings() + return + } + if (parsed.kind === 'unknown') { + toast(`No command is called "${parsed.id}"`) + openKeymapSettings() + return + } + if (parsed.kind === 'invalid') { + toast(`Can't bind ${commandTitle(parsed.id)}: ${parsed.error}`) + return + } + const { id, binding } = parsed + const display = formatCommandBinding(binding, state.keymapOverrides) + const { blocking, shadows } = findCommandBindingConflicts(id, binding, { + overrides: state.keymapOverrides, + commandKeymaps: state.commandKeymaps + }) + const sequence = commandBindingKind(binding) === 'sequence' + if (blocking.length > 0) { + const [first] = blocking + toast( + sequence && first.kind === 'action' + ? `${display} is refused: the key after the leader belongs to the built-in "${first.title}", and built-in leader keys always win` + : `${display} already belongs to ${first.kind === 'command' ? `the command ${first.title}` : first.title}: change that one first` + ) + return + } + state.setCommandKeymapBinding(id, binding) + const notes: string[] = [] + if (shadows.length > 0) { + notes.push(`it wins over the default of ${shadows.map((s) => s.title).join(', ')}`) + } + if (sequence && !state.vimMode) notes.push('it works once Vim mode is on') + toast( + `${commandTitle(id)} is on ${display}${notes.length > 0 ? `; ${notes.join('; ')}` : ''}`, + 'success' + ) +} + +/** `:unbind <action.id>` and `:unbind command <command.id>`. Bare, or with an + * id nothing knows, it opens Settings → Keymap instead of guessing. */ +export function runUnbindEx(argString: string): void { + const state = useStore.getState() + const parsed = parseUnbindExArgs(argString) + if (parsed.kind === 'open') { + openKeymapSettings() + return + } + if (parsed.kind === 'unknown') { + toast( + parsed.command + ? `No command is called "${parsed.id}"` + : `No keymap action is called "${parsed.id}"` + ) + openKeymapSettings() + return + } + if (parsed.kind === 'action') { + const definition = getKeymapDefinition(parsed.id) + const before = getKeymapDisplay(state.keymapOverrides, definition.id) + state.setKeymapBinding(definition.id, UNBOUND_BINDING) + toast( + before + ? `Unbound ${definition.title} (was ${before})` + : `${definition.title} is already unbound`, + 'success' + ) + return + } + const title = commandTitle(parsed.id) + const before = formatCommandBinding(state.commandKeymaps[parsed.id], state.keymapOverrides) + if (!before) { + toast(`${title} already has no key`, 'success') + return + } + state.setCommandKeymapBinding(parsed.id, UNBOUND_BINDING) + toast(`Unbound ${title} (was ${before})`, 'success') +} diff --git a/packages/app-core/src/lib/command-keymaps.test.ts b/packages/app-core/src/lib/command-keymaps.test.ts new file mode 100644 index 000000000..f414ff475 --- /dev/null +++ b/packages/app-core/src/lib/command-keymaps.test.ts @@ -0,0 +1,411 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + commandBindingHint, + commandIdForChordEvent, + commandLeaderHints, + commandRecorderStep, + findActionCommandConflicts, + findCommandBindingConflicts, + findLeaderCollision, + formatCommandBinding, + normalizeCommandKeymaps, + parseBindExArgs, + parseCommandBinding, + parseUnbindExArgs, + parseVimKeyNotation, + stepCommandLeader +} from './command-keymaps' +import { sequenceTokenFromEvent, shortcutBindingFromEvent } from './keymaps' + +// Every binding below is read on one platform at a time: `Mod` means Cmd on a +// Mac and Ctrl elsewhere, and the canonical forms differ accordingly. +type Host = typeof globalThis & { window?: { zen?: { platformSync?: () => NodeJS.Platform } } } +let previousWindow: Host['window'] + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(globalThis, 'window', { + value: { zen: { platformSync: () => platform } }, + configurable: true + }) +} + +beforeEach(() => { + previousWindow = (globalThis as Host).window + setPlatform('darwin') + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + if (previousWindow === undefined) Reflect.deleteProperty(globalThis, 'window') + else Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true }) + vi.restoreAllMocks() +}) + +function key(init: { + key: string + code: string + ctrlKey?: boolean + metaKey?: boolean + altKey?: boolean + shiftKey?: boolean + repeat?: boolean +}): KeyboardEvent { + return { + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false, + ...init + } as KeyboardEvent +} + +const recorderContext = { + leaderToken: 'Space', + shortcutFromEvent: shortcutBindingFromEvent, + tokenFromEvent: sequenceTokenFromEvent +} + +describe('parseCommandBinding', () => { + it('reads a chord in the keymap table format and canonicalizes it', () => { + expect(parseCommandBinding('Mod+Backspace', {})).toEqual({ ok: true, binding: 'Mod+Backspace' }) + expect(parseCommandBinding('Cmd+Backspace', {})).toEqual({ ok: true, binding: 'Mod+Backspace' }) + expect(parseCommandBinding('cmd+shift+k', {})).toEqual({ ok: true, binding: 'Shift+Mod+K' }) + expect(parseCommandBinding('F5', {})).toEqual({ ok: true, binding: 'F5' }) + }) + + it('refuses a chord that would type text', () => { + expect(parseCommandBinding('x', {}).ok).toBe(false) + expect(parseCommandBinding('Shift+X', {}).ok).toBe(false) + expect(parseCommandBinding('Backspace', {}).ok).toBe(false) + }) + + it('reads a leader sequence however it is spelled, and keeps the leader symbolic', () => { + expect(parseCommandBinding('Leader x t', {})).toEqual({ ok: true, binding: 'Leader x t' }) + expect(parseCommandBinding('<leader> x t', {})).toEqual({ ok: true, binding: 'Leader x t' }) + // Space is the leader by default, so spelling it out means the leader. + expect(parseCommandBinding('Space x t', {})).toEqual({ ok: true, binding: 'Leader x t' }) + // With the leader moved to `,`, Space no longer starts a sequence. + expect(parseCommandBinding('Space x t', { 'vim.leaderPrefix': ',' }).ok).toBe(false) + expect(parseCommandBinding(', x t', { 'vim.leaderPrefix': ',' })).toEqual({ + ok: true, + binding: 'Leader x t' + }) + }) + + it('refuses sequences it cannot press', () => { + expect(parseCommandBinding('Leader', {}).ok).toBe(false) + expect(parseCommandBinding('Leader a b c d', {}).ok).toBe(false) + expect(parseCommandBinding('Leader xt', {}).ok).toBe(false) + expect(parseCommandBinding('Leader Ctrl+X', {}).ok).toBe(false) + expect(parseCommandBinding('g g', {}).ok).toBe(false) + }) + + it('reads "" as a deliberate unbind', () => { + expect(parseCommandBinding('', {})).toEqual({ ok: true, binding: '' }) + }) +}) + +describe('normalizeCommandKeymaps', () => { + it('keeps bindable ids, drops unknown ones and broken bindings, keeps ""', () => { + expect( + normalizeCommandKeymaps({ + 'note.trash': 'Cmd+Backspace', + 'note.archive': 'Leader x a', + 'note.restore': '', + 'not.a.command': 'Mod+K', + 'workflow.run.reading-log': 'Mod+J', + 'note.favorite': 'q', + 'note.unarchive': 42 + }) + ).toEqual({ 'note.archive': 'Leader x a', 'note.trash': 'Mod+Backspace', 'note.restore': '' }) + }) + + it('reads nav.back as the Go Back command: the command map is its own namespace', () => { + expect(normalizeCommandKeymaps({ 'nav.back': 'Alt+Mod+B' })).toEqual({ 'nav.back': 'Alt+Mod+B' }) + }) + + it('refuses a sequence that starts on a built-in leader key', () => { + // `Space t` is the template picker, so `Space t x` could never be reached. + expect(normalizeCommandKeymaps({ 'note.trash': 'Leader t x' })).toEqual({}) + // A built-in group counts too: `Space l` is the note actions. + expect(normalizeCommandKeymaps({ 'note.trash': 'Leader l x' })).toEqual({}) + // Once the user moves the template picker, `t` is free. + expect( + normalizeCommandKeymaps({ 'note.trash': 'Leader t x' }, { 'vim.leaderTemplatePicker': 'T' }) + ).toEqual({ 'note.trash': 'Leader t x' }) + }) + + it('writes entries in palette order, whatever order they came in', () => { + const out = normalizeCommandKeymaps({ 'note.trash': 'F6', 'note.new.quick': 'F5' }) + expect(Object.keys(out)).toEqual(['note.new.quick', 'note.trash']) + }) + + it('returns nothing for anything that is not a map', () => { + expect(normalizeCommandKeymaps(null)).toEqual({}) + expect(normalizeCommandKeymaps('Mod+K')).toEqual({}) + expect(normalizeCommandKeymaps(['Mod+K'])).toEqual({}) + }) +}) + +describe('conflicts between commands and actions', () => { + it('lets a command take a built-in default, and says whose', () => { + // Mod+L is Toggle checkbox's default: allowed, a shadow, not a block. + const { blocking, shadows } = findCommandBindingConflicts('note.trash', 'Mod+L', { + overrides: {}, + commandKeymaps: {} + }) + expect(blocking).toEqual([]) + expect(shadows.map((s) => s.id)).toEqual(['editor.toggleCheckbox']) + }) + + it('refuses a key an action override already holds', () => { + const { blocking } = findCommandBindingConflicts('note.trash', 'Alt+Mod+Y', { + overrides: { 'global.zoomIn': 'Alt+Mod+Y' }, + commandKeymaps: {} + }) + expect(blocking).toEqual([ + { kind: 'action', id: 'global.zoomIn', title: 'Zoom in', user: true } + ]) + }) + + it('refuses a key another command holds, both ways', () => { + const commandKeymaps = { 'note.archive': 'Mod+Backspace' } + const fromTrash = findCommandBindingConflicts('note.trash', 'Mod+Backspace', { + overrides: {}, + commandKeymaps + }) + expect(fromTrash.blocking.map((b) => b.id)).toEqual(['note.archive']) + const fromArchive = findCommandBindingConflicts('note.archive', 'Mod+Backspace', { + overrides: {}, + commandKeymaps: { 'note.trash': 'Mod+Backspace' } + }) + expect(fromArchive.blocking.map((b) => b.id)).toEqual(['note.trash']) + // Its own key is not a conflict with itself. + expect( + findCommandBindingConflicts('note.archive', 'Mod+Backspace', { overrides: {}, commandKeymaps }) + .blocking + ).toEqual([]) + }) + + it("catches Vim's reserved chords by what is pressed, not how it is spelled", () => { + setPlatform('linux') + // On Linux Mod+W is Ctrl+W: both Close active tab and Vim's pane prefix. + const { shadows } = findCommandBindingConflicts('note.trash', 'Mod+W', { + overrides: {}, + commandKeymaps: {} + }) + expect(shadows.map((s) => s.id).sort()).toEqual(['global.closeActiveTab', 'vim.panePrefix']) + }) + + it('refuses a sequence that overlaps another command sequence or a leader key', () => { + const ctx = { overrides: {}, commandKeymaps: { 'note.trash': 'Leader x t' } } + expect(findCommandBindingConflicts('note.archive', 'Leader x', ctx).blocking.map((b) => b.id)).toEqual([ + 'note.trash' + ]) + expect( + findCommandBindingConflicts('note.archive', 'Leader x t a', ctx).blocking.map((b) => b.id) + ).toEqual(['note.trash']) + expect(findCommandBindingConflicts('note.archive', 'Leader x a', ctx).blocking).toEqual([]) + const leader = findCommandBindingConflicts('note.archive', 'Leader t a', ctx).blocking + expect(leader.map((b) => b.id)).toEqual(['vim.leaderTemplatePicker']) + expect(findLeaderCollision('Leader t a', {})?.id).toBe('vim.leaderTemplatePicker') + }) + + it('finds the same clash from the action side', () => { + const commandKeymaps = { 'note.trash': 'Alt+Mod+Y', 'note.archive': 'Leader x a' } + expect( + findActionCommandConflicts('global.zoomIn', 'Alt+Mod+Y', { overrides: {}, commandKeymaps }).map( + (c) => c.id + ) + ).toEqual(['note.trash']) + // Moving a built-in leader key onto `x` takes the archive sequence's start. + expect( + findActionCommandConflicts('vim.leaderDailyNote', 'x', { overrides: {}, commandKeymaps }).map( + (c) => c.id + ) + ).toEqual(['note.archive']) + expect( + findActionCommandConflicts('global.zoomIn', '', { overrides: {}, commandKeymaps }) + ).toEqual([]) + }) +}) + +describe('matching keys', () => { + it('finds the command on a chord, first in palette order', () => { + const event = key({ key: 'Backspace', code: 'Backspace', metaKey: true }) + expect(commandIdForChordEvent(event, { 'note.trash': 'Mod+Backspace' })).toBe('note.trash') + expect( + commandIdForChordEvent(event, { 'note.trash': 'Mod+Backspace', 'note.new.quick': 'Mod+Backspace' }) + ).toBe('note.new.quick') + expect(commandIdForChordEvent(key({ key: 'x', code: 'KeyX' }), { 'note.trash': 'Mod+Backspace' })).toBe( + null + ) + expect(commandIdForChordEvent(event, { 'note.trash': '' })).toBe(null) + // A sequence is never a chord. + expect(commandIdForChordEvent(event, { 'note.trash': 'Leader x t' })).toBe(null) + }) + + it('walks a leader sequence one key at a time', () => { + const keymaps = { 'note.trash': 'Leader x t', 'note.archive': 'Leader x a', 'note.favorite': 'Leader z' } + expect(stepCommandLeader(keymaps, [], 'x')).toEqual({ kind: 'more', prefix: ['x'] }) + expect(stepCommandLeader(keymaps, ['x'], 't')).toEqual({ kind: 'run', id: 'note.trash' }) + expect(stepCommandLeader(keymaps, ['x'], 'a')).toEqual({ kind: 'run', id: 'note.archive' }) + expect(stepCommandLeader(keymaps, [], 'z')).toEqual({ kind: 'run', id: 'note.favorite' }) + expect(stepCommandLeader(keymaps, ['x'], 'q')).toBe(null) + expect(stepCommandLeader(keymaps, [], 'q')).toBe(null) + }) + + it('names the next keys for the which-key hints', () => { + const keymaps = { 'note.trash': 'Leader x t', 'note.archive': 'Leader x a', 'note.favorite': 'Leader z' } + expect(commandLeaderHints(keymaps, []).map((h) => [h.key, h.label])).toEqual([ + ['z', 'Toggle Note Favorite'], + ['x', '2 commands'] + ]) + expect(commandLeaderHints(keymaps, ['x']).map((h) => [h.key, h.label])).toEqual([ + ['a', 'Move Note to Archive'], + ['t', 'Move Note to Trash'] + ]) + }) +}) + +describe('display', () => { + it('formats chords and sequences, the leader as the user has it', () => { + expect(formatCommandBinding('Mod+Backspace', {})).toBe('⌘Backspace') + expect(formatCommandBinding('Leader x t', {})).toBe('Space x t') + expect(formatCommandBinding('Leader x t', { 'vim.leaderPrefix': ',' })).toBe(', x t') + expect(formatCommandBinding('', {})).toBe('') + }) + + it('hides a sequence where it names a Vim key nobody can press', () => { + expect(commandBindingHint('Leader x t', { overrides: {}, vimMode: true })).toBe('Space x t') + expect(commandBindingHint('Leader x t', { overrides: {}, vimMode: false })).toBe('') + expect(commandBindingHint('Mod+Backspace', { overrides: {}, vimMode: false })).toBe('⌘Backspace') + }) +}) + +describe('the command recorder', () => { + it('records a chord, saves on Enter and cancels on Escape', () => { + expect( + commandRecorderStep('', key({ key: 'Backspace', code: 'Backspace', metaKey: true }), recorderContext) + ).toEqual({ type: 'set', binding: 'Mod+Backspace' }) + expect(commandRecorderStep('Mod+Backspace', key({ key: 'Enter', code: 'Enter' }), recorderContext)).toEqual({ + type: 'save' + }) + expect(commandRecorderStep('Mod+Backspace', key({ key: 'Escape', code: 'Escape' }), recorderContext)).toEqual({ + type: 'cancel' + }) + expect( + commandRecorderStep('Mod+Backspace', key({ key: 'Backspace', code: 'Backspace' }), recorderContext) + ).toEqual({ type: 'set', binding: '' }) + }) + + it('starts a sequence on the leader and takes back one key at a time', () => { + const space = key({ key: ' ', code: 'Space' }) + expect(commandRecorderStep('', space, recorderContext)).toEqual({ type: 'set', binding: 'Leader' }) + expect(commandRecorderStep('Leader', key({ key: 'x', code: 'KeyX' }), recorderContext)).toEqual({ + type: 'set', + binding: 'Leader x' + }) + expect( + commandRecorderStep('Leader x', key({ key: 'T', code: 'KeyT', shiftKey: true }), recorderContext) + ).toEqual({ type: 'set', binding: 'Leader x T' }) + expect(commandRecorderStep('Leader x T', key({ key: 'Backspace', code: 'Backspace' }), recorderContext)).toEqual({ + type: 'set', + binding: 'Leader x' + }) + expect( + commandRecorderStep('Leader a b c', key({ key: 'd', code: 'KeyD' }), recorderContext).type + ).toBe('ignore') + }) + + it('turns away a bare letter with a hint, and lets modifiers and Tab through', () => { + const letter = commandRecorderStep('', key({ key: 'x', code: 'KeyX' }), recorderContext) + expect(letter.type).toBe('ignore') + expect(letter.type === 'ignore' && letter.hint).toBeTruthy() + expect(commandRecorderStep('', key({ key: 'Meta', code: 'MetaLeft', metaKey: true }), recorderContext)).toEqual({ + type: 'ignore' + }) + expect(commandRecorderStep('', key({ key: 'Tab', code: 'Tab' }), recorderContext)).toEqual({ type: 'ignore' }) + }) +}) + +describe(':bind and :unbind', () => { + it("reads Vim's key notation", () => { + expect(parseVimKeyNotation('<C-BS>')).toEqual(['Ctrl+Backspace']) + expect(parseVimKeyNotation('<D-BS>')).toEqual(['Meta+Backspace']) + expect(parseVimKeyNotation('<M-x>')).toEqual(['Alt+x']) + expect(parseVimKeyNotation('<Space>xt')).toEqual(['Space', 'x', 't']) + expect(parseVimKeyNotation('<leader>xt')).toEqual(['Leader', 'x', 't']) + expect(parseVimKeyNotation('<F5>')).toEqual(['F5']) + expect(parseVimKeyNotation('<C-->')).toEqual(['Ctrl+-']) + expect(parseVimKeyNotation('<Q-x>')).toBe(null) + expect(parseVimKeyNotation('<C-x')).toBe(null) + }) + + it('binds a chord or a sequence, the command id last', () => { + expect(parseBindExArgs('<D-BS> note.trash', {})).toEqual({ + kind: 'bind', + id: 'note.trash', + binding: 'Mod+Backspace' + }) + expect(parseBindExArgs('<C-BS> note.trash', {})).toEqual({ + kind: 'bind', + id: 'note.trash', + binding: 'Ctrl+Backspace' + }) + expect(parseBindExArgs('<Space>xt note.trash', {})).toEqual({ + kind: 'bind', + id: 'note.trash', + binding: 'Leader x t' + }) + expect(parseBindExArgs('<leader>xa note.archive', {})).toEqual({ + kind: 'bind', + id: 'note.archive', + binding: 'Leader x a' + }) + expect(parseBindExArgs('Mod+Backspace note.trash', {})).toEqual({ + kind: 'bind', + id: 'note.trash', + binding: 'Mod+Backspace' + }) + expect(parseBindExArgs('Space x t note.trash', {})).toEqual({ + kind: 'bind', + id: 'note.trash', + binding: 'Leader x t' + }) + }) + + it('reads <C-…> as Ctrl, which is Mod off the Mac', () => { + setPlatform('linux') + expect(parseBindExArgs('<C-BS> note.trash', {})).toEqual({ + kind: 'bind', + id: 'note.trash', + binding: 'Mod+Backspace' + }) + }) + + it('opens Settings when there is nothing to bind, and names what it cannot read', () => { + expect(parseBindExArgs('', {})).toEqual({ kind: 'open' }) + expect(parseBindExArgs('note.trash', {})).toEqual({ kind: 'open' }) + expect(parseBindExArgs('<C-x> no.such.command', {})).toEqual({ kind: 'unknown', id: 'no.such.command' }) + expect(parseBindExArgs('x note.trash', {}).kind).toBe('invalid') + expect(parseBindExArgs('<Q-x> note.trash', {}).kind).toBe('invalid') + }) + + it('keeps :unbind <action.id> and adds :unbind command <command.id>', () => { + expect(parseUnbindExArgs('global.zoomIn')).toEqual({ kind: 'action', id: 'global.zoomIn' }) + expect(parseUnbindExArgs('command note.trash')).toEqual({ kind: 'command', id: 'note.trash' }) + // A bare command id that is no action's id: the command. + expect(parseUnbindExArgs('note.trash')).toEqual({ kind: 'command', id: 'note.trash' }) + expect(parseUnbindExArgs('')).toEqual({ kind: 'open' }) + expect(parseUnbindExArgs('command')).toEqual({ kind: 'open' }) + expect(parseUnbindExArgs('nope')).toEqual({ kind: 'unknown', id: 'nope', command: false }) + expect(parseUnbindExArgs('command nope')).toEqual({ kind: 'unknown', id: 'nope', command: true }) + }) + + it('is not ambiguous for nav.back, the id that is both', () => { + expect(parseUnbindExArgs('nav.back')).toEqual({ kind: 'action', id: 'nav.back' }) + expect(parseUnbindExArgs('command nav.back')).toEqual({ kind: 'command', id: 'nav.back' }) + }) +}) diff --git a/packages/app-core/src/lib/command-keymaps.ts b/packages/app-core/src/lib/command-keymaps.ts new file mode 100644 index 000000000..4aa103a46 --- /dev/null +++ b/packages/app-core/src/lib/command-keymaps.ts @@ -0,0 +1,726 @@ +/** + * Keys for command palette commands (#971). + * + * The keymap table (`keymaps.ts`) binds the app's built-in ACTIONS: a fixed + * catalog, each with a shipped default. Commands are the palette's rows, and + * there are more of them than actions, so they get their own namespace: a + * map from command id to binding, stored apart from `keymapOverrides`. One + * id lives in both worlds (`nav.back` is the `h` back-out action and the Go + * Back command), which is exactly why the two maps never share keys. + * + * Every command starts unbound. A binding is one of two shapes: + * + * - a chord, in the keymap table's shortcut format (`Mod+Backspace`, + * `Shift+Mod+K`, `F5`). It needs Ctrl, Alt or Cmd, or a function key: a + * bare letter would eat typing. + * - a leader sequence, `Leader x t`: the leader key, then one to three + * single characters. The leader stays symbolic, so the sequence follows a + * rebound leader. Sequences run through Vim mode's leader machinery + * (VimNav) and are inert with Vim mode off. + * + * As in the keymap table, `""` is a deliberate unbind. + */ +import { COMMAND_CATALOG, COMMAND_LEADER_TOKEN, isBindableCommandId } from '@shared/commands-catalog' +import { + formatKeyToken, + getKeymapBinding, + getKeymapDefinitions, + getKeymapDisplay, + getSequenceTokens, + isMacPlatform, + isUnboundBinding, + matchesShortcutBinding, + normalizeSequenceToken, + normalizeShortcutBinding, + UNBOUND_BINDING, + type KeymapDefinition, + type KeymapId, + type KeymapOverrides +} from './keymaps' + +export type CommandKeymaps = Record<string, string> + +export { COMMAND_LEADER_TOKEN, isBindableCommandId } + +/** How many keys may follow the leader in a command's sequence. */ +export const MAX_COMMAND_SEQUENCE_KEYS = 3 + +export type CommandBindingKind = 'shortcut' | 'sequence' + +/** The kind of a stored binding, or null for an unbound one. */ +export function commandBindingKind(binding: string | null | undefined): CommandBindingKind | null { + if (typeof binding !== 'string' || isUnboundBinding(binding)) return null + return binding.split(/\s+/)[0] === COMMAND_LEADER_TOKEN ? 'sequence' : 'shortcut' +} + +/** The keys after the leader in a sequence binding, else an empty list. */ +export function commandSequenceKeys(binding: string | null | undefined): string[] { + if (commandBindingKind(binding) !== 'sequence') return [] + return (binding ?? '').split(/\s+/).filter(Boolean).slice(1) +} + +/** The leader key's token under these overrides, or "" while it is unbound. */ +export function leaderTokenOf(overrides: KeymapOverrides | null | undefined): string { + return getSequenceTokens(overrides, 'vim.leaderPrefix')[0] ?? UNBOUND_BINDING +} + +const CHORD_MODIFIERS = new Set(['Ctrl', 'Alt', 'Mod', 'Meta']) +const FUNCTION_KEY = /^F([1-9]|1[0-9]|2[0-4])$/ + +/** + * Whether a normalized shortcut can stand as a command's chord: one with + * Ctrl, Alt or Cmd (Shift alone types a capital), or a function key. + */ +export function isValidCommandChord(binding: string): boolean { + const parts = binding.split('+') + const key = parts.pop() ?? '' + if (!key) return false + if (FUNCTION_KEY.test(key)) return true + return parts.some((part) => CHORD_MODIFIERS.has(part)) +} + +/** A key that may follow the leader: one visible character, no modifiers. */ +function isSequenceKey(token: string): boolean { + return token.length === 1 && token > ' ' && token <= '~' +} + +function isLeaderWord(token: string): boolean { + return /^(<leader>|leader)$/i.test(token) +} + +export type CommandBindingParse = + | { ok: true; binding: string } + | { ok: false; error: string } + +/** + * Read a binding as Settings, config.toml or `:bind` writes it and return + * its canonical form. Accepts `Mod+Backspace` and the other shortcut + * spellings the keymap table takes, `Leader x t` (also `<leader> x t`), and + * a sequence that spells the leader out (`Space x t` while Space is the + * leader). The canonical form is what is stored and written to the file. + */ +export function parseCommandBinding( + input: string, + overrides: KeymapOverrides | null | undefined +): CommandBindingParse { + const tokens = input.trim().split(/\s+/).filter(Boolean) + if (tokens.length === 0) return { ok: true, binding: UNBOUND_BINDING } + const leader = leaderTokenOf(overrides) + const first = tokens[0] + const startsWithLeader = + isLeaderWord(first) || (!!leader && normalizeSequenceToken(first) === leader && tokens.length > 1) + if (startsWithLeader) { + const keys = tokens.slice(1) + if (keys.length === 0) { + return { ok: false, error: 'A leader sequence needs at least one key after the leader' } + } + if (keys.length > MAX_COMMAND_SEQUENCE_KEYS) { + return { + ok: false, + error: `A leader sequence takes at most ${MAX_COMMAND_SEQUENCE_KEYS} keys after the leader` + } + } + for (const key of keys) { + if (!isSequenceKey(key)) { + return { + ok: false, + error: `"${key}" can't follow the leader: use single keys such as x or T, separated by spaces` + } + } + } + return { ok: true, binding: [COMMAND_LEADER_TOKEN, ...keys].join(' ') } + } + if (tokens.length > 1) { + return { + ok: false, + error: 'A key sequence for a command starts with the leader key, as in "Leader x t"' + } + } + const chord = normalizeShortcutBinding(first) + if (!chord) return { ok: false, error: `"${first}" is not a key ZenNotes can read` } + if (!isValidCommandChord(chord)) { + return { + ok: false, + error: `${formatKeyToken(chord)} would type text: a command's key needs Ctrl, Alt or ${isMacPlatform() ? 'Cmd' : 'Meta'}, or a function key` + } + } + return { ok: true, binding: chord } +} + +/* ------------------------------------------------------------------------ */ +/* Built-in leader keys */ +/* ------------------------------------------------------------------------ */ + +// The leader actions that sit one level down, after Leader l (note actions) +// or Leader s (search). Everything else in the leader scope is a key right +// after the leader, which is the level a command's sequence starts on. +const LEADER_SUBGROUP_IDS: ReadonlySet<KeymapId> = new Set<KeymapId>([ + 'vim.leaderFormatNote', + 'vim.leaderCopyMarkdown', + 'vim.leaderToggleFavorite', + 'vim.leaderTableToDatabase', + 'vim.leaderNoteCover', + 'vim.leaderNoteCoverUnsplash', + 'vim.leaderSearchVaultText' +]) + +/** The built-in keys that follow the leader directly, under these overrides. */ +export function builtinLeaderKeys( + overrides: KeymapOverrides | null | undefined +): Array<{ key: string; definition: KeymapDefinition }> { + const out: Array<{ key: string; definition: KeymapDefinition }> = [] + for (const definition of getKeymapDefinitions()) { + if (definition.scope !== 'leader') continue + if (definition.id === 'vim.leaderPrefix' || LEADER_SUBGROUP_IDS.has(definition.id)) continue + const key = getSequenceTokens(overrides, definition.id)[0] + if (key) out.push({ key, definition }) + } + return out +} + +/** + * The built-in leader key a sequence would run into, or null. Built-in leader + * keys always win, so a command sequence may not start with one: `Space t` + * is the template picker, which makes `Space t x` unreachable and refused. + * (A built-in group such as `Space l` counts as taken too: its keys are its + * own.) + */ +export function findLeaderCollision( + binding: string, + overrides: KeymapOverrides | null | undefined +): KeymapDefinition | null { + const keys = commandSequenceKeys(binding) + if (keys.length === 0) return null + return builtinLeaderKeys(overrides).find((entry) => entry.key === keys[0])?.definition ?? null +} + +/* ------------------------------------------------------------------------ */ +/* Normalizing a stored map */ +/* ------------------------------------------------------------------------ */ + +/** + * Validate an untrusted command keymap (localStorage, config.toml). Unknown + * ids, bindings that do not parse, and sequences that collide with a + * built-in leader key are dropped; `""` survives as a deliberate unbind. + * Entries come out in palette order, so the written file is stable. + */ +export function normalizeCommandKeymaps( + input: unknown, + overrides?: KeymapOverrides | null +): CommandKeymaps { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {} + const raw = input as Record<string, unknown> + const out: CommandKeymaps = {} + for (const entry of COMMAND_CATALOG) { + const value = raw[entry.id] + if (typeof value !== 'string') continue + const parsed = parseCommandBinding(value, overrides) + if (!parsed.ok) { + console.warn(`[keymaps.commands] ignoring "${entry.id}" = "${value}": ${parsed.error}`) + continue + } + const collision = findLeaderCollision(parsed.binding, overrides) + if (collision) { + console.warn( + `[keymaps.commands] ignoring "${entry.id}" = "${value}": the built-in "${collision.title}" holds that leader key` + ) + continue + } + out[entry.id] = parsed.binding + } + return out +} + +/* ------------------------------------------------------------------------ */ +/* Conflicts */ +/* ------------------------------------------------------------------------ */ + +/** + * What a chord physically is on this platform: its modifier keys and its key. + * The shortcut format writes the platform's primary modifier as `Mod`, while + * Vim's sequence tokens write `Ctrl+W`; comparing physical chords is what + * catches a command on Ctrl+W colliding with Vim's pane prefix on Linux. + */ +function chordSignature(binding: string, format: 'shortcut' | 'token'): string | null { + if (!binding) return null + const parts = binding.split('+') + const rawKey = parts.pop() + if (!rawKey) return null + const mac = isMacPlatform() + const mods = new Set<string>() + for (const part of parts) { + if (part === 'Mod') mods.add(mac ? 'meta' : 'ctrl') + else if (part === 'Meta') mods.add('meta') + else if (part === 'Ctrl') mods.add('ctrl') + else if (part === 'Alt') mods.add('alt') + else if (part === 'Shift') mods.add('shift') + } + // A sequence token without a modifier is a typed key, never a chord. + if (format === 'token' && mods.size === 0) return null + const key = rawKey.length === 1 ? rawKey.toUpperCase() : rawKey + return `${[...mods].sort().join('+')}|${key}` +} + +export interface CommandKeymapConflict { + /** A built-in action or another command. */ + kind: 'action' | 'command' + id: string + title: string + /** True when the other side is a key the user set (an override or another + * command's binding): two of those cannot share a key. False for a + * built-in default, which an explicit binding wins over. */ + user: boolean +} + +function catalogTitle(id: string): string { + return COMMAND_CATALOG.find((entry) => entry.id === id)?.title ?? id +} + +function actionChordSignature( + definition: KeymapDefinition, + overrides: KeymapOverrides | null | undefined +): string | null { + const binding = getKeymapBinding(overrides, definition.id) + if (isUnboundBinding(binding)) return null + if (definition.kind === 'shortcut') return chordSignature(binding, 'shortcut') + const tokens = binding.split(/\s+/).filter(Boolean) + return tokens.length === 1 ? chordSignature(tokens[0], 'token') : null +} + +/** + * Everything a command binding would collide with. + * + * - `blocking`: another key the user set, on the same chord (an action + * override or another command), a sequence that is the same as, or a + * prefix of, another command's sequence, or a built-in leader key. Save is + * refused for these. + * - `shadows`: a built-in action's DEFAULT on the same chord. Allowed: an + * explicit binding wins over a default, and the recorder says which + * default stops answering that key. + */ +export function findCommandBindingConflicts( + commandId: string, + binding: string, + context: { overrides: KeymapOverrides | null | undefined; commandKeymaps: CommandKeymaps } +): { blocking: CommandKeymapConflict[]; shadows: CommandKeymapConflict[] } { + const blocking: CommandKeymapConflict[] = [] + const shadows: CommandKeymapConflict[] = [] + const kind = commandBindingKind(binding) + if (!kind) return { blocking, shadows } + + if (kind === 'sequence') { + const leaderHit = findLeaderCollision(binding, context.overrides) + if (leaderHit) { + blocking.push({ kind: 'action', id: leaderHit.id, title: leaderHit.title, user: true }) + } + const keys = commandSequenceKeys(binding) + for (const [otherId, other] of Object.entries(context.commandKeymaps)) { + if (otherId === commandId) continue + const otherKeys = commandSequenceKeys(other) + if (otherKeys.length === 0) continue + const shared = Math.min(keys.length, otherKeys.length) + if (keys.slice(0, shared).join(' ') === otherKeys.slice(0, shared).join(' ')) { + blocking.push({ kind: 'command', id: otherId, title: catalogTitle(otherId), user: true }) + } + } + return { blocking, shadows } + } + + const signature = chordSignature(binding, 'shortcut') + if (!signature) return { blocking, shadows } + for (const [otherId, other] of Object.entries(context.commandKeymaps)) { + if (otherId === commandId || commandBindingKind(other) !== 'shortcut') continue + if (chordSignature(other, 'shortcut') === signature) { + blocking.push({ kind: 'command', id: otherId, title: catalogTitle(otherId), user: true }) + } + } + for (const definition of getKeymapDefinitions()) { + if (actionChordSignature(definition, context.overrides) !== signature) continue + const user = typeof context.overrides?.[definition.id] === 'string' + const conflict: CommandKeymapConflict = { + kind: 'action', + id: definition.id, + title: definition.title, + user + } + if (user) blocking.push(conflict) + else shadows.push(conflict) + } + return { blocking, shadows } +} + +/** + * The other direction: the commands whose binding a built-in action's key + * would collide with. A chord shared with a command, or a leader key that + * starts a command's sequence (the built-in then wins and the command can no + * longer be reached). + */ +export function findActionCommandConflicts( + actionId: KeymapId, + binding: string, + context: { overrides: KeymapOverrides | null | undefined; commandKeymaps: CommandKeymaps } +): CommandKeymapConflict[] { + if (isUnboundBinding(binding)) return [] + const definition = getKeymapDefinitions().find((d) => d.id === actionId) + if (!definition) return [] + const out: CommandKeymapConflict[] = [] + const nextOverrides: KeymapOverrides = { ...(context.overrides ?? {}), [actionId]: binding } + const signature = actionChordSignature(definition, nextOverrides) + const leaderKey = + definition.scope === 'leader' && + definition.id !== 'vim.leaderPrefix' && + !LEADER_SUBGROUP_IDS.has(definition.id) + ? binding.split(/\s+/)[0] + : null + for (const [commandId, commandBinding] of Object.entries(context.commandKeymaps)) { + const kind = commandBindingKind(commandBinding) + if (kind === 'shortcut' && signature && chordSignature(commandBinding, 'shortcut') === signature) { + out.push({ kind: 'command', id: commandId, title: catalogTitle(commandId), user: true }) + } else if (kind === 'sequence' && leaderKey && commandSequenceKeys(commandBinding)[0] === leaderKey) { + out.push({ kind: 'command', id: commandId, title: catalogTitle(commandId), user: true }) + } + } + return out +} + +/* ------------------------------------------------------------------------ */ +/* Matching keys */ +/* ------------------------------------------------------------------------ */ + +/** + * The command whose chord this keydown is, or null. Checked in palette order, + * so two commands left on one chord by a hand-edited config.toml resolve the + * same way every time: the first one wins. + */ +export function commandIdForChordEvent( + event: KeyboardEvent, + commandKeymaps: CommandKeymaps +): string | null { + // Only a modifier chord or a function key can be a command's chord; every + // other key is typing and skips the walk. + if (!event.ctrlKey && !event.metaKey && !event.altKey && !/^F\d{1,2}$/.test(event.key)) return null + for (const entry of COMMAND_CATALOG) { + const binding = commandKeymaps[entry.id] + if (commandBindingKind(binding) !== 'shortcut') continue + if (matchesShortcutBinding(event, binding)) return entry.id + } + return null +} + +export type CommandLeaderStep = + | { kind: 'run'; id: string } + | { kind: 'more'; prefix: string[] } + +/** + * One key of a command's leader sequence. `prefix` holds the keys already + * pressed after the leader. A complete sequence runs; a key that starts a + * longer one keeps the leader armed; anything else is not a command's. + */ +export function stepCommandLeader( + commandKeymaps: CommandKeymaps, + prefix: readonly string[], + key: string +): CommandLeaderStep | null { + const next = [...prefix, key] + let longer = false + for (const entry of COMMAND_CATALOG) { + const keys = commandSequenceKeys(commandKeymaps[entry.id]) + if (keys.length < next.length) continue + if (next.some((k, i) => keys[i] !== k)) continue + if (keys.length === next.length) return { kind: 'run', id: entry.id } + longer = true + } + return longer ? { kind: 'more', prefix: next } : null +} + +/** + * The which-key rows for command sequences after `prefix`: one per next key, + * named for its command when only one sequence continues there. + */ +export function commandLeaderHints( + commandKeymaps: CommandKeymaps, + prefix: readonly string[] +): Array<{ key: string; label: string; detail: string }> { + const byKey = new Map<string, Array<{ id: string; rest: string[] }>>() + for (const entry of COMMAND_CATALOG) { + const keys = commandSequenceKeys(commandKeymaps[entry.id]) + if (keys.length <= prefix.length) continue + if (prefix.some((k, i) => keys[i] !== k)) continue + const key = keys[prefix.length] + const list = byKey.get(key) ?? [] + list.push({ id: entry.id, rest: keys.slice(prefix.length + 1) }) + byKey.set(key, list) + } + return [...byKey.entries()].map(([key, list]) => { + if (list.length === 1) { + const [only] = list + return { + key, + label: catalogTitle(only.id), + detail: only.rest.length > 0 ? `Then ${only.rest.join(' ')}. Command ${only.id}.` : `Command ${only.id}.` + } + } + return { + key, + label: `${list.length} commands`, + detail: list.map((item) => catalogTitle(item.id)).join(', ') + } + }) +} + +/* ------------------------------------------------------------------------ */ +/* Display */ +/* ------------------------------------------------------------------------ */ + +/** A binding as the UI shows it: `⌘⌫`, `Space x t`. Empty for unbound. */ +export function formatCommandBinding( + binding: string | null | undefined, + overrides: KeymapOverrides | null | undefined +): string { + const kind = commandBindingKind(binding) + if (!kind) return '' + if (kind === 'shortcut') return formatKeyToken(binding ?? '') + const leader = getKeymapDisplay(overrides, 'vim.leaderPrefix') || COMMAND_LEADER_TOKEN + return [leader, ...commandSequenceKeys(binding).map((key) => formatKeyToken(key))].join(' ') +} + +/** + * What a palette row or the manual shows for a command's own key. A leader + * sequence names a Vim key, so it shows only while Vim mode is on and the + * leader has a key; a chord always shows. + */ +export function commandBindingHint( + binding: string | null | undefined, + context: { overrides: KeymapOverrides | null | undefined; vimMode: boolean } +): string { + const kind = commandBindingKind(binding) + if (!kind) return '' + if (kind === 'sequence' && (!context.vimMode || !leaderTokenOf(context.overrides))) return '' + return formatCommandBinding(binding, context.overrides) +} + +/* ------------------------------------------------------------------------ */ +/* The recorder */ +/* ------------------------------------------------------------------------ */ + +export type CommandRecorderStep = + | { type: 'save' } + | { type: 'cancel' } + | { type: 'set'; binding: string } + | { type: 'ignore'; hint?: string } + +/** + * One keydown in a command's recorder. Unlike an action's recorder, which + * takes any key, a command's binding never is a bare Enter, Escape or + * Backspace, so those three drive it from the keyboard: Enter saves, Escape + * cancels, Backspace clears a chord or takes back the last key of a + * sequence. Pressing the leader first starts a sequence; anything else is a + * chord. + */ +export function commandRecorderStep( + current: string, + event: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>, + context: { + leaderToken: string + shortcutFromEvent: (event: KeyboardEvent) => string | null + tokenFromEvent: (event: KeyboardEvent) => string | null + } +): CommandRecorderStep { + const bare = !event.ctrlKey && !event.metaKey && !event.altKey + if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) return { type: 'ignore' } + // Tab moves between the recorder's buttons, as in any dialog. + if (bare && event.key === 'Tab') return { type: 'ignore' } + if (bare && !event.shiftKey && event.key === 'Enter') return { type: 'save' } + if (bare && !event.shiftKey && event.key === 'Escape') return { type: 'cancel' } + if (bare && !event.shiftKey && (event.key === 'Backspace' || event.key === 'Delete')) { + if (commandBindingKind(current) === 'sequence') { + const tokens = current.split(/\s+/).filter(Boolean) + tokens.pop() + return { type: 'set', binding: tokens.join(' ') } + } + return { type: 'set', binding: UNBOUND_BINDING } + } + const asEvent = event as KeyboardEvent + const sequence = current === COMMAND_LEADER_TOKEN || commandBindingKind(current) === 'sequence' + if (sequence) { + const token = context.tokenFromEvent(asEvent) + if (!token || !isSequenceKey(token)) { + return { type: 'ignore', hint: 'After the leader, press single keys such as x or T' } + } + const keys = current.split(/\s+/).filter(Boolean).slice(1) + if (keys.length >= MAX_COMMAND_SEQUENCE_KEYS) { + return { type: 'ignore', hint: `At most ${MAX_COMMAND_SEQUENCE_KEYS} keys follow the leader` } + } + return { type: 'set', binding: [COMMAND_LEADER_TOKEN, ...keys, token].join(' ') } + } + if (context.leaderToken && context.tokenFromEvent(asEvent) === context.leaderToken) { + return { type: 'set', binding: COMMAND_LEADER_TOKEN } + } + const chord = context.shortcutFromEvent(asEvent) + if (!chord) return { type: 'ignore' } + if (!isValidCommandChord(chord)) { + return { + type: 'ignore', + hint: `Hold Ctrl, Alt or ${isMacPlatform() ? 'Cmd' : 'Meta'} with the key, use a function key, or start with the leader` + } + } + return { type: 'set', binding: chord } +} + +/* ------------------------------------------------------------------------ */ +/* The ex line: :bind and :unbind */ +/* ------------------------------------------------------------------------ */ + +const VIM_KEY_NAMES: Record<string, string> = { + bs: 'Backspace', + backspace: 'Backspace', + cr: 'Enter', + enter: 'Enter', + return: 'Enter', + esc: 'Escape', + escape: 'Escape', + tab: 'Tab', + space: 'Space', + del: 'Delete', + delete: 'Delete', + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + home: 'Home', + end: 'End', + pageup: 'PageUp', + pagedown: 'PageDown', + insert: 'Insert', + lt: '<', + bar: '|', + bslash: '\\' +} + +const VIM_MODIFIERS: Record<string, string> = { + c: 'Ctrl', + a: 'Alt', + m: 'Alt', + s: 'Shift', + d: 'Meta' +} + +/** + * Vim's key notation, as `:map` writes it, into the keymap table's tokens: + * `<C-BS>` is Ctrl+Backspace, `<D-BS>` Cmd+Backspace (MacVim's D), `<M-x>` + * and `<A-x>` Alt+X, `<leader>` the leader, `<Space>` Space, and characters + * outside angle brackets are keys of their own (`<Space>xt` is Space, x, t). + * Returns null for notation it cannot read. + */ +export function parseVimKeyNotation(input: string): string[] | null { + const tokens: string[] = [] + let i = 0 + const text = input.trim() + while (i < text.length) { + const ch = text[i] + if (/\s/.test(ch)) { + i += 1 + continue + } + if (ch === '<') { + const close = text.indexOf('>', i + 1) + if (close === -1) return null + const inner = text.slice(i + 1, close) + i = close + 1 + if (/^leader$/i.test(inner)) { + tokens.push(COMMAND_LEADER_TOKEN) + continue + } + const parts = inner.split('-') + // `<C-->` (Ctrl and minus) splits into an empty last part. + let key = parts.pop() ?? '' + if (key === '' && parts.length > 0 && parts[parts.length - 1] === '') { + parts.pop() + key = '-' + } + const mods: string[] = [] + for (const part of parts) { + const mod = VIM_MODIFIERS[part.toLowerCase()] + if (!mod) return null + mods.push(mod) + } + const named = VIM_KEY_NAMES[key.toLowerCase()] + let base: string + if (named) base = named + else if (/^f([1-9]|1[0-9]|2[0-4])$/i.test(key)) base = key.toUpperCase() + else if (key.length === 1) base = key + else return null + tokens.push(mods.length > 0 ? `${mods.join('+')}+${base}` : base) + continue + } + tokens.push(ch) + i += 1 + } + return tokens.length > 0 ? tokens : null +} + +/** + * The keys half of `:bind` into a canonical binding. Vim notation when it + * has angle brackets, otherwise the Settings spelling (`Mod+Backspace`, + * `Space x t`). + */ +export function parseBindKeys( + keys: string, + overrides: KeymapOverrides | null | undefined +): CommandBindingParse { + if (!keys.includes('<')) return parseCommandBinding(keys, overrides) + const tokens = parseVimKeyNotation(keys) + if (!tokens) return { ok: false, error: `Can't read the keys "${keys}"` } + return parseCommandBinding(tokens.join(' '), overrides) +} + +export type BindExArgs = + | { kind: 'open' } + | { kind: 'unknown'; id: string } + | { kind: 'invalid'; id: string; error: string } + | { kind: 'bind'; id: string; binding: string } + +/** `:bind <keys> <command.id>`: the command id is the last word. */ +export function parseBindExArgs( + argString: string, + overrides: KeymapOverrides | null | undefined +): BindExArgs { + const text = argString.trim() + if (!text) return { kind: 'open' } + const split = text.lastIndexOf(' ') + if (split === -1) return isBindableCommandId(text) ? { kind: 'open' } : { kind: 'unknown', id: text } + const id = text.slice(split + 1).trim() + const keys = text.slice(0, split).trim() + if (!isBindableCommandId(id)) return { kind: 'unknown', id } + const parsed = parseBindKeys(keys, overrides) + if (!parsed.ok) return { kind: 'invalid', id, error: parsed.error } + if (!parsed.binding) return { kind: 'invalid', id, error: 'No keys given' } + return { kind: 'bind', id, binding: parsed.binding } +} + +export type UnbindExArgs = + | { kind: 'open' } + | { kind: 'action'; id: KeymapId } + | { kind: 'command'; id: string } + | { kind: 'unknown'; id: string; command: boolean } + +/** + * `:unbind <action.id>` as before, `:unbind command <command.id>` for a + * command. A bare id that names no action but does name a command unbinds + * the command, the same courtesy config.toml pays; an id that is both + * (`nav.back`) is the action, and `:unbind command nav.back` the command. + */ +export function parseUnbindExArgs(argString: string): UnbindExArgs { + const words = argString.trim().split(/\s+/).filter(Boolean) + if (words.length === 0) return { kind: 'open' } + if (words[0] === 'command') { + const id = words[1] + if (!id) return { kind: 'open' } + return isBindableCommandId(id) ? { kind: 'command', id } : { kind: 'unknown', id, command: true } + } + const id = words[0] + const action = getKeymapDefinitions().find((definition) => definition.id === id) + if (action) return { kind: 'action', id: action.id } + if (isBindableCommandId(id)) return { kind: 'command', id } + return { kind: 'unknown', id, command: false } +} diff --git a/packages/app-core/src/lib/command-palette-mode.ts b/packages/app-core/src/lib/command-palette-mode.ts index ceb11e1d4..172c8da86 100644 --- a/packages/app-core/src/lib/command-palette-mode.ts +++ b/packages/app-core/src/lib/command-palette-mode.ts @@ -1,5 +1,8 @@ export type CommandPaletteMode = 'main' | 'theme' | 'vault' | 'workflow' -export type CommandPaletteInitialMode = 'main' | 'vault' +/** The list the palette opens on. Besides the command list: the vault + * switcher (`<leader>v`), and the theme and workflow pickers, which a key + * bound to Themes… or Run Workflow… opens directly (#971). */ +export type CommandPaletteInitialMode = 'main' | 'vault' | 'theme' | 'workflow' /** * Whether Escape (and the "‹ Back" affordance) should step the command diff --git a/packages/app-core/src/lib/commands-catalog.test.ts b/packages/app-core/src/lib/commands-catalog.test.ts new file mode 100644 index 000000000..fcbf82310 --- /dev/null +++ b/packages/app-core/src/lib/commands-catalog.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { COMMAND_CATALOG } from '@shared/commands-catalog' +import { KEYMAP_CATALOG } from '@shared/keymaps-catalog' + +// COMMAND_CATALOG (shared-domain) is a slim mirror of the commands a key can be +// bound to, used by the config writer to list them under [keymaps.commands] +// and to read a command id written straight under [keymaps]. buildCommands() +// is the source of truth: this test fails when the catalog drifts (a command +// added, removed, renamed or recategorized). To regenerate, run it with +// ZN_PRINT_COMMAND_CATALOG=1 and paste the printed array. + +/** Ids the palette makes at runtime, one per thing in the vault. */ +const RUNTIME_ID = /^(tasks\.savedFilter\.|workflow\.run\.|app\.workspace\.remote\.profile\.|app\.agentEdits\.review:)/ + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + // Every host capability on, so commands a host leaves out still list: + // a binding is portable and must mean the same thing everywhere. + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsUpdater: true, + supportsNativeMenus: true, + supportsFloatingWindows: true, + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: true, + supportsCliInstall: true, + supportsCustomTemplates: true, + supportsWorkflows: true, + supportsCloudSync: true + }), + platformSync: () => 'darwin', + applyWorkflow: vi.fn(), + closeVault: vi.fn(), + openVaultWindow: vi.fn() + } + }) +} + +async function generatedCatalog(): Promise< + Array<{ id: string; title: string; category: string; keywords?: string }> +> { + vi.resetModules() + localStorage.clear() + const { useStore } = await import('../store') + const { buildCommands } = await import('./commands') + useStore.setState({ workflowsEnabled: true, workspaceMode: 'local' }) + return buildCommands({ includeUnavailable: true }) + .filter((command) => !RUNTIME_ID.test(command.id)) + .map((command) => ({ + id: command.id, + title: command.bindTitle ?? command.title, + category: command.category, + ...(command.keywords ? { keywords: command.keywords } : {}) + })) +} + +beforeEach(() => { + vi.restoreAllMocks() + installZen() +}) + +describe('COMMAND_CATALOG', () => { + it('mirrors every command with a fixed id, in palette order', async () => { + const generated = await generatedCatalog() + if (process.env.ZN_PRINT_COMMAND_CATALOG) { + const q = (value: string): string => JSON.stringify(value) + const lines = generated.map( + (entry) => + ` { id: ${q(entry.id)}, title: ${q(entry.title)}, category: ${q(entry.category)}${ + entry.keywords ? `, keywords: ${q(entry.keywords)}` : '' + } }` + ) + console.log(`export const COMMAND_CATALOG: CommandCatalogEntry[] = [\n${lines.join(',\n')}\n]`) + } + expect(COMMAND_CATALOG).toEqual(generated) + }) + + it('has no duplicate ids', () => { + const ids = COMMAND_CATALOG.map((entry) => entry.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('shares ids with the keymap catalog only where the two mean different things', () => { + // `nav.back` is both the `h` back-out action and the Go Back command, + // the collision that gave command bindings their own namespace. Any new + // shared id has to be looked at the same way before it ships. + const actionIds = new Set(KEYMAP_CATALOG.map((entry) => entry.id)) + expect(COMMAND_CATALOG.filter((entry) => actionIds.has(entry.id)).map((e) => e.id)).toEqual([ + 'nav.back' + ]) + }) +}) diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index c782bb748..b807a9fd3 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -641,3 +641,39 @@ describe('commands that give a phone editor the keyboard', () => { for (const id of EDITOR_FOCUS_COMMAND_IDS) expect(ids.has(id), id).toBe(true) }) }) + +describe('a key the user gave a command (#971)', () => { + const active = { folder: 'inbox', path: 'inbox/Plan.md', title: 'Plan', body: '' } as never + + it('shows on the palette row, ahead of the key the command already had', async () => { + const { buildCommands, useStore } = await loadCommands() + useStore.setState({ activeNote: active, commandKeymaps: { 'note.favorite': 'F5' } }) + const favorite = buildCommands().find((c) => c.id === 'note.favorite') + expect(favorite?.shortcut).toBe('F5 / Space l s') + expect(favorite?.builtinShortcut).toBe('Space l s') + const trash = buildCommands().find((c) => c.id === 'note.trash') + expect(trash?.shortcut).toBeUndefined() + }) + + it('shows a leader sequence only while Vim mode is on', async () => { + const { buildCommands, useStore } = await loadCommands() + useStore.setState({ activeNote: active, commandKeymaps: { 'note.trash': 'Leader x t' } }) + expect(buildCommands().find((c) => c.id === 'note.trash')?.shortcut).toBe('Space x t') + useStore.setState({ vimMode: false }) + expect(buildCommands().find((c) => c.id === 'note.trash')?.shortcut).toBeUndefined() + }) + + it('gives the shared guards a reason, and the note family its own', async () => { + const { buildCommands, useStore } = await loadCommands() + useStore.setState({ activeNote: null, selectedPath: null, editorViewRef: null }) + const all = buildCommands({ includeUnavailable: true }) + const reason = (id: string): string | undefined => + all.find((c) => c.id === id)?.unavailableReason?.() + expect(reason('note.rename')).toBe('Rename Note… needs an open note') + expect(reason('editor.reflow-paragraph')).toBe('Reflow Paragraph needs a note open in the editor') + expect(reason('note.trash')).toBe('Move Note to Trash needs an open note') + useStore.setState({ activeNote: { ...(active as object), folder: 'trash' } as never }) + expect(reason('note.trash')).toBe('This note is already in the Trash') + expect(reason('note.unarchive')).toBe('Unarchive Note only works on a note in the Archive') + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 77df51a67..e56bfc91e 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -57,6 +57,7 @@ import { canRenameVault, renameVaultWithPrompt } from './rename-vault' import { promptImageWidth } from './image-resize' import { copyLinkAtCursor } from './link-copy' import { getKeymapDisplay, getLeaderChordDisplay, type KeymapId } from './keymaps' +import { commandBindingHint } from './command-keymaps' import { dispatchKeyboardContextMenu, findTabContextMenuTarget } from './keyboard-context-menu' import { resolveSystemFolderLabels } from './system-folder-labels' import { @@ -125,10 +126,33 @@ export interface Command { * and keep focusing the editor. */ opensToRead?: () => boolean + /** + * The name a key binding goes by, for a command whose title flips with + * state (Enable Vim Mode / Disable Vim Mode). Settings → Keymap and the + * config.toml reference list it, so a key is never filed under what the + * command happened to say the moment the list was made. + */ + bindTitle?: string + /** + * Why `when` says no, in words. A command run from its own key cannot + * vanish the way a palette row does, so the key says what is missing + * instead of doing nothing (#971). Commands without one get the generic + * "isn't available here". + */ + unavailableReason?: () => string + /** `shortcut` as it was before the user's own key for the command was put + * in front of it: the key the command has anyway (a leader chord, `:w`). + * Settings shows it beside the binding as an "also". */ + builtinShortcut?: string } export function buildCommands(options?: { includeUnavailable?: boolean }): Command[] { const getState = (): ReturnType<typeof useStore.getState> => useStore.getState() + // The three guards most commands share. Named, so the reason a bound key + // gives when one says no can be filled in once, below, instead of per row. + const hasNote = (): boolean => !!getState().activeNote + const hasEditorNote = (): boolean => !!getState().editorViewRef && !!getState().activeNote + const hasSelection = (): boolean => !!getState().selectedPath const labels = () => resolveSystemFolderLabels(getState().systemFolderLabels) const pathLabel = (): string => getState().workspaceMode === 'remote' ? 'Server Path' : 'Absolute Path' @@ -212,6 +236,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.daily.today', + unavailableReason: () => 'Daily notes are off: turn them on under Settings, Vault, Periodic notes', title: "Open Today's Daily Note", category: 'Note', keywords: 'daily journal date today log', @@ -223,6 +248,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.daily.rollover', + unavailableReason: () => 'Daily notes are off: turn them on under Settings, Vault, Periodic notes', title: 'Roll Over Unfinished Tasks to Today', category: 'Note', keywords: 'daily tasks rollover roll over migrate unfinished carry forward today', @@ -236,6 +262,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.weekly.thisWeek', + unavailableReason: () => 'Weekly notes are off: turn them on under Settings, Vault, Periodic notes', title: "Open This Week's Note", category: 'Note', keywords: 'weekly week review date log', @@ -246,6 +273,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.monthly.thisMonth', + unavailableReason: () => 'Monthly notes are off: turn them on under Settings, Vault, Periodic notes', title: "Open This Month's Note", category: 'Note', keywords: 'monthly month review reflection date log', @@ -272,7 +300,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma shortcut: shortcut('global.insertTemplate') || (getState().vimMode ? leaderShortcut('vim.leaderInsertTemplate') : undefined), - when: () => !!getState().activeNote, + when: hasNote, run: () => getState().openTemplatePaletteForInsert() }, { @@ -287,7 +315,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Embed Existing Drawing…', category: 'Note', keywords: 'excalidraw drawing diagram sketch insert embed image picture canvas', - when: () => !!getState().activeNote, + when: hasNote, run: () => getState().setEmbedDrawingPaletteOpen(true) }, { @@ -295,11 +323,12 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Embed New Drawing', category: 'Note', keywords: 'excalidraw drawing diagram sketch create new insert embed canvas', - when: () => !!getState().activeNote, + when: hasNote, run: () => void getState().embedNewDrawing() }, { id: 'note.publish', + unavailableReason: () => window.zen.getCapabilities().supportsCloudSync === true ? `Publish Note needs an open note outside the ${labels().trash}` : "Publishing isn't available here", title: 'Publish Note', category: 'Note', keywords: 'share public link web cloud update', @@ -316,6 +345,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'template.removeBuiltins', + unavailableReason: () => 'The built-in templates are already removed', title: 'Remove Built-in Templates', category: 'Note', keywords: 'template built-in builtin remove hide delete clear shipped default', @@ -334,6 +364,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'template.restoreBuiltins', + unavailableReason: () => 'The built-in templates are already there', title: 'Restore Built-in Templates', category: 'Note', keywords: 'template built-in builtin restore bring back show shipped default', @@ -345,11 +376,12 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Save Current Note as Template…', category: 'Note', keywords: 'template save custom create from note', - when: () => !!getState().activeNote, + when: hasNote, run: () => getState().saveActiveNoteAsTemplate() }, { id: 'note.new.here', + unavailableReason: () => `New Note in Current Folder needs a note or folder outside the ${labels().trash}`, title: 'New Note in Current Folder', category: 'Note', shortcut: shortcut('global.newNoteHere'), @@ -368,7 +400,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Note', shortcut: ':w', keywords: 'persist write', - when: () => !!getState().selectedPath, + when: hasSelection, run: () => getState().persistActive() }, { @@ -377,14 +409,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Note', shortcut: ':format', keywords: 'prettier', - when: () => !!getState().selectedPath, + when: hasSelection, run: () => getState().formatActiveNote() }, { id: 'note.rename', title: 'Rename Note…', category: 'Note', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { const isCurrent = captureNavigationContext() const active = getState().activeNote @@ -400,6 +432,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.favorite', + bindTitle: 'Toggle Note Favorite', + unavailableReason: () => getState().activeNote ? `A note in the ${labels().trash} can't be a favorite` : 'Favorites need an open note', // The sidebar row's context menu was the only mouse route and the // leader chord the only keyboard one; the palette gives every host, // phones included, a way to fill the Favorites section on Home. (#810) @@ -415,6 +449,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.cover', + bindTitle: 'Add or Change Cover…', + unavailableReason: () => 'A cover needs an open note', title: activeNoteCover() ? 'Change Cover…' : 'Add Cover…', category: 'Note', keywords: 'banner header image picture gallery gradient', @@ -426,6 +462,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.cover.unsplash', + unavailableReason: () => coverNotePath() ? "Unsplash search isn't available here" : 'A cover needs an open note', title: 'Add Cover from Unsplash…', category: 'Note', keywords: 'banner header image picture photo stock unsplash', @@ -437,6 +474,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.cover.paste', + unavailableReason: () => 'A cover needs an open note', title: 'Paste Cover from Clipboard', category: 'Note', keywords: 'banner header image screenshot', @@ -449,6 +487,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.cover.reposition', + unavailableReason: () => coverNotePath() ? 'This note has no cover' : 'A cover needs an open note', title: 'Reposition Cover', category: 'Note', keywords: 'banner header image move focal', @@ -460,6 +499,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.cover.remove', + unavailableReason: () => coverNotePath() ? 'This note has no cover' : 'A cover needs an open note', title: 'Remove Cover', category: 'Note', keywords: 'banner header image delete', @@ -472,6 +512,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.archive', + unavailableReason: () => getState().activeNote ? `Move Note to ${labels().archive} works on a note in ${labels().inbox} or ${labels().quick}` : `Move Note to ${labels().archive} needs an open note`, title: `Move Note to ${labels().archive}`, category: 'Note', when: () => { @@ -482,6 +523,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.unarchive', + unavailableReason: () => `Unarchive Note only works on a note in the ${labels().archive}`, title: 'Unarchive Note', category: 'Note', when: () => getState().activeNote?.folder === 'archive', @@ -489,6 +531,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.trash', + unavailableReason: () => getState().activeNote ? `This note is already in the ${labels().trash}` : `Move Note to ${labels().trash} needs an open note`, title: `Move Note to ${labels().trash}`, category: 'Note', keywords: 'delete', @@ -497,6 +540,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.restore', + unavailableReason: () => `Restore from ${labels().trash} only works on a note in the ${labels().trash}`, title: `Restore Note from ${labels().trash}`, category: 'Note', when: () => getState().activeNote?.folder === 'trash', @@ -504,6 +548,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.delete-permanently', + unavailableReason: () => `Delete Note Permanently only works on a note in the ${labels().trash}`, title: 'Delete Note Permanently', category: 'Note', keywords: 'trash purge forever remove', @@ -515,7 +560,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Copy Note as Wikilink', category: 'Note', keywords: 'link clipboard', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { const active = getState().activeNote if (!active) return @@ -528,7 +573,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Copy Note as Markdown', category: 'Note', keywords: 'copy clipboard markdown source document whole content text yank', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { await getState().copyActiveNoteAsMarkdown() } @@ -538,7 +583,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Copy Note as HTML (for email)', category: 'Note', keywords: 'copy clipboard html email rich mail paste share formatted', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { await getState().copyActiveNoteAsHtml() } @@ -578,7 +623,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Copy Note Path', category: 'Note', keywords: 'clipboard relative vault', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { const active = getState().activeNote if (!active) return @@ -657,7 +702,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Open in Floating Window', category: 'Note', keywords: 'popout window detach', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { const p = getState().activeNote?.path if (p) await window.zen.openNoteWindow(p) @@ -668,7 +713,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Move Note to Folder…', category: 'Note', keywords: 'move mv relocate folder archive inbox', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { const isCurrent = captureNavigationContext() const state = getState() @@ -722,12 +767,13 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // (#242). Vim closes the tab with `:q` (→ closeActiveNote), matching how // save shows `:w`. Outside Vim, the real Mod+W binding is correct. shortcut: getState().vimMode ? ':q' : shortcut('global.closeActiveTab'), - when: () => !!getState().selectedPath, + when: hasSelection, opensToRead: () => true, run: () => getState().closeActiveNote() }, { id: 'tab.reopen', + unavailableReason: () => 'No closed tab to reopen', title: 'Reopen Closed Tab', category: 'Tabs', keywords: 'restore last undo closed reopen', @@ -738,6 +784,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'tab.next', + unavailableReason: () => 'No other tab to go to', title: 'Next Tab', category: 'Tabs', shortcut: shortcut('tabs.next'), @@ -751,6 +798,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'tab.previous', + unavailableReason: () => 'No other tab to go to', title: 'Previous Tab', category: 'Tabs', shortcut: shortcut('tabs.previous'), @@ -764,10 +812,11 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'tab.pin', + bindTitle: 'Pin or Unpin Tab', title: isActiveTabPinned() ? 'Unpin Tab' : 'Pin Tab', category: 'Tabs', keywords: 'stick sticky', - when: () => !!getState().activeNote, + when: hasNote, run: () => { const s = getState() if (s.activeNote) s.toggleTabPin(s.activePaneId, s.activeNote.path) @@ -775,6 +824,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'tab.close-others', + unavailableReason: () => 'No other tab to close', title: 'Close Other Tabs in Pane', category: 'Tabs', keywords: 'only siblings keep current close others', @@ -789,6 +839,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'tab.close-right', + unavailableReason: () => 'No tab to the right to close', title: 'Close Tabs to the Right', category: 'Tabs', keywords: 'siblings later right side close', @@ -832,7 +883,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Navigation', shortcut: getState().vimMode ? leaderShortcut('vim.leaderNoteOutline') : undefined, keywords: 'outline headings toc jump toc table of contents leader', - when: () => !!getState().activeNote, + when: hasNote, run: () => getState().setOutlinePaletteOpen(true) }, { @@ -841,7 +892,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'View', shortcut: shortcut('global.toggleOutlinePanel'), keywords: 'outline panel sidebar right headings', - when: () => !!getState().activeNote, + when: hasNote, run: () => { window.dispatchEvent(new Event('zen:toggle-outline')) } @@ -852,7 +903,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'View', shortcut: shortcut('global.toggleCommentsPanel'), keywords: 'comments annotations discussion margin review', - when: () => !!getState().activeNote, + when: hasNote, run: () => { window.dispatchEvent(new Event('zen:toggle-comments')) } @@ -863,13 +914,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Editor', shortcut: shortcut('global.addComment'), keywords: 'comment annotate selection note review', - when: () => !!getState().activeNote, + when: hasNote, run: () => { window.dispatchEvent(new Event('zen:add-comment')) } }, { id: 'view.calendar-panel', + unavailableReason: () => 'The calendar opens beside a daily or weekly note', title: 'Toggle Calendar Panel', category: 'View', shortcut: getState().vimMode ? leaderShortcut('vim.leaderCalendar') : undefined, @@ -889,7 +941,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'timeline tasks due dates overdue milestones upcoming agenda schedule panel right', // Like the other right-hand panels it sits beside a note, so it needs // one in the pane; the tasks it lists are the whole vault's. (#1007) - when: () => !!getState().activeNote, + when: hasNote, run: () => { window.dispatchEvent(new Event('zen:toggle-timeline')) } @@ -899,7 +951,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Close Right Panel', category: 'View', keywords: 'close hide dismiss right panel pane connections comments outline calendar timeline', - when: () => !!getState().activeNote, + when: hasNote, run: () => { window.dispatchEvent(new Event('zen:close-right-panel')) } @@ -910,7 +962,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'View', shortcut: shortcut('global.modeEdit'), keywords: 'editor writing raw markdown pane mode toolbar editmode', - when: () => !!getState().activeNote, + when: hasNote, run: () => requestPaneMode('edit') }, { @@ -919,7 +971,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'View', shortcut: shortcut('global.modeSplit'), keywords: 'editor preview side by side pane mode toolbar splitmode', - when: () => !!getState().activeNote, + when: hasNote, run: () => requestPaneMode('split') }, { @@ -928,7 +980,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'View', shortcut: shortcut('global.modePreview'), keywords: 'reading rendered markdown pane mode toolbar previewmode', - when: () => !!getState().activeNote, + when: hasNote, run: () => requestPaneMode('preview') }, { @@ -1037,7 +1089,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Editor', shortcut: shortcut('vim.foldCurrent'), keywords: 'collapse fold heading section list item task bullet children callout', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('foldAtCursor') }, { @@ -1046,7 +1098,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Editor', shortcut: shortcut('vim.unfoldCurrent'), keywords: 'expand unfold heading section list item task bullet children callout', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('unfoldAtCursor') }, { @@ -1055,7 +1107,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Editor', shortcut: shortcut('vim.foldAll'), keywords: 'collapse fold all every heading list item task outline callout', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('foldAllOutline') }, { @@ -1064,7 +1116,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Editor', shortcut: shortcut('vim.unfoldAll'), keywords: 'expand unfold all every reset heading list item task outline callout', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('unfoldAll') }, { @@ -1135,7 +1187,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma ? [shortcut('vim.linkPopover'), shortcut('editor.linkPopover')].filter(Boolean).join(' / ') : shortcut('editor.linkPopover'), keywords: 'link popover hover preview edit create note wikilink peek floating in place', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => { const view = getState().editorViewRef if (!view) return @@ -1152,7 +1204,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Editor', shortcut: shortcut('editor.toggleCheckbox'), keywords: 'toggle checkbox task check uncheck line todo checklist convert done', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => { const view = getState().editorViewRef if (view) toggleCheckbox(view) @@ -1164,7 +1216,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Editor', shortcut: shortcut('editor.reflowParagraph'), keywords: 'reflow join lines unwrap hard wrap word wrap format paragraph gq fill', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => { const view = getState().editorViewRef if (!view) return @@ -1199,7 +1251,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // Stays listed whenever a note is open: with no table under the cursor // the run says so, which beats an entry that vanishes from the palette // and an ex command that returns nothing. - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: async () => { const view = getState().editorViewRef if (!view) return @@ -1211,7 +1263,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Copy Link Under Cursor', category: 'Editor', keywords: 'copy link url email address clipboard gy', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => { const view = getState().editorViewRef if (!view) return @@ -1225,7 +1277,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Resize Image…', category: 'Editor', keywords: 'image picture width size resize embed px hint shrink grow', - when: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: async () => { const view = getState().editorViewRef if (!view) return @@ -1269,7 +1321,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Panes', shortcut: ':vsplit', keywords: 'vsplit vertical', - when: () => !!getState().selectedPath, + when: hasSelection, run: () => { const st = getState() const path = st.selectedPath @@ -1287,7 +1339,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Panes', shortcut: ':split', keywords: 'split horizontal', - when: () => !!getState().selectedPath, + when: hasSelection, run: () => { const st = getState() const path = st.selectedPath @@ -1423,6 +1475,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma cmds.push( { id: 'view.tasks', + unavailableReason: () => `The ${labels().tasks} view is already open`, title: `Open ${labels().tasks}`, category: 'View', shortcut: ':tasks', @@ -1432,6 +1485,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.workflows', + unavailableReason: () => 'Workflows are off: turn them on under Settings, Workflows', title: 'Open Workflows', category: 'View', shortcut: ':workflows', @@ -1443,6 +1497,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.atlas', + unavailableReason: () => 'The Atlas is turned off in Settings', title: 'Open Atlas', category: 'View', shortcut: leaderShortcut('vim.leaderAtlas'), @@ -1453,6 +1508,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.unresolved-links', + unavailableReason: () => 'The Unresolved Links view is already open', title: 'Open Unresolved Links', category: 'View', shortcut: getState().vimMode ? leaderShortcut('vim.leaderUnresolvedLinks') : ':unresolved', @@ -1463,6 +1519,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.tags', + unavailableReason: () => 'The Tags view is already open', title: 'Open Tags', category: 'View', shortcut: ':tag', @@ -1496,6 +1553,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.toggle.tags', + bindTitle: 'Show or Hide Tags in Sidebar', title: getState().tagsCollapsed ? 'Show Tags in Sidebar' : 'Hide Tags in Sidebar', @@ -1505,6 +1563,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.content-align', + bindTitle: 'Toggle Content Alignment', title: getState().contentAlign === 'center' ? 'Align Content Left' : 'Center Content', @@ -1517,6 +1576,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.sort.name-asc', + unavailableReason: () => 'Notes are already sorted this way', title: 'Sort Notes: Name (A → Z)', category: 'View', keywords: 'alphabetical order', @@ -1525,6 +1585,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.sort.name-desc', + unavailableReason: () => 'Notes are already sorted this way', title: 'Sort Notes: Name (Z → A)', category: 'View', keywords: 'alphabetical order reverse', @@ -1533,6 +1594,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.sort.updated-desc', + unavailableReason: () => 'Notes are already sorted this way', title: 'Sort Notes: Updated (Newest First)', category: 'View', keywords: 'date recent time modified', @@ -1541,6 +1603,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.sort.updated-asc', + unavailableReason: () => 'Notes are already sorted this way', title: 'Sort Notes: Updated (Oldest First)', category: 'View', keywords: 'date time modified', @@ -1549,6 +1612,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.sort.created-desc', + unavailableReason: () => 'Notes are already sorted this way', title: 'Sort Notes: Created (Newest First)', category: 'View', keywords: 'date added', @@ -1557,6 +1621,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.sort.created-asc', + unavailableReason: () => 'Notes are already sorted this way', title: 'Sort Notes: Created (Oldest First)', category: 'View', keywords: 'date added reverse', @@ -1565,6 +1630,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.sort.manual', + unavailableReason: () => 'Notes are already sorted this way', title: 'Sort Notes: Manual', category: 'View', keywords: 'none drag order', @@ -1573,6 +1639,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.group-by-kind', + bindTitle: 'Toggle Grouping Notes by Kind', title: getState().groupByKind ? 'Ungroup Notes by Kind' : 'Group Notes by Kind', @@ -1582,6 +1649,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.focus-mode', + bindTitle: 'Toggle Zen Mode', title: (() => { const st = getState() return st.zenMode ? 'Exit Zen Mode' : 'Enter Zen Mode' @@ -1596,6 +1664,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.dark-sidebar', + bindTitle: 'Toggle Dark Sidebar', title: getState().darkSidebar ? 'Light Sidebar' : 'Dark Sidebar', category: 'View', run: () => getState().setDarkSidebar(!getState().darkSidebar) @@ -1611,6 +1680,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.line-numbers.off', + unavailableReason: () => 'Line numbers already show this way', title: 'Line Numbers: Off', category: 'View', when: () => getState().lineNumberMode !== 'off', @@ -1618,6 +1688,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.line-numbers.absolute', + unavailableReason: () => 'Line numbers already show this way', title: 'Line Numbers: Absolute', category: 'View', when: () => getState().lineNumberMode !== 'absolute', @@ -1625,6 +1696,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.line-numbers.relative', + unavailableReason: () => 'Line numbers already show this way', title: 'Line Numbers: Relative', category: 'View', when: () => getState().lineNumberMode !== 'relative', @@ -1635,6 +1707,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // The Kanban card order (#889), from any note: the next board opened uses it. cmds.push({ id: 'view.kanban-card-sort', + bindTitle: 'Kanban: Toggle Card Order', title: getState().kanbanCardSort === 'due' ? 'Kanban: Order Cards Manually' @@ -1647,6 +1720,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // The Tasks list's order (#989), from any note: the next list opened uses it. cmds.push({ id: 'view.tasks-list-order', + bindTitle: 'Tasks: Toggle List Order', title: getState().tasksListOrder === 'priority' ? 'Tasks: Order List by Note Order' @@ -1677,12 +1751,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma cmds.push( { id: 'editor.vim.toggle', + bindTitle: 'Toggle Vim Mode', title: getState().vimMode ? 'Disable Vim Mode' : 'Enable Vim Mode', category: 'Editor', run: () => getState().setVimMode(!getState().vimMode) }, { id: 'editor.completed-task.none', + unavailableReason: () => 'Completed tasks already look this way', title: 'Completed Tasks: No Style', category: 'Editor', when: () => getState().completedTaskStyle !== 'none', @@ -1690,6 +1766,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.completed-task.strikethrough', + unavailableReason: () => 'Completed tasks already look this way', title: 'Completed Tasks: Strikethrough', category: 'Editor', when: () => getState().completedTaskStyle !== 'strikethrough', @@ -1697,6 +1774,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.completed-task.gray', + unavailableReason: () => 'Completed tasks already look this way', title: 'Completed Tasks: Gray', category: 'Editor', when: () => getState().completedTaskStyle !== 'gray', @@ -1704,6 +1782,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.completed-task.gray-strikethrough', + unavailableReason: () => 'Completed tasks already look this way', title: 'Completed Tasks: Strikethrough + Gray', category: 'Editor', when: () => getState().completedTaskStyle !== 'gray-strikethrough', @@ -1711,6 +1790,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.which-key.toggle', + bindTitle: 'Toggle Leader Key Hints', title: getState().whichKeyHints ? 'Disable Leader Key Hints' : 'Enable Leader Key Hints', @@ -1721,6 +1801,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.live-preview.toggle', + bindTitle: 'Toggle Live Preview', title: getState().livePreview ? 'Disable Live Preview' : 'Enable Live Preview', category: 'Editor', keywords: 'decoration inline', @@ -1728,6 +1809,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.harper.toggle', + bindTitle: 'Toggle Grammar and Spelling with Harper', title: getState().harperEnabled ? 'Disable Grammar and Spelling with Harper' : 'Enable Grammar and Spelling with Harper', @@ -1738,6 +1820,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.harper.next', + unavailableReason: () => !harperSupported() ? "Harper isn't available here" : !getState().harperEnabled ? 'Harper is off: turn it on under Settings, Editor' : 'Harper needs a note open in the editor', title: 'Harper: Next Suggestion', category: 'Editor', keywords: 'harper grammar spelling', @@ -1749,6 +1832,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.harper.previous', + unavailableReason: () => !harperSupported() ? "Harper isn't available here" : !getState().harperEnabled ? 'Harper is off: turn it on under Settings, Editor' : 'Harper needs a note open in the editor', title: 'Harper: Previous Suggestion', category: 'Editor', keywords: 'harper grammar spelling', @@ -1760,6 +1844,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.harper.suggest', + unavailableReason: () => !harperSupported() ? "Harper isn't available here" : !getState().harperEnabled ? 'Harper is off: turn it on under Settings, Editor' : 'Harper needs a note open in the editor', title: 'Harper: Show Suggestions at Cursor', category: 'Editor', keywords: 'harper grammar spelling fix', @@ -1771,6 +1856,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.harper.apply', + unavailableReason: () => !harperSupported() ? "Harper isn't available here" : !getState().harperEnabled ? 'Harper is off: turn it on under Settings, Editor' : 'Harper needs a note open in the editor', title: 'Harper: Apply First Suggestion', category: 'Editor', keywords: 'harper grammar spelling fix', @@ -1782,6 +1868,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.harper.addWord', + unavailableReason: () => !harperSupported() ? "Harper isn't available here" : !getState().harperEnabled ? 'Harper is off: turn it on under Settings, Editor' : 'Harper needs a note open in the editor', title: 'Harper: Add Word to Dictionary', category: 'Editor', keywords: 'harper spelling dictionary', @@ -1793,6 +1880,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.harper.ignore', + unavailableReason: () => !harperSupported() ? "Harper isn't available here" : !getState().harperEnabled ? 'Harper is off: turn it on under Settings, Editor' : 'Harper needs a note open in the editor', title: 'Harper: Ignore Suggestion', category: 'Editor', keywords: 'harper grammar spelling', @@ -1804,12 +1892,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.tabs.toggle', + bindTitle: 'Toggle Tabs', title: getState().tabsEnabled ? 'Disable Tabs' : 'Enable Tabs', category: 'Editor', run: () => getState().setTabsEnabled(!getState().tabsEnabled) }, { id: 'editor.keep-panels.toggle', + bindTitle: 'Toggle Remembering Panels per Note', title: getState().keepPanelsAcrossNotes ? 'Remember Panels per Note' : 'Keep Panels When Switching Notes', @@ -1819,6 +1909,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.persist-undo-history.toggle', + bindTitle: 'Toggle Keeping Undo History After Quitting', title: getState().persistUndoHistory ? 'Stop Keeping Undo History After Quitting' : 'Keep Undo History After Quitting', @@ -1829,6 +1920,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.word-wrap.toggle', + bindTitle: 'Toggle Word Wrap', title: getState().wordWrap ? 'Disable Word Wrap' : 'Enable Word Wrap', category: 'Editor', shortcut: shortcut('global.toggleWordWrap'), @@ -1837,6 +1929,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.auto-reveal.toggle', + bindTitle: 'Toggle Auto-Reveal Active Note', title: getState().autoReveal ? 'Disable Auto-Reveal Active Note' : 'Enable Auto-Reveal Active Note', @@ -1845,6 +1938,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.quick-note-date.toggle', + bindTitle: 'Toggle Quick Note Date Titles', title: getState().quickNoteDateTitle ? 'Disable Quick Note Date Titles' : 'Enable Quick Note Date Titles' @@ -1855,6 +1949,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.pdf-embed.compact', + unavailableReason: () => 'PDF embeds already show this way', title: 'PDF Embeds: Compact Card', category: 'Editor', keywords: 'pdf embed compact card preview reference', @@ -1863,6 +1958,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'editor.pdf-embed.full', + unavailableReason: () => 'PDF embeds already show this way', title: 'PDF Embeds: Inline Viewer', category: 'Editor', keywords: 'pdf embed full iframe inline', @@ -1878,7 +1974,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Pin Active Note as Reference', category: 'Reference', keywords: 'sticky side companion research', - when: () => !!getState().activeNote, + when: hasNote, run: async () => { const path = getState().activeNote?.path if (path) await getState().pinReference(path) @@ -1886,6 +1982,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'ref.unpin', + unavailableReason: () => 'No note is pinned as the reference', title: 'Unpin Reference', category: 'Reference', when: () => !!getState().pinnedRefPath, @@ -1895,6 +1992,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'ref.toggle', + bindTitle: 'Show or Hide Reference Pane', title: getState().pinnedRefVisible ? 'Hide Reference Pane' : 'Show Reference Pane', @@ -1928,9 +2026,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Themes…', category: 'UI', keywords: 'color appearance palette dark light', - run: () => { - /* handled by CommandPalette */ - } + // The palette drills into its theme list when this row is picked there; + // run on its own (a key, the ex line) it opens the palette on that list. + run: () => getState().setCommandPaletteOpen(true, 'theme') }) cmds.push({ id: 'ui.newTheme', @@ -2005,6 +2103,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma cmds.push( { id: 'demo.generate', + bindTitle: 'Generate Demo Tour Notes', title: getState().notes.some((note) => isDemoTourStartPath(note.path)) ? 'Regenerate Demo Tour Notes' : 'Generate Demo Tour Notes', @@ -2127,6 +2226,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'app.cloud.reviewConflicts', + unavailableReason: () => 'No Cloud conflicts are waiting', title: 'Review Cloud Sync Conflicts', category: 'Vault', keywords: 'cloud sync conflict merge review resolve queue two devices differ settings', @@ -2178,9 +2278,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma window.zen.getAppInfo().runtime === 'desktop' && (window.zen.getCapabilities().supportsLocalFilesystemPickers || window.zen.getCapabilities().supportsRemoteWorkspace), - run: () => { - /* handled by CommandPalette */ - } + // Drilled into by the palette; on its own key it opens the palette there. + run: () => getState().setCommandPaletteOpen(true, 'vault') }, { id: 'vault.rename', @@ -2492,9 +2591,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Workflow', keywords: 'workflow run list pick choose automation trigger pipeline', when: stillOn, - run: () => { - /* handled by CommandPalette */ - } + // Drilled into by the palette; on its own key it opens the palette there. + run: () => getState().setCommandPaletteOpen(true, 'workflow') }) for (const entry of state.workflowIndex) { if (entry.status !== 'active') continue @@ -2510,6 +2608,34 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma } } + // The shared guards get their reason here, once (see hasNote above). + for (const cmd of cmds) { + if (cmd.unavailableReason || !cmd.when) continue + const title = cmd.title + if (cmd.when === hasNote) cmd.unavailableReason = () => `${title} needs an open note` + else if (cmd.when === hasEditorNote) { + cmd.unavailableReason = () => `${title} needs a note open in the editor` + } else if (cmd.when === hasSelection) { + cmd.unavailableReason = () => `${title} needs an open note` + } + } + + // A key the user gave a command shows on its row, ahead of any key it + // already had (Toggle favorite keeps Space l s after it). A leader + // sequence names a Vim key, so it shows only with Vim mode on. + { + const state = getState() + const bindings = state.commandKeymaps + for (const cmd of cmds) { + cmd.builtinShortcut = cmd.shortcut + const own = commandBindingHint(bindings[cmd.id], { + overrides: state.keymapOverrides, + vimMode: state.vimMode + }) + if (own) cmd.shortcut = cmd.shortcut ? `${own} / ${cmd.shortcut}` : own + } + } + // Filter out commands whose `when` guard rejects them. if (options?.includeUnavailable) return cmds return cmds.filter((c) => !c.when || c.when()) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 978e86db6..85ba1a1d9 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -143,6 +143,7 @@ import { import { composeNewNoteBody } from './lib/search-create' import type { KeymapId, KeymapOverrides } from './lib/keymaps' import { normalizeKeymapOverrides } from './lib/keymaps' +import { normalizeCommandKeymaps, type CommandKeymaps } from './lib/command-keymaps' import { PORTABLE_PREF_KEYS, pickPortablePrefs, @@ -304,7 +305,7 @@ export type LineNumberPosition = 'edge' | 'text' * not drawn at all ('hidden'; the frontmatter is untouched). */ export type NoteCoverPlacement = 'scroll' | 'pinned' | 'hidden' export type WhichKeyHintMode = 'timed' | 'sticky' -export type CommandPaletteInitialMode = 'main' | 'vault' +export type CommandPaletteInitialMode = 'main' | 'vault' | 'theme' | 'workflow' const PREFS_KEY = 'zen:prefs:v2' const WORKSPACE_KEY = 'zen:workspace:v1' @@ -580,6 +581,10 @@ interface Prefs { * or the complete logical line when word wrap is enabled. */ vimWrappedLineMotions: VimWrappedLineMotionMode keymapOverrides: KeymapOverrides + /** Keys the user gave command palette commands (#971): command id to a + * chord or a `Leader x t` sequence; "" is a deliberate unbind. Kept apart + * from `keymapOverrides`, whose ids name built-in actions. */ + commandKeymaps: CommandKeymaps /** Enabled CSS overrides, keyed by filename (e.g. `"focus.css": "on"`). Persisted. */ enabledOverrides: Record<string, string> /** Visual color tweaks from the picker UI, keyed by token slug (e.g. `"accent": "#ff3b30"`). Persisted. */ @@ -1139,6 +1144,7 @@ export const DEFAULT_PREFS: Prefs = { vimBlockImeInNormalMode: true, vimWrappedLineMotions: 'display', keymapOverrides: {}, + commandKeymaps: {}, whichKeyHints: true, whichKeyHintMode: 'timed', whichKeyHintTimeoutMs: 900, @@ -1288,6 +1294,13 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { ? p.vimWrappedLineMotions : DEFAULT_PREFS.vimWrappedLineMotions, keymapOverrides: normalizeKeymapOverrides(p.keymapOverrides), + // Read against the overrides above: a sequence that would start on a + // built-in leader key is refused, and the leader in use is the one + // these overrides set. + commandKeymaps: normalizeCommandKeymaps( + p.commandKeymaps, + normalizeKeymapOverrides(p.keymapOverrides) + ), enabledOverrides: normalizeEnabledOverrides(p.enabledOverrides), themeTweaks: normalizeThemeTweaks(p.themeTweaks), whichKeyHints: @@ -2475,6 +2488,7 @@ function collectPrefs(s: { vimBlockImeInNormalMode: boolean vimWrappedLineMotions: VimWrappedLineMotionMode keymapOverrides: KeymapOverrides + commandKeymaps: CommandKeymaps enabledOverrides: Record<string, string> themeTweaks: Record<string, string> whichKeyHints: boolean @@ -2595,6 +2609,7 @@ function collectPrefs(s: { vimBlockImeInNormalMode: s.vimBlockImeInNormalMode, vimWrappedLineMotions: s.vimWrappedLineMotions, keymapOverrides: s.keymapOverrides, + commandKeymaps: s.commandKeymaps, enabledOverrides: s.enabledOverrides, themeTweaks: s.themeTweaks, whichKeyHints: s.whichKeyHints, @@ -3148,6 +3163,7 @@ interface Store { /** Display-row or logical-line semantics for $, I, A and dependent operators. */ vimWrappedLineMotions: VimWrappedLineMotionMode keymapOverrides: KeymapOverrides + commandKeymaps: CommandKeymaps /** Enabled CSS overrides, keyed by filename. Persisted to config [overrides]. */ enabledOverrides: Record<string, string> /** Visual color tweaks (token slug → color). Persisted to config [tweaks]. */ @@ -3805,6 +3821,10 @@ interface Store { setVimBlockImeInNormalMode: (on: boolean) => void setVimWrappedLineMotions: (mode: VimWrappedLineMotionMode) => void setKeymapBinding: (id: KeymapId, binding: string | null) => void + /** Give a command palette command a key (#971): a canonical binding from + * `parseCommandBinding`, "" to unbind it, null to clear the entry. */ + setCommandKeymapBinding: (id: string, binding: string | null) => void + /** Reset every action to its default and take every command's key away. */ resetAllKeymaps: () => void setWhichKeyHints: (on: boolean) => void setWhichKeyHintMode: (mode: WhichKeyHintMode) => void @@ -6114,6 +6134,7 @@ export const useStore = create<Store>((set, get) => { vimBlockImeInNormalMode: loadPrefs().vimBlockImeInNormalMode, vimWrappedLineMotions: loadPrefs().vimWrappedLineMotions, keymapOverrides: loadPrefs().keymapOverrides, + commandKeymaps: loadPrefs().commandKeymaps, enabledOverrides: loadPrefs().enabledOverrides, themeTweaks: loadPrefs().themeTweaks, whichKeyHints: loadPrefs().whichKeyHints, @@ -9357,8 +9378,18 @@ export const useStore = create<Store>((set, get) => { }) savePrefs(collectPrefs(get())) }, + setCommandKeymapBinding: (id, binding) => { + set((s) => { + const next = { ...s.commandKeymaps } + // As with the actions: null clears the entry, "" is a stored unbind. + if (binding === null) delete next[id] + else next[id] = binding + return { commandKeymaps: next } + }) + savePrefs(collectPrefs(get())) + }, resetAllKeymaps: () => { - set({ keymapOverrides: {} }) + set({ keymapOverrides: {}, commandKeymaps: {} }) savePrefs(collectPrefs(get())) }, setOverrideEnabled: (name, on) => { diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts new file mode 100644 index 000000000..7152e6f81 --- /dev/null +++ b/packages/shared-domain/src/commands-catalog.ts @@ -0,0 +1,225 @@ +// Slim, generated catalog of the command palette commands a key can be bound +// to (#971): every command `buildCommands()` makes with a fixed id. Commands +// the palette generates at runtime, one per saved Tasks filter, workflow, +// remote vault profile or note an assistant edited, are left out: their ids +// come and go with the vault, so a binding to one would outlive it. +// +// Lives here (not in app-core) so the desktop main process can list every +// bindable command in the config file's `[keymaps.commands]` reference and +// read a command id written straight under `[keymaps]`. The commands +// themselves stay in `packages/app-core/src/lib/commands.ts`, the source of +// truth; a test there asserts this catalog stays in sync (id, title, +// category, keywords), in palette order. +// +// Regenerate via the commands-catalog drift test in app-core if commands change. + +export interface CommandCatalogEntry { + id: string + /** The name a binding goes by: the command's `bindTitle`, else its title + * as the palette shows it with default settings. */ + title: string + category: string + keywords?: string +} + +/** The token that stands for the leader key at the start of a command's key + * sequence, as stored and written in config.toml (`"Leader x t"`). Kept + * symbolic so a sequence follows the leader when the leader is rebound. */ +export const COMMAND_LEADER_TOKEN = 'Leader' + +export const COMMAND_CATALOG: CommandCatalogEntry[] = [ + { id: "note.new.quick", title: "New Quick Note", category: "Note", keywords: "scratch capture jot" }, + { id: "note.new.inbox", title: "New Note in Inbox", category: "Note", keywords: "create add write" }, + { id: "database.new", title: "New Database", category: "Note", keywords: "database table csv records spreadsheet board kanban base" }, + { id: "task.new", title: "New Task", category: "Note", keywords: "task todo checkbox tasknotes taskforge new add create file due priority" }, + { id: "task.new.folder", title: "New Task in Folder…", category: "Note", keywords: "task todo new create folder project location directory organize where place" }, + { id: "note.daily.today", title: "Open Today's Daily Note", category: "Note", keywords: "daily journal date today log" }, + { id: "note.daily.rollover", title: "Roll Over Unfinished Tasks to Today", category: "Note", keywords: "daily tasks rollover roll over migrate unfinished carry forward today" }, + { id: "note.weekly.thisWeek", title: "Open This Week's Note", category: "Note", keywords: "weekly week review date log" }, + { id: "note.monthly.thisMonth", title: "Open This Month's Note", category: "Note", keywords: "monthly month review reflection date log" }, + { id: "template.create", title: "New Note from Template…", category: "Note", keywords: "template scaffold adr rfc meeting daily weekly boilerplate new" }, + { id: "template.insert", title: "Insert Template into Current Note…", category: "Note", keywords: "template insert apply into current note scaffold fill" }, + { id: "drawing.new", title: "New Drawing", category: "Note", keywords: "excalidraw drawing diagram sketch create new canvas" }, + { id: "embed.drawing.existing", title: "Embed Existing Drawing…", category: "Note", keywords: "excalidraw drawing diagram sketch insert embed image picture canvas" }, + { id: "embed.drawing.new", title: "Embed New Drawing", category: "Note", keywords: "excalidraw drawing diagram sketch create new insert embed canvas" }, + { id: "note.publish", title: "Publish Note", category: "Note", keywords: "share public link web cloud update" }, + { id: "template.removeBuiltins", title: "Remove Built-in Templates", category: "Note", keywords: "template built-in builtin remove hide delete clear shipped default" }, + { id: "template.restoreBuiltins", title: "Restore Built-in Templates", category: "Note", keywords: "template built-in builtin restore bring back show shipped default" }, + { id: "template.saveCurrent", title: "Save Current Note as Template…", category: "Note", keywords: "template save custom create from note" }, + { id: "note.new.here", title: "New Note in Current Folder", category: "Note", keywords: "create add write" }, + { id: "note.save", title: "Save Note", category: "Note", keywords: "persist write" }, + { id: "note.format", title: "Format Markdown", category: "Note", keywords: "prettier" }, + { id: "note.rename", title: "Rename Note…", category: "Note" }, + { id: "note.favorite", title: "Toggle Note Favorite", category: "Note", keywords: "favourite star bookmark home sidebar" }, + { id: "note.cover", title: "Add or Change Cover…", category: "Note", keywords: "banner header image picture gallery gradient" }, + { id: "note.cover.unsplash", title: "Add Cover from Unsplash…", category: "Note", keywords: "banner header image picture photo stock unsplash" }, + { id: "note.cover.paste", title: "Paste Cover from Clipboard", category: "Note", keywords: "banner header image screenshot" }, + { id: "note.cover.reposition", title: "Reposition Cover", category: "Note", keywords: "banner header image move focal" }, + { id: "note.cover.remove", title: "Remove Cover", category: "Note", keywords: "banner header image delete" }, + { id: "note.archive", title: "Move Note to Archive", category: "Note" }, + { id: "note.unarchive", title: "Unarchive Note", category: "Note" }, + { id: "note.trash", title: "Move Note to Trash", category: "Note", keywords: "delete" }, + { id: "note.restore", title: "Restore Note from Trash", category: "Note" }, + { id: "note.delete-permanently", title: "Delete Note Permanently", category: "Note", keywords: "trash purge forever remove" }, + { id: "note.copy-wikilink", title: "Copy Note as Wikilink", category: "Note", keywords: "link clipboard" }, + { id: "note.copy-markdown", title: "Copy Note as Markdown", category: "Note", keywords: "copy clipboard markdown source document whole content text yank" }, + { id: "note.copy-html", title: "Copy Note as HTML (for email)", category: "Note", keywords: "copy clipboard html email rich mail paste share formatted" }, + { id: "note.export-pdf", title: "Export Note as PDF…", category: "Note", keywords: "save print pdf export" }, + { id: "note.export-docx", title: "Export Note as Word Document…", category: "Note", keywords: "save word docx doc export office editable" }, + { id: "note.copy-path", title: "Copy Note Path", category: "Note", keywords: "clipboard relative vault" }, + { id: "note.copy-absolute-path", title: "Copy Note Absolute Path", category: "Note", keywords: "clipboard full system file server path" }, + { id: "folder.copy-path", title: "Copy Current Folder Path", category: "Folder", keywords: "clipboard relative vault" }, + { id: "folder.copy-absolute-path", title: "Copy Current Folder Absolute Path", category: "Folder", keywords: "clipboard full system file server path" }, + { id: "note.reveal", title: "Reveal Note in File Manager", category: "Note" }, + { id: "note.float", title: "Open in Floating Window", category: "Note", keywords: "popout window detach" }, + { id: "note.move", title: "Move Note to Folder…", category: "Note", keywords: "move mv relocate folder archive inbox" }, + { id: "tab.close", title: "Close Tab", category: "Tabs" }, + { id: "tab.reopen", title: "Reopen Closed Tab", category: "Tabs", keywords: "restore last undo closed reopen" }, + { id: "tab.next", title: "Next Tab", category: "Tabs", keywords: "cycle switch right gt ctrl+tab" }, + { id: "tab.previous", title: "Previous Tab", category: "Tabs", keywords: "cycle switch left gT back ctrl+shift+tab" }, + { id: "tab.pin", title: "Pin or Unpin Tab", category: "Tabs", keywords: "stick sticky" }, + { id: "tab.close-others", title: "Close Other Tabs in Pane", category: "Tabs", keywords: "only siblings keep current close others" }, + { id: "tab.close-right", title: "Close Tabs to the Right", category: "Tabs", keywords: "siblings later right side close" }, + { id: "tab.menu", title: "Open Active Tab Menu", category: "Tabs", keywords: "context menu right click active tab" }, + { id: "tab.buffers", title: "Open Buffer Switcher…", category: "Tabs", keywords: "buffers hidden tabs switch list vim leader" }, + { id: "nav.outline", title: "Open Note Outline…", category: "Navigation", keywords: "outline headings toc jump toc table of contents leader" }, + { id: "view.outline-panel", title: "Toggle Outline Panel", category: "View", keywords: "outline panel sidebar right headings" }, + { id: "view.comments-panel", title: "Toggle Comments Panel", category: "View", keywords: "comments annotations discussion margin review" }, + { id: "editor.add-comment", title: "Add Comment to Selection", category: "Editor", keywords: "comment annotate selection note review" }, + { id: "view.calendar-panel", title: "Toggle Calendar Panel", category: "View", keywords: "calendar daily weekly date navigate month week" }, + { id: "view.timeline-panel", title: "Toggle Timeline", category: "View", keywords: "timeline tasks due dates overdue milestones upcoming agenda schedule panel right" }, + { id: "view.close-right-panel", title: "Close Right Panel", category: "View", keywords: "close hide dismiss right panel pane connections comments outline calendar timeline" }, + { id: "view.mode.edit", title: "Switch to Edit Mode", category: "View", keywords: "editor writing raw markdown pane mode toolbar editmode" }, + { id: "view.mode.split", title: "Switch to Split Mode", category: "View", keywords: "editor preview side by side pane mode toolbar splitmode" }, + { id: "view.mode.preview", title: "Switch to Preview Mode", category: "View", keywords: "reading rendered markdown pane mode toolbar previewmode" }, + { id: "view.zoom.in", title: "Zoom In", category: "View", keywords: "bigger larger scale ui app browser" }, + { id: "view.zoom.out", title: "Zoom Out", category: "View", keywords: "smaller decrease scale ui app browser" }, + { id: "view.zoom.reset", title: "Reset Zoom", category: "View", keywords: "actual size normal reset scale ui app browser" }, + { id: "app.check-updates", title: "Check for Updates…", category: "App", keywords: "update updates upgrade version release github" }, + { id: "app.website", title: "Open ZenNotes Website", category: "App", keywords: "homepage website docs learn" }, + { id: "app.discord", title: "Join ZenNotes Discord", category: "App", keywords: "community chat support server discord" }, + { id: "app.github", title: "Open GitHub Repository", category: "App", keywords: "github source repository code" }, + { id: "app.release", title: "View Latest Release", category: "App", keywords: "release download changelog latest" }, + { id: "app.whatsNew", title: "What’s New in ZenNotes", category: "App", keywords: "whats what's new release notes changelog update announcement" }, + { id: "app.hideToTray", title: "Hide Window to Tray", category: "App", keywords: "tray menu bar background hide window close minimize keep running" }, + { id: "app.quit", title: "Quit ZenNotes", category: "App", keywords: "quit exit close app tray background stop" }, + { id: "app.report-issue", title: "Report an Issue", category: "App", keywords: "bug issue github feedback problem" }, + { id: "fold.heading", title: "Fold Heading or List Item at Cursor", category: "Editor", keywords: "collapse fold heading section list item task bullet children callout" }, + { id: "fold.unfold-heading", title: "Unfold Heading or List Item at Cursor", category: "Editor", keywords: "expand unfold heading section list item task bullet children callout" }, + { id: "fold.all", title: "Fold All Headings and Lists", category: "Editor", keywords: "collapse fold all every heading list item task outline callout" }, + { id: "fold.unfold-all", title: "Unfold All Headings and Lists", category: "Editor", keywords: "expand unfold all every reset heading list item task outline callout" }, + { id: "task.forward", title: "Forward Task to Note…", category: "Editor", keywords: "forward task move migrate rollover bullet journal due" }, + { id: "task.start", title: "Mark Task In Progress", category: "Editor", keywords: "start begin task in progress doing wip partial half started" }, + { id: "task.cancel", title: "Cancel Task", category: "Editor", keywords: "cancel task abandon drop dropped scrap wontfix strike" }, + { id: "editor.link-popover", title: "Open Link Popover", category: "Editor", keywords: "link popover hover preview edit create note wikilink peek floating in place" }, + { id: "task.toggle-checkbox", title: "Toggle Checkbox", category: "Editor", keywords: "toggle checkbox task check uncheck line todo checklist convert done" }, + { id: "editor.reflow-paragraph", title: "Reflow Paragraph", category: "Editor", keywords: "reflow join lines unwrap hard wrap word wrap format paragraph gq fill" }, + { id: "database.create-record-pages", title: "Create Pages for All Records", category: "Database", keywords: "database base records rows pages notes link wikilink bulk all csv" }, + { id: "table.to-database", title: "Convert Table to Database…", category: "Editor", keywords: "table database convert csv base grid board rows columns markdown pipe extract" }, + { id: "editor.copy-link", title: "Copy Link Under Cursor", category: "Editor", keywords: "copy link url email address clipboard gy" }, + { id: "editor.resize-image", title: "Resize Image…", category: "Editor", keywords: "image picture width size resize embed px hint shrink grow" }, + { id: "nav.back", title: "Go Back", category: "Tabs", keywords: "history previous" }, + { id: "nav.forward", title: "Go Forward", category: "Tabs", keywords: "history next" }, + { id: "nav.toggle-recent", title: "Switch to Previous Note", category: "Tabs", keywords: "recent last previous alternate toggle switch note" }, + { id: "split.right", title: "Split Right", category: "Panes", keywords: "vsplit vertical" }, + { id: "split.down", title: "Split Down", category: "Panes", keywords: "split horizontal" }, + { id: "pane.focus.left", title: "Focus Pane Left", category: "Panes" }, + { id: "pane.focus.down", title: "Focus Pane Below", category: "Panes" }, + { id: "pane.focus.up", title: "Focus Pane Above", category: "Panes" }, + { id: "pane.focus.right", title: "Focus Pane Right", category: "Panes" }, + { id: "nav.search", title: "Search Notes…", category: "Go", keywords: "find open cmd+f ctrl+f leader" }, + { id: "nav.search-text", title: "Search Text in Vault…", category: "Go", keywords: "grep live grep telescope fuzzy content body line text vault" }, + { id: "nav.folder.quick", title: "Go to Quick Notes", category: "Go", keywords: "quick scratch" }, + { id: "nav.folder.inbox", title: "Go to Inbox", category: "Go" }, + { id: "nav.folder.archive", title: "Go to Archive", category: "Go", keywords: "archive archived storage" }, + { id: "nav.folder.trash", title: "Go to Trash", category: "Go", keywords: "trash deleted restore bin" }, + { id: "nav.assets", title: "Go to Files", category: "Go", keywords: "assets files images" }, + { id: "nav.focus.sidebar", title: "Focus Sidebar", category: "Go" }, + { id: "nav.focus.editor", title: "Focus Editor", category: "Go" }, + { id: "view.tasks", title: "Open Tasks", category: "View", keywords: "tasks todo checklist due waiting done vault" }, + { id: "view.workflows", title: "Open Workflows", category: "View", keywords: "workflow automation pipeline graph canvas nodes run dry" }, + { id: "view.atlas", title: "Open Atlas", category: "View", keywords: "atlas map graph vault visualize notes links regions sky 3d" }, + { id: "view.unresolved-links", title: "Open Unresolved Links", category: "View", keywords: "unresolved links missing broken dangling wikilinks notes to write later create orphan dead" }, + { id: "view.tags", title: "Open Tags", category: "View", keywords: "tags browse filter vault" }, + { id: "view.toggle.sidebar", title: "Toggle Sidebar", category: "View" }, + { id: "view.toggle.connections", title: "Toggle Connections Panel", category: "View" }, + { id: "view.toggle.note-list", title: "Toggle Note List Column", category: "View", keywords: "middle pane list browser" }, + { id: "view.toggle.tags", title: "Show or Hide Tags in Sidebar", category: "View", keywords: "tag section fold hide collapse" }, + { id: "view.content-align", title: "Toggle Content Alignment", category: "View", keywords: "align center left width reading" }, + { id: "view.sort.name-asc", title: "Sort Notes: Name (A → Z)", category: "View", keywords: "alphabetical order" }, + { id: "view.sort.name-desc", title: "Sort Notes: Name (Z → A)", category: "View", keywords: "alphabetical order reverse" }, + { id: "view.sort.updated-desc", title: "Sort Notes: Updated (Newest First)", category: "View", keywords: "date recent time modified" }, + { id: "view.sort.updated-asc", title: "Sort Notes: Updated (Oldest First)", category: "View", keywords: "date time modified" }, + { id: "view.sort.created-desc", title: "Sort Notes: Created (Newest First)", category: "View", keywords: "date added" }, + { id: "view.sort.created-asc", title: "Sort Notes: Created (Oldest First)", category: "View", keywords: "date added reverse" }, + { id: "view.sort.manual", title: "Sort Notes: Manual", category: "View", keywords: "none drag order" }, + { id: "view.group-by-kind", title: "Toggle Grouping Notes by Kind", category: "View", keywords: "folders notes split section" }, + { id: "view.focus-mode", title: "Toggle Zen Mode", category: "View", keywords: "zn distraction-free focus" }, + { id: "view.dark-sidebar", title: "Toggle Dark Sidebar", category: "View" }, + { id: "view.status-bar", title: "Toggle Status Bar", category: "View", keywords: "status bar footer bottom bar hide show cloud sync word count backlinks chrome" }, + { id: "view.line-numbers.off", title: "Line Numbers: Off", category: "View" }, + { id: "view.line-numbers.absolute", title: "Line Numbers: Absolute", category: "View" }, + { id: "view.line-numbers.relative", title: "Line Numbers: Relative", category: "View" }, + { id: "view.kanban-card-sort", title: "Kanban: Toggle Card Order", category: "View", keywords: "tasks board sort due date deadline order cards columns manual" }, + { id: "view.tasks-list-order", title: "Tasks: Toggle List Order", category: "View", keywords: "tasks list sort priority high order note line" }, + { id: "editor.vim.toggle", title: "Toggle Vim Mode", category: "Editor" }, + { id: "editor.completed-task.none", title: "Completed Tasks: No Style", category: "Editor" }, + { id: "editor.completed-task.strikethrough", title: "Completed Tasks: Strikethrough", category: "Editor" }, + { id: "editor.completed-task.gray", title: "Completed Tasks: Gray", category: "Editor" }, + { id: "editor.completed-task.gray-strikethrough", title: "Completed Tasks: Strikethrough + Gray", category: "Editor" }, + { id: "editor.which-key.toggle", title: "Toggle Leader Key Hints", category: "Editor", keywords: "which-key leader space hints overlay vim" }, + { id: "editor.live-preview.toggle", title: "Toggle Live Preview", category: "Editor", keywords: "decoration inline" }, + { id: "editor.harper.toggle", title: "Toggle Grammar and Spelling with Harper", category: "Editor", keywords: "harper grammar spelling spell check lint" }, + { id: "editor.harper.next", title: "Harper: Next Suggestion", category: "Editor", keywords: "harper grammar spelling" }, + { id: "editor.harper.previous", title: "Harper: Previous Suggestion", category: "Editor", keywords: "harper grammar spelling" }, + { id: "editor.harper.suggest", title: "Harper: Show Suggestions at Cursor", category: "Editor", keywords: "harper grammar spelling fix" }, + { id: "editor.harper.apply", title: "Harper: Apply First Suggestion", category: "Editor", keywords: "harper grammar spelling fix" }, + { id: "editor.harper.addWord", title: "Harper: Add Word to Dictionary", category: "Editor", keywords: "harper spelling dictionary" }, + { id: "editor.harper.ignore", title: "Harper: Ignore Suggestion", category: "Editor", keywords: "harper grammar spelling" }, + { id: "editor.tabs.toggle", title: "Toggle Tabs", category: "Editor" }, + { id: "editor.keep-panels.toggle", title: "Toggle Remembering Panels per Note", category: "Editor", keywords: "panels connections outline comments calendar sticky per note remember switch" }, + { id: "editor.persist-undo-history.toggle", title: "Toggle Keeping Undo History After Quitting", category: "Editor", keywords: "undofile undo redo history persistent restart quit vim" }, + { id: "editor.word-wrap.toggle", title: "Toggle Word Wrap", category: "Editor", keywords: "wrap line soft hard" }, + { id: "editor.auto-reveal.toggle", title: "Toggle Auto-Reveal Active Note", category: "Editor" }, + { id: "editor.quick-note-date.toggle", title: "Toggle Quick Note Date Titles", category: "Editor", keywords: "daily date today yyyy-mm-dd" }, + { id: "editor.pdf-embed.compact", title: "PDF Embeds: Compact Card", category: "Editor", keywords: "pdf embed compact card preview reference" }, + { id: "editor.pdf-embed.full", title: "PDF Embeds: Inline Viewer", category: "Editor", keywords: "pdf embed full iframe inline" }, + { id: "ref.pin", title: "Pin Active Note as Reference", category: "Reference", keywords: "sticky side companion research" }, + { id: "ref.unpin", title: "Unpin Reference", category: "Reference" }, + { id: "ref.toggle", title: "Show or Hide Reference Pane", category: "Reference" }, + { id: "ref.focus", title: "Focus Reference Pane", category: "Reference" }, + { id: "ui.themes", title: "Themes…", category: "UI", keywords: "color appearance palette dark light" }, + { id: "ui.newTheme", title: "New Custom Theme…", category: "UI", keywords: "custom theme create css appearance author new" }, + { id: "ui.overrides", title: "Overrides…", category: "UI", keywords: "css override tweak override theme appearance customize color" }, + { id: "tag.rename", title: "Rename Tag…", category: "Tag" }, + { id: "tag.delete", title: "Delete Tag…", category: "Tag" }, + { id: "demo.generate", title: "Generate Demo Tour Notes", category: "Demo", keywords: "demo onboarding starter tour sample example seed welcome" }, + { id: "demo.remove", title: "Remove Demo Tour Notes", category: "Demo", keywords: "demo onboarding starter tour sample example delete clear uninstall" }, + { id: "app.help", title: "Open Help", category: "App", keywords: "manual docs documentation shortcuts vim onboarding learn" }, + { id: "app.onboarding.restart", title: "Show Onboarding Wizard", category: "App", keywords: "onboarding welcome wizard setup first-run getting started vim theme vault" }, + { id: "app.settings", title: "Open Settings", category: "App", keywords: "preferences" }, + { id: "app.vault.pick", title: "Open Local Vault…", category: "Vault", keywords: "vault local open folder workspace" }, + { id: "app.vault.openWindow", title: "Open Local Vault in New Window…", category: "Vault", keywords: "vault local open folder workspace window tab multiple" }, + { id: "app.file.open", title: "Open File…", category: "Vault", keywords: "open file markdown external outside vault one-off document proofread standalone" }, + { id: "app.vault.close", title: "Close Current Vault", category: "Vault", keywords: "vault local close remove forget workspace" }, + { id: "app.cloud.reviewConflicts", title: "Review Cloud Sync Conflicts", category: "Vault", keywords: "cloud sync conflict merge review resolve queue two devices differ settings" }, + { id: "app.cloud.reviewVaultRemoval", title: "Review Cloud Vault", category: "Vault", keywords: "cloud sync vault deleted unavailable gone stopped syncing link choose new" }, + { id: "app.vault.switch", title: "Switch Vault…", category: "Vault", keywords: "vault local remote switch workspace recent picker server" }, + { id: "vault.rename", title: "Rename Vault…", category: "Vault", keywords: "vault name display name rename label switcher sidebar header folder" }, + { id: "app.workspace.remote", title: "Connect to Remote Vault…", category: "Vault", keywords: "vault remote server workspace self-hosted" }, + { id: "app.workspace.local", title: "Switch to Local Vault", category: "Vault", keywords: "vault local workspace disconnect return" }, + { id: "app.assets.reveal", title: "Reveal Vault Root", category: "App" }, + { id: "cli.check-updates", title: "Check for zn Updates…", category: "CLI", keywords: "cli zn terminal command line update upgrade version release" }, + { id: "cli.install", title: "Install Command-Line Tool (zn)", category: "CLI", keywords: "cli zn terminal shell command line install symlink path bin" }, + { id: "cli.uninstall", title: "Uninstall Command-Line Tool (zn)", category: "CLI", keywords: "cli zn terminal shell command line uninstall remove symlink" }, + { id: "cli.openSettings", title: "Open CLI Settings", category: "CLI", keywords: "cli zn terminal settings command line preferences" }, + { id: "workflow.tutorial", title: "Start Workflows Tutorial", category: "Workflow", keywords: "workflow tutorial learn guide walkthrough practice lesson automation onboarding" }, + { id: "workflow.runPicker", title: "Run Workflow…", category: "Workflow", keywords: "workflow run list pick choose automation trigger pipeline" } +] + +const COMMAND_CATALOG_IDS: ReadonlySet<string> = new Set(COMMAND_CATALOG.map((entry) => entry.id)) + +/** True when `id` names a command a key can be bound to. */ +export function isBindableCommandId(id: string): boolean { + return COMMAND_CATALOG_IDS.has(id) +} From de773edab02bf5a7fab8e69e67a33732e7347ff8 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 12:39:54 -0500 Subject: [PATCH 18/34] Feat(config): command keys travel in config.toml under [keymaps.commands] (#971) commandKeymaps joins the portable preferences, so a key given to a command syncs with the rest of the dotfile and a hand edit applies live. The writer puts it in its own table under the action overrides: [keymaps.commands] "note.trash" = "Mod+Backspace" "note.archive" = "Leader x a" followed by every bindable command as a commented reference line with its category and title (`# "note.trash" = "" # Note: Move Note to Trash`). Every command starts unbound, so the reference shows "" and the header says what a key may be. It is static, so the file reads the same bytes on every OS (#901). "" is a deliberate unbind, as in [keymaps], and is marked. The reader splits [keymaps]: action ids stay overrides, the commands sub-table becomes commandKeymaps. As a courtesy to anyone who followed the issue's example, a command id written straight under [keymaps] is read as a command key when no action has that id; nav.back, which both have, stays the back-out action. The canonical table wins over such a line, and since the app rewrites the file in canonical form on launch, the line moves into [keymaps.commands] the next time the file is written. The renderer validates what it reads like any other pref: unknown ids and keys it cannot read are dropped, and so is a sequence that starts on a built-in leader key, which could never be reached. The web client keeps these keys in its stored preferences, the same place as the overrides. Tests: the round trip with an unbind, the reference, the courtesy read and its precedence, byte-identical output across platforms, and a launch that moves a bare id into its table on disk. --- apps/desktop/src/main/app-config.test.ts | 90 ++++++++++++++++++++++ apps/desktop/src/main/app-config.ts | 85 ++++++++++++++++++++ packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + 4 files changed, 177 insertions(+) diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 781c68370..55bbe82e2 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -456,3 +456,93 @@ describe('keymap reference across platforms (#901)', () => { expect(deserializeConfig(text).portable.keymapOverrides ?? {}).toEqual({}) }) }) + +describe('command keys in [keymaps.commands] (#971)', () => { + it('round-trips command bindings, unbinds included, beside the action overrides', () => { + const portable: AppConfigPortable = { + keymapOverrides: { 'global.zoomIn': 'Mod+K' }, + commandKeymaps: { 'note.trash': 'Mod+Backspace', 'note.archive': 'Leader x a', 'note.restore': '' } + } + const text = serializeConfig(portable) + expect(text).toContain('[keymaps.commands]\n"note.trash" = "Mod+Backspace"\n"note.archive" = "Leader x a"\n"note.restore" = "" # unbound\n') + const { portable: back } = deserializeConfig(text) + expect(back.keymapOverrides).toEqual({ 'global.zoomIn': 'Mod+K' }) + expect(back.commandKeymaps).toEqual(portable.commandKeymaps) + // Canonical: writing what was read gives the same bytes. + expect(serializeConfig(back)).toBe(text) + }) + + it('lists every bindable command with its title, and only commented', () => { + const text = serializeConfig({}) + expect(text).toContain('# "note.trash" = "" # Note: Move Note to Trash\n') + expect(text).toContain('# "note.favorite" = "" # Note: Toggle Note Favorite\n') + // The id that is also an action is listed in both, each in its own table. + expect(text).toContain('# "nav.back" = "" # Tabs: Go Back\n') + expect(text).toContain('# "nav.back" = "h" # Back out\n') + // Runtime-made commands are not in the list. + expect(text).not.toContain('"workflow.run.') + const { portable } = deserializeConfig(text) + expect(portable.commandKeymaps).toEqual({}) + expect(portable.keymapOverrides).toEqual({}) + }) + + it('reads a command id written straight under [keymaps], and the next write moves it', () => { + const handWritten = [ + 'config_version = 1', + '', + '[keymaps]', + '"global.zoomIn" = "Mod+K"', + '"note.trash" = "Mod+Backspace"', + '"nav.back" = "Ctrl+H"', + '' + ].join('\n') + const { portable } = deserializeConfig(handWritten) + // nav.back is an action id first: it stays the back-out action. + expect(portable.keymapOverrides).toEqual({ 'global.zoomIn': 'Mod+K', 'nav.back': 'Ctrl+H' }) + expect(portable.commandKeymaps).toEqual({ 'note.trash': 'Mod+Backspace' }) + const rewritten = serializeConfig(portable) + expect(rewritten).toContain('[keymaps.commands]\n"note.trash" = "Mod+Backspace"\n') + expect(rewritten.split('[keymaps.commands]')[0]).not.toMatch(/^"note\.trash"/m) + }) + + it('lets the canonical table win over a stray line for the same command', () => { + const text = [ + '[keymaps]', + '"note.trash" = "F6"', + '', + '[keymaps.commands]', + '"note.trash" = "Mod+Backspace"', + '' + ].join('\n') + expect(deserializeConfig(text).portable.commandKeymaps).toEqual({ 'note.trash': 'Mod+Backspace' }) + }) + + it('renders the same bytes on every OS', () => { + const realPlatform = process.platform + const portable: AppConfigPortable = { commandKeymaps: { 'note.trash': 'Mod+Backspace' } } + const renders = (['darwin', 'linux', 'win32'] as const).map((platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + try { + return serializeConfig(portable) + } finally { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) + } + }) + expect(new Set(renders).size).toBe(1) + }) + + it('moves a bare command id into its table when the app opens the file', async () => { + const dir = await tmp('zen-cfg-') + process.env.ZENNOTES_CONFIG_DIR = dir + await writeFile( + getConfigFilePath(), + 'config_version = 1\n\n[keymaps]\n"note.trash" = "Mod+Backspace"\n', + 'utf8' + ) + await initAppConfig(() => {}) + expect(getPortableConfigSnapshot().commandKeymaps).toEqual({ 'note.trash': 'Mod+Backspace' }) + const onDisk = await readFile(getConfigFilePath(), 'utf8') + expect(onDisk).toContain('[keymaps.commands]\n"note.trash" = "Mod+Backspace"\n') + expect(onDisk.split('[keymaps.commands]')[0]).not.toMatch(/^"note\.trash"/m) + }) +}) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 6887966af..be0015910 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -24,6 +24,7 @@ import { KEYMAP_GROUP_ORDER, KEYMAP_GROUP_LABELS } from '@shared/keymaps-catalog' +import { COMMAND_CATALOG, isBindableCommandId } from '@shared/commands-catalog' import { writeFileAtomic } from './vault' const CONFIG_FILE_NAME = 'config.toml' @@ -665,6 +666,7 @@ export function serializeConfig(portable: AppConfigPortable): string { } lines.push(...keymapSectionLines(portable.keymapOverrides)) + lines.push(...commandKeymapSectionLines(portable.commandKeymaps)) for (const key of Object.keys(MAP_TABLE_FIELDS) as PortablePrefKey[]) { if (key === 'keymapOverrides') continue // emitted with its full reference above @@ -743,6 +745,83 @@ function keymapSectionLines(rawOverrides: unknown): string[] { return lines } +/** Build the `[keymaps.commands]` block (#971): the keys given to command + * palette commands, then every bindable command as a commented reference. + * Every command starts unbound, so the reference shows `""`: uncomment a + * line and fill in a key. Static, so it reads the same on every OS (#901). */ +function commandKeymapSectionLines(rawBindings: unknown): string[] { + const bindings: Record<string, string> = {} + if (rawBindings && typeof rawBindings === 'object' && !Array.isArray(rawBindings)) { + for (const [key, value] of Object.entries(rawBindings as Record<string, unknown>)) { + if (typeof value === 'string') bindings[key] = value + } + } + + const lines = [ + '', + '# Keys for command palette commands. Add or uncomment "<command.id>" = "<key>".', + '# Every command starts unbound. A key is a chord with Ctrl, Alt or Cmd', + '# ("Mod+Backspace", "Alt+Mod+T") or a function key ("F5"), or, with Vim', + '# mode on, a leader sequence of one to three keys ("Leader x t", which', + '# reads as Space x t while Space is the leader). A sequence may not start', + '# on a key the built-in leader uses. "" leaves a command with no key.', + '[keymaps.commands]' + ] + + for (const [key, value] of Object.entries(bindings)) { + const line = `${tomlKey(key)} = ${tomlValue(value)}` + lines.push(value === '' ? `${line} # unbound` : line) + } + + lines.push('', '# --- All commands (uncomment + add a key to bind one) ---') + for (const entry of COMMAND_CATALOG) { + if (entry.id in bindings) continue + lines.push(`# ${tomlKey(entry.id)} = "" # ${entry.category}: ${entry.title}`) + } + + return lines +} + +/** + * Split the parsed `[keymaps]` table: built-in action overrides stay + * `keymapOverrides`, the `[keymaps.commands]` sub-table is `commandKeymaps`. + * A command id written straight under `[keymaps]` (`"note.trash" = "..."`, + * which the old docs suggested and nothing read) counts as a command binding + * when no action has that id; `nav.back`, both, stays the action. The + * canonical table wins over such a line, and the next write moves the line + * into it. + */ +function readKeymapsTable(table: unknown): { + keymapOverrides?: Record<string, unknown> + commandKeymaps?: Record<string, string> +} { + if (!table || typeof table !== 'object' || Array.isArray(table)) return {} + const actionIds = new Set(KEYMAP_CATALOG.map((entry) => entry.id)) + const overrides: Record<string, unknown> = {} + const commands: Record<string, string> = {} + const courtesy: Record<string, string> = {} + let sawCommands = false + for (const [key, value] of Object.entries(table as Record<string, unknown>)) { + if (key === 'commands' && value && typeof value === 'object' && !Array.isArray(value)) { + sawCommands = true + for (const [id, binding] of Object.entries(value as Record<string, unknown>)) { + if (typeof binding === 'string') commands[id] = binding + } + continue + } + if (!actionIds.has(key) && isBindableCommandId(key) && typeof value === 'string') { + courtesy[key] = value + continue + } + overrides[key] = value + } + const merged = { ...courtesy, ...commands } + return { + keymapOverrides: overrides, + ...(sawCommands || Object.keys(merged).length > 0 ? { commandKeymaps: merged } : {}) + } +} + /** Parse TOML text into a portable config object plus its version. Throws on * malformed TOML — callers fall back to the last good config. */ export function deserializeConfig(text: string): { version: number; portable: AppConfigPortable } { @@ -767,6 +846,12 @@ export function deserializeConfig(text: string): { version: number; portable: Ap for (const [key, def] of Object.entries(MAP_TABLE_FIELDS)) { if (!def) continue const value = parsed[def.table] + if (key === 'keymapOverrides') { + const { keymapOverrides, commandKeymaps } = readKeymapsTable(value) + if (keymapOverrides) portable.keymapOverrides = keymapOverrides + if (commandKeymaps) portable.commandKeymaps = commandKeymaps + continue + } if (value && typeof value === 'object' && !Array.isArray(value)) { portable[key as PortablePrefKey] = value } diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 3f52ddcb8..e53a7fe0c 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -16,6 +16,7 @@ export const PORTABLE_PREF_KEYS = [ 'whichKeyHintTimeoutMs', // keymaps (overrides only) 'keymapOverrides', + 'commandKeymaps', 'ignoredKeys', 'externalApplicationSchemes', // search diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 95db50da6..96b830dd9 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -82,6 +82,7 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { whichKeyHintMode: 'timed', whichKeyHintTimeoutMs: 900, keymapOverrides: {}, + commandKeymaps: {}, ignoredKeys: [], externalApplicationSchemes: [], vaultTextSearchBackend: 'auto', From 8e1d97eb8bcfc8c2a1862356f966606646f29f54 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 12:40:01 -0500 Subject: [PATCH 19/34] Docs(help): the manual says every command can take a key, and how (#971) The manual promised that every shortcut was remappable while the palette's commands had no keys at all. Now it can say so truthfully, and it says how: - a How-To, "Give any command a key": Alt+Enter in the palette, the Commands group in Settings, chords and leader sequences, what Vim off does, why built-in leader keys win, the precedence over defaults and the editor's keys, the toast, :bind and :unbind command, [keymaps.commands]; - Alt+Enter (in the command palette) among the global shortcuts; - a :bind entry on the ex line, and :unbind command beside :unbind; - the Commands group, the two-way conflict rules and Reset all in the Keymaps settings section; - [keymaps.commands] and the courtesy read in the configuration file entry. --- packages/app-core/src/lib/help.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 2fc05adec..efecee27c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -161,6 +161,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Use the note context menu, search for `move` or `mv` in the command palette, or run `:move` or `:mv`. With no argument, ZenNotes opens a folder picker that speaks the sidebar\'s language: folders of your notes area as you see them there (`Work/Research`, no `inbox/` in front, and empty for the notes root, which is Inbox or the vault root depending on your Primary notes location), plus `archive/…` for the Archive. With a target like `:mv Work/Research` or `:mv archive/Reference`, it moves the note directly; `inbox/Work` still works on an Inbox vault.' }, + { + title: 'Give any command a key', + body: + 'Every command in the command palette can take a key of its own, from Move Note to Trash to Toggle Word Wrap; all of them start unbound. In the palette, highlight a command and press `Alt+Enter` (Option+Enter on macOS): a recorder opens over the palette, you press the key, Enter saves and Escape goes back to the same row, which now shows its key. Or open Settings → Keymap, where the Commands group lists every command with its category and id (filter by title, category, keywords or id; Settings search finds a command by its title too), and use Change…, Unbind and Reset as on any other row. A command’s key is a chord with Ctrl, Alt or Cmd, such as `Mod+Backspace`, or a function key; with Vim mode on it can also be a leader sequence, the leader and then one to three keys, such as `Space x t`. Sequences run through the leader like the built-in ones and show in the leader hints; with Vim mode off they do nothing, and Settings marks them “Inert with Vim off”. Built-in leader keys always win, so a sequence may not start on one: `Space t` is the template picker, so `Space t x` is refused. A key you give a command wins over every default, the editor’s own keys included (a command on `Mod+Backspace` runs even in the editor, where that key would delete to the line start), and the recorder says which default it takes over. Two of your own keys cannot share one: the recorder refuses a key another of your bindings holds, and marks both rows when config.toml has made them share. When the command cannot run where you are, the key does nothing and a short note says why, for example “Restore from Trash only works on a note in the Trash”. From the ex line, `:bind <C-BS> note.trash` or `:bind <leader>xa note.archive` binds and `:unbind command note.trash` takes the key away. In `config.toml` the keys live under `[keymaps.commands]`, as `"note.trash" = "Mod+Backspace"` or `"note.archive" = "Leader x a"`.' + }, { title: 'Act on multiple sidebar items', body: @@ -560,6 +565,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it. With Vim mode off, Mod+F in the main window opens note search instead (the row above); unbind or move Search notes in non-Vim mode under Settings \u2192 Keymap to give Mod+F to the bar, or use F3. With Vim mode on, Linux and Windows keep Ctrl+F as Vim\u2019s page-forward (search with / instead); macOS keeps Cmd+F for the bar. A floating window has no note search, so there Mod+F opens the bar with Vim mode off too.' }, { keys: 'F3 / Mod+G (in the editor)', action: 'Find the next match in the note', detail: 'With the find bar closed, open it with your last search filled in and selected, ready to retype or to press F3 again; with it open, move to the next match. Shift+F3 or Shift+Mod+G moves to the previous one. The same with Vim mode on or off, in every window.' }, { keys: 'Shift+Mod+P', action: 'Open commands', detail: 'Open the command palette.' }, + { keys: 'Alt+Enter (in the command palette)', action: 'Give the highlighted command a key', detail: 'Record a key for the command under the cursor without leaving the palette (Option+Enter on macOS). Enter saves, Escape returns to the same row, which then shows its key. Every command can take one; the same keys live in Settings → Keymap → Commands.' }, { keys: 'Mod+N', action: 'New note in current folder', detail: 'Create a note in the active note\u2019s folder (or the browsed folder when no note is open) and focus its title. On Linux and Windows this wins over Vim\u2019s Ctrl+N (cursor down). Rebindable under Settings \u2192 Keymaps.' }, { keys: 'Shift+Mod+N', action: 'New Quick Note', detail: 'Create a quick capture note in the main window and focus its title.' }, { keys: 'Alt+Mod+T (macOS)', action: 'New note from template', detail: 'Open the template picker to create a note, with Vim mode on or off. Bound by default on macOS; on Linux and Windows it starts unbound, since Ctrl+Alt+T opens a terminal on most Linux desktops, so set a key under Settings → Keymap.' }, @@ -840,8 +846,13 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ }, { command: ':unbind action.id', - summary: 'Remove an action’s key entirely', - detail: 'Leave an action with no key at all, instead of parking it on some obscure chord: `:unbind global.zoomIn` takes Zoom in off its key until it is rebound or reset under Settings, Keymaps. Bare `:unbind` opens that page, which lists every action id. The unbind travels in `config.toml` as `\"global.zoomIn\" = \"\"`.' + summary: 'Remove an action’s or a command’s key entirely', + detail: 'Leave an action with no key at all, instead of parking it on some obscure chord: `:unbind global.zoomIn` takes Zoom in off its key until it is rebound or reset under Settings, Keymaps. `:unbind command note.trash` does the same for a command palette command. A bare id that names no action but does name a command unbinds the command; `nav.back`, both an action (`h`, back out) and the Go Back command, is the action, and `:unbind command nav.back` the command. Bare `:unbind` opens that page, which lists every id. The unbind travels in `config.toml` as `\"global.zoomIn\" = \"\"`.' + }, + { + command: ':bind <keys> command.id', + summary: 'Give a command palette command a key', + detail: 'The ex twin of Alt+Enter in the palette: `:bind <C-BS> note.trash`, `:bind <D-BS> note.trash` (Cmd+Backspace on macOS), `:bind <leader>xa note.archive` or `:bind <Space>xa note.archive`. Keys are written in Vim’s notation (`<C-…>` Ctrl, `<A-…>` or `<M-…>` Alt, `<S-…>` Shift, `<D-…>` Cmd, `<BS>`, `<CR>`, `<F5>`, `<leader>`) or the way Settings and config.toml write them (`Mod+Backspace`, `Space x a`); the command id is the last word. A key another of your bindings holds is refused, as is a sequence that starts on a built-in leader key. Bare `:bind` opens Settings, Keymap, which lists every command id.' }, { command: ':ignorekey <key>', @@ -1254,12 +1265,13 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { title: 'Keymaps', items: [ - { label: 'Shortcut overrides', detail: 'Remap global app shortcuts, Vim-specific bindings, panel navigation keys, and view actions from one place.' }, + { label: 'Shortcut overrides', detail: 'Remap global app shortcuts, Vim-specific bindings, panel navigation keys, and view actions from one place, and give any command palette command a key of its own.' }, + { label: 'Commands', detail: 'The fifth group lists every command in the command palette, each with its title, category and id, all unbound to start: every command can take a key. A key is a chord with Ctrl, Alt or Cmd, or a function key, or with Vim mode on a leader sequence (the leader and one to three keys, `Space x t`), which does nothing with Vim mode off and is marked so. A command that already has a leader chord, like Toggle Note Favorite’s `Space l s`, keeps it, and its own key is added. The filter finds a command by title, category, keywords or id, and Settings search finds it by title. `Alt+Enter` on a palette row records a key without coming here, `:bind <keys> <command.id>` does it from the ex line, and in `config.toml` the keys live under `[keymaps.commands]`.' }, { label: 'Recorded sequences', detail: 'Capture single shortcuts or multi-step sequences such as Leader flows, pane prefixes, `g g`, `g d`, or fold motions without editing raw config files.' }, - { label: 'Conflict detection', detail: 'When you record a global shortcut that another action already uses, the recorder names the clash and disables Save, so two actions can no longer silently share one key. Any existing clash shows a badge on the affected rows. Vim, navigation, and view keys that deliberately reuse a key by context are left alone.' }, + { label: 'Conflict detection', detail: 'When you record a global shortcut that another action already uses, the recorder names the clash and disables Save, so two actions can no longer silently share one key. The same goes between commands and actions, both ways: a key another of your bindings holds is refused. A command’s key that lands on a built-in default is allowed, since your key wins, and the recorder and both rows say which default stops answering it. A leader sequence that starts on a built-in leader key is refused, because built-in leader keys always win. Any existing clash shows a badge on the affected rows. Vim, navigation, and view keys that deliberately reuse a key by context are left alone.' }, { label: 'Context-menu bindings', detail: 'The same keymap table controls the context-menu action used in the sidebar, note list, and preview-side active-tab menu, so mouse-free navigation stays configurable.' }, { label: 'Unbind a key', detail: 'Unbind removes an action’s key entirely instead of parking it on some obscure chord: nothing triggers the action until you record a new key or reset it, the row reads “Unbound”, and the which-key hints, the command palette, and this manual stop advertising it. Unbind sits next to Change on every row and inside the recorder; `:unbind <action.id>` (for example `:unbind global.zoomIn`) does the same from the ex line, and in `config.toml` the entry is `"global.zoomIn" = ""`.' }, - { label: 'Reset controls', detail: 'Clear an individual override, including an unbind, or reset the entire keymap table back to the shipped defaults.' }, + { label: 'Reset controls', detail: 'Clear an individual override, including an unbind, or reset the entire keymap table back to the shipped defaults; Reset all also takes every command’s key away.' }, { label: 'Ignored keys', detail: 'Keys the app never sees. Keyboard remappers with tap-hold layers (Kanata, QMK, ZMK) send a harmless extra key with every keystroke, on Linux usually the Katakana/Hiragana key, which reads as KanaMode, and each one used to reset a pending `jk`, `dd`, leader chord or hint. Record the key here (or `:ignorekey KanaMode`, or `ignored_keys = ["KanaMode"]` under `[editor]` in config.toml) and it is swallowed before the editor or any panel reacts.' } ] }, @@ -1329,7 +1341,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Count this copy anonymously', detail: 'Once when ZenNotes is installed and once per update, the desktop app sends zennotes.org the version, operating system, architecture and install type, so we know how many copies are in use. It carries no identifier and nothing about your notes, vaults or account, and only daily totals are kept. On by default; turn it off here to send nothing. Set in config.toml as `[privacy] usage_count`.' }, { label: 'Updates and releases', detail: 'Check for updates, download a newer build, install and relaunch, or jump straight to the latest GitHub release from inside the app. AUR and tarball installs get the check and the notice only; the package manager does the install.' }, { label: 'Website, community, and issue links', detail: 'The app now exposes direct links to the ZenNotes website, Discord, GitHub repository, and issue tracker so support paths stay discoverable.' }, - { label: 'Configuration file', detail: 'Your preferences (theme, editor, Vim, keymaps, fonts, search backend, and more) are mirrored to a plain-text `config.toml` so you can sync them across machines with git, stow, or chezmoi. It lives at `$XDG_CONFIG_HOME/zennotes/config.toml` (`~/.config/zennotes/config.toml` on macOS and Linux, `%APPDATA%\\zennotes\\config.toml` on Windows), or wherever `$ZENNOTES_CONFIG_DIR` points. The file is self-documenting: every available setting is listed with its allowed values, and every keymap action is listed with its default binding (commented out: uncomment a line and edit it to remap, or set it to `""` to remove the key entirely), so you can discover and change anything without opening the app. That list reads the same on every OS: where macOS has its own default, the line notes it, as in `# "tabs.select1" = "Alt+1" # Go to tab 1 (macOS: "Ctrl+1")`, so a file synced between a Mac and a Linux or Windows machine changes only when you change a setting. Settings → About has Reveal and Copy-path buttons. Existing setups are written out automatically the first time you launch this version, and edits to the file, by hand or via a synced dotfile, apply live without a restart. Machine-specific layout (window size, pane widths, collapsed folders) stays local so the file does not churn.' }, + { label: 'Configuration file', detail: 'Your preferences (theme, editor, Vim, keymaps, fonts, search backend, and more) are mirrored to a plain-text `config.toml` so you can sync them across machines with git, stow, or chezmoi. It lives at `$XDG_CONFIG_HOME/zennotes/config.toml` (`~/.config/zennotes/config.toml` on macOS and Linux, `%APPDATA%\\zennotes\\config.toml` on Windows), or wherever `$ZENNOTES_CONFIG_DIR` points. The file is self-documenting: every available setting is listed with its allowed values, and every keymap action is listed with its default binding (commented out: uncomment a line and edit it to remap, or set it to `""` to remove the key entirely), and every command palette command is listed under `[keymaps.commands]` (`"note.trash" = "Mod+Backspace"`, or `"note.archive" = "Leader x a"` for a leader sequence; a command id written straight under `[keymaps]` is read as a command key too and moved to that table on the next save), so you can discover and change anything without opening the app. That list reads the same on every OS: where macOS has its own default, the line notes it, as in `# "tabs.select1" = "Alt+1" # Go to tab 1 (macOS: "Ctrl+1")`, so a file synced between a Mac and a Linux or Windows machine changes only when you change a setting. Settings → About has Reveal and Copy-path buttons. Existing setups are written out automatically the first time you launch this version, and edits to the file, by hand or via a synced dotfile, apply live without a restart. Machine-specific layout (window size, pane widths, collapsed folders) stays local so the file does not churn.' }, { label: 'Lumary Labs', detail: 'The About section links to Lumary Labs at lumarylabs.com so company details stay easy to find from inside the app.' } ] } From 865c14d085a83a280627dc15c412c474bad6bbe5 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 13:26:54 -0500 Subject: [PATCH 20/34] Fix(keymap): a Mod chord stays Mod, and list views hear the chords their actions are bound to (#972) #972 found two ways a list view's binding went dead without a word. The sequence normalizer dropped `Mod`. `"nav.delete" = "Mod+Backspace"` was stored as a bare Backspace, so plain Backspace started deleting in the Archive and Trash views (behind their confirms) and Cmd+Backspace did nothing. Keydowns never spell `Mod` (they say Ctrl, Alt, Meta), which is why it was thrown away. Now the stored token keeps `Mod`, the portable "Cmd on macOS, Ctrl elsewhere" that the shortcut format already uses, so one config.toml means the same chord on a Mac and a Linux box; matching resolves it per platform (resolveSequenceToken, applied by getSequenceTokens and advanceSequence for every caller). A letter under Mod is uppercased like one under Ctrl. Vim's sequence form follows: Mod is <D-> on macOS and <C-> elsewhere, where it used to be <D-> everywhere. Entries already saved as the bare key stay as they are; nothing guesses which ones were meant to carry Mod. Then every list view opened with `if (e.metaKey || e.ctrlKey || e.altKey) return`, ahead of its nav.* checks, so a chord bound to one of its actions never reached it. One helper replaces that bail (lib/list-view-keys.ts): - a typed key reaches the view exactly as before, so bare x, d, j and k are untouched; - a chord reaches it only when it is the key of one of the view's own actions, and never when a global or tab shortcut, or with Vim on the pane prefix, holds it: those keep their key everywhere; - a chord never answers the view's hard-coded keys, so a binding on Alt+ArrowDown runs its action instead of moving the cursor; - with Vim off typed keys stay off, as the Vim-off rule says, while a chord bound to an action works in either mode. Converted: Archive, Trash, Quick Notes, Tags, Tasks (list), Unresolved Links, the Timeline panel and the Workflows list. Left alone on purpose, because none of them answers a bindable action and their letters are hard-coded: the Kanban board, the Tasks calendar, the calendar panel, the database grid, the Workflows canvas (its own Cmd+A comes before the bail) and the icon and color pickers, which are modals. AtlasView, What's New, the context menu, the date picker and the editor helpers keep theirs too. A binding that can still never fire is now said out loud instead of saved silently. Settings, Keymap marks the row "Never fires: Search notes has this key", and the recorder warns before Save; it does not refuse, since the user may be about to move the other key. The recorder also records a Backspace or Delete held with Ctrl, Alt or Cmd: Backspace always cleared the recording, so Mod+Backspace could not be recorded at all. A bare Backspace still clears. Tests: tokens with and without Mod on macOS, Linux and Windows, the bare entry left bare, resolved sequences and Vim sequences, the shared guard (typed keys, unrelated chords, a user chord, a global shortcut and the pane prefix in the way), and the Settings warning. --- .../app-core/src/components/ArchiveView.tsx | 37 +++- .../src/components/KeymapRecorder.tsx | 24 ++- .../src/components/QuickNotesView.tsx | 35 +++- .../app-core/src/components/SettingsModal.tsx | 18 ++ packages/app-core/src/components/TagView.tsx | 37 +++- .../app-core/src/components/TasksView.tsx | 39 +++- .../app-core/src/components/TimelinePanel.tsx | 50 +++-- .../app-core/src/components/TrashView.tsx | 36 +++- .../src/components/UnresolvedLinksView.tsx | 33 ++- .../app-core/src/components/WorkflowsView.tsx | 53 +++-- packages/app-core/src/lib/command-keymaps.ts | 4 +- packages/app-core/src/lib/keymaps.test.ts | 118 ++++++++++- packages/app-core/src/lib/keymaps.ts | 64 +++++- .../app-core/src/lib/list-view-keys.test.ts | 197 ++++++++++++++++++ packages/app-core/src/lib/list-view-keys.ts | 179 ++++++++++++++++ packages/app-core/src/lib/vim-key-sequence.ts | 11 +- 16 files changed, 844 insertions(+), 91 deletions(-) create mode 100644 packages/app-core/src/lib/list-view-keys.test.ts create mode 100644 packages/app-core/src/lib/list-view-keys.ts diff --git a/packages/app-core/src/components/ArchiveView.tsx b/packages/app-core/src/components/ArchiveView.tsx index 58cbf7713..63e174331 100644 --- a/packages/app-core/src/components/ArchiveView.tsx +++ b/packages/app-core/src/components/ArchiveView.tsx @@ -8,10 +8,29 @@ import { CollectionViewHeader } from './CollectionViewHeader' import { ContextMenu } from './ContextMenu' import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from '../lib/move-note' import { promptApp } from '../lib/prompt-requests' -import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' +import { advanceSequence, getKeymapBinding, type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isAppOverlayOpen } from '../lib/overlay-open' +// Every action this view answers, so a chord bound to one reaches it. +const ARCHIVE_VIEW_ACTIONS: readonly KeymapId[] = [ + 'nav.filter', + 'nav.contextMenu', + 'nav.moveDown', + 'nav.moveUp', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.openResult', + 'nav.unarchive', + 'nav.delete' +] + function formatDate(ms: number): string { const d = new Date(ms) const now = new Date() @@ -277,14 +296,16 @@ export function ArchiveView(): JSX.Element { const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } - if (e.metaKey || e.ctrlKey || e.altKey) return - - const key = e.key const overrides = keymapOverrides + // A chord gets through only as the key of one of this view's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(e, overrides, ARCHIVE_VIEW_ACTIONS, vimMode)) return + + const key = listViewKey(e) // When Vim mode is off, the single-key Vim shortcuts (j/k/x/d/gg/G/o/r//…) - // are disabled — only arrows/Enter/Escape navigate. (songgenqing report) - const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => - vimMode && matchesSequenceToken(e, overrides, id) + // are disabled: only arrows/Enter/Escape navigate, plus any action the + // user bound to a chord. (songgenqing report) + const seq = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -330,7 +351,7 @@ export function ArchiveView(): JSX.Element { return } if ( - vimMode && + listViewSequencesLive(e, vimMode) && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), diff --git a/packages/app-core/src/components/KeymapRecorder.tsx b/packages/app-core/src/components/KeymapRecorder.tsx index e6f8c406e..bbee3d7dc 100644 --- a/packages/app-core/src/components/KeymapRecorder.tsx +++ b/packages/app-core/src/components/KeymapRecorder.tsx @@ -5,6 +5,7 @@ import { getDefaultKeymapBinding, getKeymapBinding, getKeymapDisplay, + isChordEvent, isMacPlatform, isUnboundBinding, sequenceTokenFromEvent, @@ -26,6 +27,7 @@ import { type CommandKeymapConflict, type CommandKeymaps } from '../lib/command-keymaps' +import { keymapBindingTakenBy } from '../lib/list-view-keys' import { Button } from './ui/Button' import { Modal } from './ui/Modal' @@ -77,6 +79,7 @@ export function KeymapRecorderModal({ definition={target.definition} overrides={overrides} commandKeymaps={commandKeymaps} + vimMode={vimMode} onClose={onClose} onSave={onSave} onUnbind={onUnbind} @@ -211,6 +214,7 @@ function ActionRecorder({ definition, overrides, commandKeymaps, + vimMode, onClose, onSave, onUnbind @@ -218,6 +222,7 @@ function ActionRecorder({ definition: KeymapDefinition overrides: KeymapOverrides commandKeymaps: CommandKeymaps + vimMode: boolean onClose: () => void onSave: (binding: string) => void onUnbind: () => void @@ -235,13 +240,22 @@ function ActionRecorder({ : [] const leaderKey = definition.scope === 'leader' const blockingCommand = leaderKey ? null : commandHits[0] ?? null + // A list action's chord that an app-wide key takes first never fires + // (#972). Said before Save, not refused: the user may be about to move the + // other key. + const takenBy = binding + ? keymapBindingTakenBy(overrides, definition.id, binding, vimMode) + : null useEffect(() => { const onKey = (event: KeyboardEvent): void => { const target = event.target as HTMLElement | null if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return const key = event.key - if (key === 'Backspace' || key === 'Delete') { + // A bare Backspace or Delete edits the recording; with Ctrl, Alt or Cmd + // held it is a key to record. Mod+Backspace for Delete selected result + // could not be recorded at all (#972). + if ((key === 'Backspace' || key === 'Delete') && !isChordEvent(event)) { event.preventDefault() event.stopPropagation() if (definition.kind === 'shortcut') { @@ -282,8 +296,14 @@ function ActionRecorder({ }, [definition]) const notices = - conflict || blockingCommand || (leaderKey && commandHits.length > 0) ? ( + conflict || blockingCommand || takenBy || (leaderKey && commandHits.length > 0) ? ( <> + {takenBy && !conflict && !blockingCommand && ( + <Notice tone="warning"> + <strong className="font-semibold">{takenBy.title}</strong> has this key everywhere in + the app, so it would never reach this action. Pick another key, or move that one first. + </Notice> + )} {conflict && ( <Notice tone="danger"> Already used by <strong className="font-semibold">{conflict.title}</strong>. Two global diff --git a/packages/app-core/src/components/QuickNotesView.tsx b/packages/app-core/src/components/QuickNotesView.tsx index 4ebf09757..1b45310ba 100644 --- a/packages/app-core/src/components/QuickNotesView.tsx +++ b/packages/app-core/src/components/QuickNotesView.tsx @@ -3,10 +3,27 @@ import { isQuickNotesViewActive, useStore } from '../store' import { ArrowUpRightIcon, PlusIcon, ZapIcon } from './icons' import { CollectionViewHeader } from './CollectionViewHeader' import { resolveQuickNoteTitle } from '../lib/quick-note-title' -import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' +import { advanceSequence, getKeymapBinding, type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { isAppOverlayOpen } from '../lib/overlay-open' +// Every action this view answers, so a chord bound to one reaches it. +const QUICK_NOTES_VIEW_ACTIONS: readonly KeymapId[] = [ + 'nav.filter', + 'nav.newQuickNote', + 'nav.moveDown', + 'nav.moveUp', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.openResult' +] + function formatDate(ms: number): string { const d = new Date(ms) const now = new Date() @@ -107,14 +124,16 @@ export function QuickNotesView(): JSX.Element { const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } - if (e.metaKey || e.ctrlKey || e.altKey) return - - const key = e.key const overrides = keymapOverrides + // A chord gets through only as the key of one of this view's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(e, overrides, QUICK_NOTES_VIEW_ACTIONS, vimMode)) return + + const key = listViewKey(e) // When Vim mode is off, the single-key Vim shortcuts (j/k/gg/G/o//…) are - // disabled — only arrows/Enter/Escape navigate. (songgenqing report) - const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => - vimMode && matchesSequenceToken(e, overrides, id) + // disabled: only arrows/Enter/Escape navigate, plus any action the user + // bound to a chord. (songgenqing report) + const seq = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -160,7 +179,7 @@ export function QuickNotesView(): JSX.Element { return } if ( - vimMode && + listViewSequencesLive(e, vimMode) && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index d6e9c1c8d..96f0f8fbf 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -73,6 +73,7 @@ import { UNBOUND_BINDING, UNBOUND_LABEL, } from "../lib/keymaps"; +import { keymapBindingTakenBy } from "../lib/list-view-keys"; import { COMMAND_CATALOG } from "@shared/commands-catalog"; import { commandBindingKind, @@ -6589,6 +6590,14 @@ function KeymapSettings({ const inactive = (definition.vimOnly && !vimMode) || (definition.nonVimOnly && vimMode); + // A list action's chord that an app-wide key takes first + // can never fire where the action lives (#972). + const takenBy = keymapBindingTakenBy( + overrides, + definition.id, + current, + vimMode, + ); // The binding and its buttons wrap under the title on a // narrow pane instead of squeezing it to a word per line. return ( @@ -6624,6 +6633,15 @@ function KeymapSettings({ Conflicts with {conflict.title} </span> )} + {takenBy && ( + <span + data-keymap-conflict="never-fires" + className={`${KEYMAP_CHIP} border-warning/35 bg-warning/10 text-ink-700`} + title={`“${takenBy.title}” has this key everywhere in the app, so it never reaches this action. Pick another key, or move that one.`} + > + Never fires: {takenBy.title} has this key + </span> + )} {commandHits.map((hit) => definition.scope === "leader" ? ( <span diff --git a/packages/app-core/src/components/TagView.tsx b/packages/app-core/src/components/TagView.tsx index bee66312d..0578f84c0 100644 --- a/packages/app-core/src/components/TagView.tsx +++ b/packages/app-core/src/components/TagView.tsx @@ -14,11 +14,30 @@ import { resolveTypstPreambleFolder } from '../lib/typst-preamble' import { TagIcon, CloseIcon, DocumentIcon } from './icons' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { TaggedBlockText } from './TaggedBlockText' -import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' +import { advanceSequence, getKeymapBinding, type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' import { isPrimaryNotesAtRoot, noteFolderSubpath } from '../lib/vault-layout' import { isImeComposing } from '../lib/ime' import { isAppOverlayOpen } from '../lib/overlay-open' +// Every action this view answers, so a chord bound to one reaches it. +const TAG_VIEW_ACTIONS: readonly KeymapId[] = [ + 'nav.filter', + 'nav.localEx', + 'nav.moveDown', + 'nav.moveUp', + 'nav.jumpBottom', + 'tags.nextNote', + 'tags.previousNote', + 'nav.jumpTop', + 'nav.openResult' +] + function formatDate(ms: number): string { const d = new Date(ms) const now = new Date() @@ -307,14 +326,16 @@ export function TagView(): JSX.Element { const t = focused.tagName if (t === 'INPUT' || t === 'TEXTAREA' || focused.isContentEditable) return } - if (e.metaKey || e.ctrlKey || e.altKey) return - - const k = e.key const overrides = keymapOverrides + // A chord gets through only as the key of one of this view's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(e, overrides, TAG_VIEW_ACTIONS, vimMode)) return + + const k = listViewKey(e) // When Vim mode is off, the single-key Vim shortcuts (j/k/gg/G/o//…) are - // disabled — only arrows/Enter/Escape navigate. (songgenqing report) - const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => - vimMode && matchesSequenceToken(e, overrides, id) + // disabled: only arrows/Enter/Escape navigate, plus any action the user + // bound to a chord. (songgenqing report) + const seq = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -370,7 +391,7 @@ export function TagView(): JSX.Element { return } if ( - vimMode && + listViewSequencesLive(e, vimMode) && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 97a02fdbb..285d3cc7f 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -8,7 +8,13 @@ import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' import { TasksKanban } from './TasksKanban' import { CalendarIcon, CheckSquareIcon, KanbanIcon, ListIcon } from './icons' -import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' +import { advanceSequence, getKeymapBinding, matchesSequenceToken, type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems, chooseTaskDueDate } from '../lib/task-context-menu' import { isImeComposing } from '../lib/ime' @@ -17,6 +23,21 @@ import { promptApp } from '../lib/prompt-requests' import { useToastStore } from '../lib/toast' import { findSavedTaskFilterName, savedTaskFilterNameForQuery } from '../lib/saved-task-filters' +// Every action this view answers, so a chord bound to one reaches it. +const TASKS_VIEW_ACTIONS: readonly KeymapId[] = [ + 'nav.filter', + 'tasks.savedFilters', + 'nav.localEx', + 'nav.moveDown', + 'nav.moveUp', + 'tasks.moveTaskUp', + 'tasks.moveTaskDown', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.openResult', + 'nav.toggleTask' +] + type GroupKey = 'today' | 'upcoming' | 'undated' | 'waiting' | 'forwarded' | 'done' | 'cancelled' const GROUP_LABELS: Record<GroupKey, string> = { @@ -641,14 +662,16 @@ export function TasksView(): JSX.Element { const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } - if (e.metaKey || e.ctrlKey || e.altKey) return - - const key = e.key const overrides = keymapOverrides + // A chord gets through only as the key of one of this view's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(e, overrides, TASKS_VIEW_ACTIONS, vimMode)) return + + const key = listViewKey(e) // When Vim mode is off, the single-key Vim shortcuts (j/k/gg/G/o/Space/1-3/…) - // are disabled — only arrows/Enter/Escape navigate. (songgenqing report) - const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => - vimMode && matchesSequenceToken(e, overrides, id) + // are disabled: only arrows/Enter/Escape navigate, plus any action the + // user bound to a chord. (songgenqing report) + const seq = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -753,7 +776,7 @@ export function TasksView(): JSX.Element { return } if ( - vimMode && + listViewSequencesLive(e, vimMode) && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), diff --git a/packages/app-core/src/components/TimelinePanel.tsx b/packages/app-core/src/components/TimelinePanel.tsx index f28153d9d..36503e1a5 100644 --- a/packages/app-core/src/components/TimelinePanel.tsx +++ b/packages/app-core/src/components/TimelinePanel.tsx @@ -34,9 +34,12 @@ import { import { getKeymapDisplay, getSequenceTokens, + isChordEvent, matchesSequenceToken, - sequenceTokenFromEvent + sequenceTokenFromEvent, + type KeymapId } from '../lib/keymaps' +import { listViewBindingMatches, listViewKey, listViewYieldsKey } from '../lib/list-view-keys' import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { buildTaskMenuItems } from '../lib/task-context-menu' import { taskTail } from '../lib/task-tail' @@ -51,6 +54,16 @@ import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { Button } from './ui/Button' const HOVER_DELAY_MS = 280 +// Every action the panel answers, so a chord bound to one reaches it. +const TIMELINE_PANEL_ACTIONS: readonly KeymapId[] = [ + 'nav.moveDown', + 'nav.moveUp', + 'nav.contextMenu', + 'nav.toggleTask', + 'nav.filter', + 'nav.jumpBottom', + 'nav.jumpTop' +] /** How long the rail's task list counts as fresh after the panel closes. A * pane remounts its panels while a newly opened note loads, and a full vault * scan on each of those would be waste; past this, edits made while no task @@ -265,15 +278,20 @@ export function TimelinePanel({ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return } - if (event.metaKey || event.ctrlKey || event.altKey) return + const native = event.nativeEvent + // A chord gets through only as the key of one of the panel's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(native, keymapOverrides, TIMELINE_PANEL_ACTIONS, vimMode)) return + // Empty for a chord, so the hard-coded keys below never answer one. + const key = listViewKey(native) // A focused control keeps its own Enter and Space. const onControl = target !== panel && !!target.closest('button, a, [role="checkbox"]') - if (onControl && (event.key === 'Enter' || event.key === ' ')) return + if (onControl && (key === 'Enter' || key === ' ')) return // With Vim off the single-character keys are not the panel's: arrows, - // Home/End, Enter and Escape are universal, the letters belong to Vim. - if (!vimMode && event.key.length === 1) return + // Home/End, Enter and Escape are universal, the letters belong to Vim. A + // chord bound to one of the panel's actions works in either mode. + if (!vimMode && !isChordEvent(native) && key.length === 1) return - const native = event.nativeEvent const rows = rail.rows const current: TimelineRow | undefined = rows[cursorIdx] const consume = (): void => { @@ -285,35 +303,35 @@ export function TimelinePanel({ setCursor(Math.max(0, Math.min(rows.length - 1, to))) } - if (event.key === 'Escape' || event.key === 'ArrowLeft') { + if (key === 'Escape' || key === 'ArrowLeft') { consume() focusEditor() return } - if (event.key === 'ArrowDown' || (vimMode && matchesSequenceToken(native, keymapOverrides, 'nav.moveDown'))) { + if (key === 'ArrowDown' || listViewBindingMatches(native, keymapOverrides, 'nav.moveDown', vimMode)) { consume() move(cursorIdx + 1) return } - if (event.key === 'ArrowUp' || (vimMode && matchesSequenceToken(native, keymapOverrides, 'nav.moveUp'))) { + if (key === 'ArrowUp' || listViewBindingMatches(native, keymapOverrides, 'nav.moveUp', vimMode)) { consume() move(cursorIdx - 1) return } - if (event.key === 'Home' || event.key === 'End') { + if (key === 'Home' || key === 'End') { consume() - move(event.key === 'Home' ? 0 : rows.length - 1) + move(key === 'Home' ? 0 : rows.length - 1) return } - if (event.key === 'Enter') { + if (key === 'Enter') { consume() if (current) void openTaskAt(current.task) return } if ( - (event.shiftKey && event.key === 'F10') || - event.key === 'ContextMenu' || - (vimMode && matchesSequenceToken(native, keymapOverrides, 'nav.contextMenu')) + (event.shiftKey && key === 'F10') || + key === 'ContextMenu' || + listViewBindingMatches(native, keymapOverrides, 'nav.contextMenu', vimMode) ) { consume() if (!current) return @@ -321,7 +339,7 @@ export function TimelinePanel({ openMenuAt(row ? row.left + 24 : 0, row ? row.bottom : 0, current.task) return } - if (!vimMode) return + if (!vimMode && !isChordEvent(native)) return if (matchesSequenceToken(native, keymapOverrides, 'nav.toggleTask')) { consume() diff --git a/packages/app-core/src/components/TrashView.tsx b/packages/app-core/src/components/TrashView.tsx index 9d132b99a..ca33ab22b 100644 --- a/packages/app-core/src/components/TrashView.tsx +++ b/packages/app-core/src/components/TrashView.tsx @@ -4,11 +4,29 @@ import type { DeletedAsset, NoteMeta } from '@shared/ipc' import { isTrashViewActive, useStore } from '../store' import { ArrowUpRightIcon, TrashIcon } from './icons' import { CollectionViewHeader } from './CollectionViewHeader' -import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' +import { advanceSequence, getKeymapBinding, type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { confirmApp } from '../lib/confirm-requests' import { isAppOverlayOpen } from '../lib/overlay-open' +// Every action this view answers, so a chord bound to one reaches it. +const TRASH_VIEW_ACTIONS: readonly KeymapId[] = [ + 'nav.filter', + 'nav.moveDown', + 'nav.moveUp', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.openResult', + 'nav.restore', + 'nav.delete' +] + function formatDate(ms: number): string { const d = new Date(ms) const now = new Date() @@ -185,14 +203,16 @@ export function TrashView(): JSX.Element { const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } - if (e.metaKey || e.ctrlKey || e.altKey) return - - const key = e.key const overrides = keymapOverrides + // A chord gets through only as the key of one of this view's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(e, overrides, TRASH_VIEW_ACTIONS, vimMode)) return + + const key = listViewKey(e) // When Vim mode is off, the single-key Vim shortcuts (j/k/x/d/gg/G/o/r//…) - // are disabled — only arrows/Enter/Escape navigate. (songgenqing report) - const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => - vimMode && matchesSequenceToken(e, overrides, id) + // are disabled: only arrows/Enter/Escape navigate, plus any action the + // user bound to a chord. (songgenqing report) + const seq = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -232,7 +252,7 @@ export function TrashView(): JSX.Element { return } if ( - vimMode && + listViewSequencesLive(e, vimMode) && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), diff --git a/packages/app-core/src/components/UnresolvedLinksView.tsx b/packages/app-core/src/components/UnresolvedLinksView.tsx index b6e4d4803..4fd4b2dde 100644 --- a/packages/app-core/src/components/UnresolvedLinksView.tsx +++ b/packages/app-core/src/components/UnresolvedLinksView.tsx @@ -16,13 +16,30 @@ import { getKeymapBinding, getKeymapDisplay, matchesSequenceToken, - matchesShortcut + matchesShortcut, + type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' import { linkCreateDestination } from '../lib/link-popover' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { Button } from './ui' import { LinkIcon, SearchIcon } from './icons' +// Every action this view answers, so a chord bound to one reaches it. +const UNRESOLVED_LINKS_VIEW_ACTIONS: readonly KeymapId[] = [ + 'nav.filter', + 'nav.moveDown', + 'nav.moveUp', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.openResult' +] + /** * The Unresolved Links view: every `[[wikilink]]` in the vault that names a * note no one has written yet, with the notes and lines that link to it. @@ -269,11 +286,13 @@ export function UnresolvedLinksView(): JSX.Element { writeInPopover(current) return } - if (e.metaKey || e.ctrlKey || e.altKey) return - // With Vim off, single-key shortcuts stay off: arrows, Enter, Escape. - const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => - vimMode && matchesSequenceToken(e, overrides, id) - const key = e.key + // A chord gets through only as the key of one of this view's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(e, overrides, UNRESOLVED_LINKS_VIEW_ACTIONS, vimMode)) return + // With Vim off, single-key shortcuts stay off: arrows, Enter, Escape, + // plus any action the user bound to a chord. + const seq = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) + const key = listViewKey(e) if (key === 'Escape') { if (!filter) return consume() @@ -302,7 +321,7 @@ export function UnresolvedLinksView(): JSX.Element { return } if ( - vimMode && + listViewSequencesLive(e, vimMode) && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), diff --git a/packages/app-core/src/components/WorkflowsView.tsx b/packages/app-core/src/components/WorkflowsView.tsx index 34829c471..d091905d5 100644 --- a/packages/app-core/src/components/WorkflowsView.tsx +++ b/packages/app-core/src/components/WorkflowsView.tsx @@ -95,8 +95,14 @@ import { formatKeyToken, getKeymapBinding, isMacPlatform, - matchesSequenceToken + type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' // One way to open a context menu from the keyboard, shared with the sidebar and // the tab strip: the row's own `contextmenu` handler answers, so the mouse and @@ -176,6 +182,16 @@ import { CATEGORY_TITLE } from './workflows/node-presentation' +// Every action the workflow list answers, so a chord bound to one reaches it. +const WORKFLOWS_LIST_ACTIONS: readonly KeymapId[] = [ + 'nav.moveDown', + 'nav.moveUp', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.delete', + 'nav.openResult' +] + /* -------------------------------------------------------------------------- */ /* Grid */ /* -------------------------------------------------------------------------- */ @@ -4206,29 +4222,34 @@ export function WorkflowsView(): JSX.Element { // The list owns its own keys rather than a window listener: this view can be // open in two panes, and only the focused list should move. const onListKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => { - if (event.metaKey || event.ctrlKey || event.altKey) return const native = event.nativeEvent + // A chord gets through only as the key of one of the list's actions, and + // never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(native, keymapOverrides, WORKFLOWS_LIST_ACTIONS, vimMode)) return + // Empty for a chord, so the hard-coded keys below never answer one. + const pressed = listViewKey(native) const consume = (): void => { event.preventDefault() event.stopPropagation() } // Vim off means the single-key shortcuts are off; arrows and Enter always - // work, so the list is never mouse-only. - const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => - vimMode && matchesSequenceToken(native, keymapOverrides, id) + // work, so the list is never mouse-only. A chord bound to an action works + // in either mode. + const seq = (id: KeymapId): boolean => + listViewBindingMatches(native, keymapOverrides, id, vimMode) // The authoring keys are plain letters, so they follow the same rule: with // Vim off they are off, and the header hint stops offering them. - const letter = (key: string): boolean => vimMode && !event.shiftKey && event.key === key + const letter = (key: string): boolean => vimMode && !event.shiftKey && pressed === key // `R` runs, `r` reloads. Shift is the entire difference between the two, so // they are matched apart and neither can ever fire the other. - const shifted = (key: string): boolean => vimMode && event.shiftKey && event.key === key + const shifted = (key: string): boolean => vimMode && event.shiftKey && pressed === key - if (event.key === 'ArrowDown' || seq('nav.moveDown')) { + if (pressed === 'ArrowDown' || seq('nav.moveDown')) { consume() moveCursor(1) return } - if (event.key === 'ArrowUp' || seq('nav.moveUp')) { + if (pressed === 'ArrowUp' || seq('nav.moveUp')) { consume() moveCursor(-1) return @@ -4236,24 +4257,24 @@ export function WorkflowsView(): JSX.Element { // Escape closes a panel and leaves the workflow alone. Never handled when // there is nothing open, so it still falls through to whatever owns Escape // outside this view. - if (event.key === 'Escape') { + if (pressed === 'Escape') { if (selectedNodeId === null) return consume() setSelectedNodeId(null) return } - if (event.key === 'Home') { + if (pressed === 'Home') { consume() setCursor(0) return } - if (event.key === 'End' || seq('nav.jumpBottom')) { + if (pressed === 'End' || seq('nav.jumpBottom')) { consume() setCursor(Math.max(0, list.length - 1)) return } if ( - vimMode && + listViewSequencesLive(native, vimMode) && advanceSequence( native, getKeymapBinding(keymapOverrides, 'nav.jumpTop'), @@ -4271,7 +4292,7 @@ export function WorkflowsView(): JSX.Element { // every other letter; the OS keys do NOT, because with Vim off they are the // only way to reach Duplicate and Reveal without a mouse, and this view may // never become mouse-only. - if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10') || letter('m')) { + if (pressed === 'ContextMenu' || (event.shiftKey && pressed === 'F10') || letter('m')) { consume() openRowMenuAtCursor() return @@ -4298,7 +4319,7 @@ export function WorkflowsView(): JSX.Element { } // `?` is the reference. Shifted like `R`, and Vim-gated like every other // single-key shortcut in this view. - if (vimMode && event.key === '?') { + if (vimMode && pressed === '?') { consume() setReferenceOpen((open) => !open) return @@ -4348,7 +4369,7 @@ export function WorkflowsView(): JSX.Element { if (item) void removeWorkflow(item) return } - if (event.key === 'Enter' || seq('nav.openResult')) { + if (pressed === 'Enter' || seq('nav.openResult')) { consume() const item = list[cursor] if (!item) return diff --git a/packages/app-core/src/lib/command-keymaps.ts b/packages/app-core/src/lib/command-keymaps.ts index 4aa103a46..f25aad562 100644 --- a/packages/app-core/src/lib/command-keymaps.ts +++ b/packages/app-core/src/lib/command-keymaps.ts @@ -31,6 +31,7 @@ import { isUnboundBinding, matchesShortcutBinding, normalizeSequenceToken, + resolveSequenceToken, normalizeShortcutBinding, UNBOUND_BINDING, type KeymapDefinition, @@ -108,7 +109,8 @@ export function parseCommandBinding( const leader = leaderTokenOf(overrides) const first = tokens[0] const startsWithLeader = - isLeaderWord(first) || (!!leader && normalizeSequenceToken(first) === leader && tokens.length > 1) + isLeaderWord(first) || + (!!leader && resolveSequenceToken(normalizeSequenceToken(first) ?? '') === leader && tokens.length > 1) if (startsWithLeader) { const keys = tokens.slice(1) if (keys.length === 0) { diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 68c474b8a..410749a27 100644 --- a/packages/app-core/src/lib/keymaps.test.ts +++ b/packages/app-core/src/lib/keymaps.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { advanceSequence, + appShortcutForEvent, eventMatchesUserOverride, findKeymapConflict, getDefaultKeymapBinding, @@ -15,11 +16,15 @@ import { matchesSequenceToken, matchesShortcut, matchesShortcutBinding, + normalizeKeymapBinding, normalizeKeymapOverrides, + normalizeSequenceToken, normalizeShortcutBinding, + resolveSequenceToken, shortcutBindingFromEvent, sequenceTokenFromEvent } from './keymaps' +import { toVimSequence } from './vim-key-sequence' interface FakeEventInit { key: string @@ -648,3 +653,114 @@ describe('Timeline panel keymaps (#1007)', () => { }) }) }) + +describe('Mod in sequence bindings (#972)', () => { + const cmdBackspace = (): KeyboardEvent => + fakeEvent({ key: 'Backspace', code: 'Backspace', metaKey: true }) + const ctrlBackspace = (): KeyboardEvent => + fakeEvent({ key: 'Backspace', code: 'Backspace', ctrlKey: true }) + + it('keeps Mod in the stored token instead of dropping it to a bare key', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + withPlatform(platform, () => { + expect(normalizeSequenceToken('Mod+Backspace')).toBe('Mod+Backspace') + expect(normalizeKeymapBinding('nav.delete', 'Mod+Backspace')).toBe('Mod+Backspace') + // A letter under Mod is canonical uppercase, like under Ctrl. + expect(normalizeSequenceToken('Mod+d')).toBe('Mod+D') + expect(normalizeSequenceToken('Shift+Mod+Backspace')).toBe('Shift+Mod+Backspace') + }) + } + }) + + it('leaves tokens without Mod exactly as before', () => { + expect(normalizeSequenceToken('x')).toBe('x') + expect(normalizeSequenceToken('G')).toBe('G') + expect(normalizeSequenceToken('Ctrl+w')).toBe('Ctrl+W') + expect(normalizeSequenceToken('Cmd+Backspace')).toBe('Meta+Backspace') + expect(normalizeSequenceToken('Backspace')).toBe('Backspace') + }) + + it('resolves Mod to Cmd on macOS and Ctrl elsewhere when matching', () => { + expect(resolveSequenceToken('Mod+Backspace', true)).toBe('Meta+Backspace') + expect(resolveSequenceToken('Mod+Backspace', false)).toBe('Ctrl+Backspace') + expect(resolveSequenceToken('Shift+Mod+K', true)).toBe('Shift+Meta+K') + expect(resolveSequenceToken('Alt+Mod+K', false)).toBe('Ctrl+Alt+K') + expect(resolveSequenceToken('Ctrl+Mod+K', false)).toBe('Ctrl+K') + expect(resolveSequenceToken('x', true)).toBe('x') + expect(resolveSequenceToken('Ctrl+W', true)).toBe('Ctrl+W') + }) + + it('matches Cmd+Backspace on macOS and Ctrl+Backspace on Linux and Windows', () => { + const overrides = normalizeKeymapOverrides({ 'nav.delete': 'Mod+Backspace' }) + expect(overrides).toEqual({ 'nav.delete': 'Mod+Backspace' }) + withPlatform('darwin', () => { + expect(getSequenceTokens(overrides, 'nav.delete')).toEqual(['Meta+Backspace']) + expect(matchesSequenceToken(cmdBackspace(), overrides, 'nav.delete')).toBe(true) + expect(matchesSequenceToken(ctrlBackspace(), overrides, 'nav.delete')).toBe(false) + // A plain Backspace is no longer the key. + const plain = fakeEvent({ key: 'Backspace', code: 'Backspace' }) + expect(matchesSequenceToken(plain, overrides, 'nav.delete')).toBe(false) + }) + for (const platform of ['linux', 'win32'] as const) { + withPlatform(platform, () => { + expect(getSequenceTokens(overrides, 'nav.delete')).toEqual(['Ctrl+Backspace']) + expect(matchesSequenceToken(ctrlBackspace(), overrides, 'nav.delete')).toBe(true) + expect(matchesSequenceToken(cmdBackspace(), overrides, 'nav.delete')).toBe(false) + }) + } + }) + + it('keeps an entry already saved as the bare key bare, and explicit Ctrl/Cmd spellings as written', () => { + withPlatform('darwin', () => { + const bare = normalizeKeymapOverrides({ 'nav.delete': 'Backspace' }) + expect(bare).toEqual({ 'nav.delete': 'Backspace' }) + const plain = fakeEvent({ key: 'Backspace', code: 'Backspace' }) + expect(matchesSequenceToken(plain, bare, 'nav.delete')).toBe(true) + expect(matchesSequenceToken(cmdBackspace(), bare, 'nav.delete')).toBe(false) + const ctrl = normalizeKeymapOverrides({ 'nav.delete': 'Ctrl+Backspace' }) + expect(matchesSequenceToken(ctrlBackspace(), ctrl, 'nav.delete')).toBe(true) + const cmd = normalizeKeymapOverrides({ 'nav.delete': 'Cmd+Backspace' }) + expect(cmd).toEqual({ 'nav.delete': 'Meta+Backspace' }) + expect(matchesSequenceToken(cmdBackspace(), cmd, 'nav.delete')).toBe(true) + }) + }) + + it('event tokens never spell Mod, on either platform', () => { + withPlatform('darwin', () => { + expect(sequenceTokenFromEvent(cmdBackspace())).toBe('Meta+Backspace') + expect(sequenceTokenFromEvent(ctrlBackspace())).toBe('Ctrl+Backspace') + }) + withPlatform('linux', () => { + expect(sequenceTokenFromEvent(ctrlBackspace())).toBe('Ctrl+Backspace') + }) + }) + + it('walks a sequence whose first key carries Mod', () => { + withPlatform('linux', () => { + const pending = { current: 0 } + const timer: { current?: ReturnType<typeof setTimeout> } = {} + const onMatch = vi.fn() + const first = fakeEvent({ key: 'g', code: 'KeyG', ctrlKey: true }) + const second = fakeEvent({ key: 'g', code: 'KeyG' }) + expect(advanceSequence(first, 'Mod+G g', pending, timer, onMatch, () => {})).toBe(true) + expect(advanceSequence(second, 'Mod+G g', pending, timer, onMatch, () => {})).toBe(true) + expect(onMatch).toHaveBeenCalledTimes(1) + }) + }) + + it('names the global shortcut that has a chord', () => { + withPlatform('darwin', () => { + const cmdP = fakeEvent({ key: 'p', code: 'KeyP', metaKey: true }) + expect(appShortcutForEvent(cmdP, {})?.id).toBe('global.searchNotes') + expect(appShortcutForEvent(cmdBackspace(), {})).toBeNull() + // Moved off its key, Search notes no longer has it. + expect(appShortcutForEvent(cmdP, { 'global.searchNotes': '' })).toBeNull() + }) + }) + + it('hands Vim Cmd on macOS and Ctrl elsewhere for a Mod token', () => { + withPlatform('darwin', () => expect(toVimSequence('Mod+D')).toBe('<D-d>')) + withPlatform('linux', () => expect(toVimSequence('Mod+D')).toBe('<C-d>')) + withPlatform('linux', () => expect(toVimSequence('Ctrl+Mod+D')).toBe('<C-d>')) + }) +}) diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index be80026a8..b5d655537 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -1686,7 +1686,8 @@ export function getSequenceTokens( return getKeymapBinding(overrides, id) .split(/\s+/) .map((token) => token.trim()) - .filter(Boolean); + .filter(Boolean) + .map((token) => resolveSequenceToken(token)); } type RuntimeZenApi = { platformSync?: () => NodeJS.Platform }; @@ -1890,13 +1891,17 @@ export function normalizeSequenceToken(input: string): string | null { if (!rawKey) return null; let base = normalizeSequenceBaseToken(rawKey); if (!base) return null; + // `Mod` stays `Mod`: it is the portable spelling of "Cmd on macOS, Ctrl + // elsewhere", so a config.toml that says `"nav.delete" = "Mod+Backspace"` + // means the same chord on every machine it syncs to. It used to be dropped + // here, which stored that binding as a bare Backspace (#972); keydowns never + // spell `Mod`, so matching resolves it per platform (resolveSequenceToken). const modifiers = normalizeModifiers( parts .map((part) => normalizeModifierToken(part)) - .filter((part): part is NonNullable<typeof part> => !!part) - .filter((part) => part !== "Mod"), + .filter((part): part is NonNullable<typeof part> => !!part), ); - // When a non-Shift modifier (Ctrl/Alt/Meta) is combined with a single + // When a non-Shift modifier (Ctrl/Alt/Meta/Mod) is combined with a single // ASCII letter, canonicalize to uppercase. Event-produced tokens come // out lowercase (e.g. Ctrl+w → `'w'` since Shift isn't held), but the // convention in default bindings and user-written keymaps is uppercase @@ -1904,13 +1909,34 @@ export function normalizeSequenceToken(input: string): string | null { if ( base.length === 1 && /[a-zA-Z]/.test(base) && - modifiers.some((m) => m === "Ctrl" || m === "Alt" || m === "Meta") + modifiers.some( + (m) => m === "Ctrl" || m === "Alt" || m === "Meta" || m === "Mod", + ) ) { base = base.toUpperCase(); } return modifiers.length > 0 ? `${modifiers.join("+")}+${base}` : base; } +/** + * A stored sequence token as a keydown on this platform reads: `Mod` becomes + * `Meta` (Cmd) on macOS and `Ctrl` elsewhere, the way the shortcut format + * reads it. `sequenceTokenFromEvent` never produces `Mod`, so every place that + * compares a binding's token with an event's goes through this first + * (getSequenceTokens and advanceSequence do it for their callers). + */ +export function resolveSequenceToken(token: string, mac?: boolean): string { + const parts = token.split("+"); + if (parts.length < 2) return token; + const base = parts.pop() as string; + if (!parts.includes("Mod")) return token; + mac ??= isMacPlatform(); + const modifiers = normalizeModifiers( + parts.map((part) => (part === "Mod" ? (mac ? "Meta" : "Ctrl") : part)), + ); + return `${modifiers.join("+")}+${base}`; +} + export function normalizeSequenceBinding(input: string): string | null { const tokens = input .split(/\s+/) @@ -2108,6 +2134,31 @@ export function eventMatchesUserOverride( return false; } +/** + * The app-wide shortcut (an `app`-scope action: the global and tab + * shortcuts) this keydown is, under these overrides, or null. Surfaces that + * let a chord through to one of their own actions step aside for these, so + * a global shortcut keeps its key everywhere. + */ +export function appShortcutForEvent( + event: KeyboardEvent, + overrides: KeymapOverrides | null | undefined, +): KeymapDefinition | null { + for (const definition of KEYMAP_DEFINITIONS) { + if (definition.scope !== "app" || definition.kind !== "shortcut") continue; + if (matchesShortcut(event, overrides, definition.id)) return definition; + } + return null; +} + +/** Ctrl, Alt or Cmd/Meta is down: a chord, not a typed key. Shift alone + * types a capital, so it does not count. */ +export function isChordEvent( + event: Pick<KeyboardEvent, "ctrlKey" | "metaKey" | "altKey">, +): boolean { + return event.ctrlKey || event.metaKey || event.altKey; +} + export function matchesSequenceToken( event: KeyboardEvent, overrides: KeymapOverrides | null | undefined, @@ -2235,7 +2286,8 @@ export function advanceSequence( const tokens = binding .split(/\s+/) .map((token) => token.trim()) - .filter(Boolean); + .filter(Boolean) + .map((token) => resolveSequenceToken(token)); if (tokens.length === 0) return false; const token = sequenceTokenFromEvent(event); if (!token) return false; diff --git a/packages/app-core/src/lib/list-view-keys.test.ts b/packages/app-core/src/lib/list-view-keys.test.ts new file mode 100644 index 000000000..8a0004323 --- /dev/null +++ b/packages/app-core/src/lib/list-view-keys.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import type { KeymapId, KeymapOverrides } from './keymaps' +import { + chordHeldByApp, + keymapBindingTakenBy, + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from './list-view-keys' + +interface FakeEventInit { + key: string + code: string + ctrlKey?: boolean + metaKey?: boolean + altKey?: boolean + shiftKey?: boolean +} + +function fakeEvent(init: FakeEventInit): KeyboardEvent { + return { + key: init.key, + code: init.code, + ctrlKey: !!init.ctrlKey, + metaKey: !!init.metaKey, + altKey: !!init.altKey, + shiftKey: !!init.shiftKey + } as KeyboardEvent +} + +function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T { + const host = globalThis as typeof globalThis & { + window?: { zen?: { platformSync?: () => NodeJS.Platform } } + } + const previous = host.window + Object.defineProperty(host, 'window', { + value: { ...(previous ?? {}), zen: { platformSync: () => platform } }, + configurable: true + }) + try { + return run() + } finally { + if (previous === undefined) Reflect.deleteProperty(host, 'window') + else Object.defineProperty(host, 'window', { value: previous, configurable: true }) + } +} + +// The Archive view's actions, as ArchiveView lists them. +const ARCHIVE: readonly KeymapId[] = [ + 'nav.filter', + 'nav.contextMenu', + 'nav.moveDown', + 'nav.moveUp', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.openResult', + 'nav.unarchive', + 'nav.delete' +] + +const x = (): KeyboardEvent => fakeEvent({ key: 'x', code: 'KeyX' }) +const d = (): KeyboardEvent => fakeEvent({ key: 'd', code: 'KeyD' }) +const j = (): KeyboardEvent => fakeEvent({ key: 'j', code: 'KeyJ' }) +const cmdBackspace = (): KeyboardEvent => + fakeEvent({ key: 'Backspace', code: 'Backspace', metaKey: true }) +const ctrlBackspace = (): KeyboardEvent => + fakeEvent({ key: 'Backspace', code: 'Backspace', ctrlKey: true }) + +describe('listViewYieldsKey (#972)', () => { + it('never steps over a typed key, so bare x, d and j reach the view as before', () => { + withPlatform('darwin', () => { + for (const event of [x(), d(), j(), fakeEvent({ key: 'Escape', code: 'Escape' })]) { + expect(listViewYieldsKey(event, {}, ARCHIVE, true)).toBe(false) + expect(listViewYieldsKey(event, {}, ARCHIVE, false)).toBe(false) + } + }) + }) + + it('steps over a chord no action of the view has, as the old modifier bail did', () => { + withPlatform('darwin', () => { + expect(listViewYieldsKey(cmdBackspace(), {}, ARCHIVE, true)).toBe(true) + expect(listViewYieldsKey(fakeEvent({ key: 'c', code: 'KeyC', metaKey: true }), {}, ARCHIVE, true)).toBe(true) + }) + }) + + it("lets through a chord the user gave one of the view's actions, Vim on or off", () => { + const overrides: KeymapOverrides = { 'nav.delete': 'Mod+Backspace' } + withPlatform('darwin', () => { + expect(listViewYieldsKey(cmdBackspace(), overrides, ARCHIVE, true)).toBe(false) + expect(listViewYieldsKey(cmdBackspace(), overrides, ARCHIVE, false)).toBe(false) + expect(listViewYieldsKey(ctrlBackspace(), overrides, ARCHIVE, true)).toBe(true) + }) + withPlatform('linux', () => { + expect(listViewYieldsKey(ctrlBackspace(), overrides, ARCHIVE, true)).toBe(false) + }) + withPlatform('darwin', () => { + const ctrl = { 'nav.delete': 'Ctrl+Backspace' } + expect(listViewYieldsKey(ctrlBackspace(), ctrl, ARCHIVE, false)).toBe(false) + }) + }) + + it('lets through the first key of a sequence that starts on a chord', () => { + withPlatform('linux', () => { + const overrides = { 'nav.jumpTop': 'Ctrl+G g' } + const ctrlG = fakeEvent({ key: 'g', code: 'KeyG', ctrlKey: true }) + expect(listViewYieldsKey(ctrlG, overrides, ARCHIVE, true)).toBe(false) + }) + }) + + it("still steps aside for a global shortcut on the same chord", () => { + withPlatform('darwin', () => { + const overrides = { 'nav.delete': 'Mod+P' } + const cmdP = fakeEvent({ key: 'p', code: 'KeyP', metaKey: true }) + expect(listViewYieldsKey(cmdP, overrides, ARCHIVE, true)).toBe(true) + // Move Search notes off it and the view gets the key. + const moved = { ...overrides, 'global.searchNotes': 'Alt+Mod+P' } + expect(listViewYieldsKey(cmdP, moved, ARCHIVE, true)).toBe(false) + }) + }) + + it("steps aside for Vim's pane prefix while Vim is on", () => { + withPlatform('darwin', () => { + const overrides = { 'nav.delete': 'Ctrl+W' } + const ctrlW = fakeEvent({ key: 'w', code: 'KeyW', ctrlKey: true }) + expect(listViewYieldsKey(ctrlW, overrides, ARCHIVE, true)).toBe(true) + expect(listViewYieldsKey(ctrlW, overrides, ARCHIVE, false)).toBe(false) + expect(chordHeldByApp(ctrlW, overrides, true)?.id).toBe('vim.panePrefix') + }) + }) +}) + +describe('listViewKey and listViewBindingMatches', () => { + it('hides a chord from the hard-coded keys', () => { + expect(listViewKey(fakeEvent({ key: 'ArrowDown', code: 'ArrowDown' }))).toBe('ArrowDown') + expect(listViewKey(fakeEvent({ key: 'ArrowDown', code: 'ArrowDown', altKey: true }))).toBe('') + expect(listViewKey(fakeEvent({ key: 'd', code: 'KeyD', ctrlKey: true }))).toBe('') + // Shift alone still types. + expect(listViewKey(fakeEvent({ key: 'G', code: 'KeyG', shiftKey: true }))).toBe('G') + }) + + it('keeps typed keys Vim-only and lets chords work in either mode', () => { + withPlatform('darwin', () => { + expect(listViewBindingMatches(x(), {}, 'nav.delete', true)).toBe(true) + expect(listViewBindingMatches(x(), {}, 'nav.delete', false)).toBe(false) + const overrides = { 'nav.delete': 'Mod+Backspace' } + expect(listViewBindingMatches(cmdBackspace(), overrides, 'nav.delete', false)).toBe(true) + expect(listViewBindingMatches(cmdBackspace(), overrides, 'nav.delete', true)).toBe(true) + expect(listViewBindingMatches(x(), overrides, 'nav.delete', true)).toBe(false) + }) + }) + + it('runs multi-key sequences with Vim on, or from a chord', () => { + expect(listViewSequencesLive(fakeEvent({ key: 'g', code: 'KeyG' }), true)).toBe(true) + expect(listViewSequencesLive(fakeEvent({ key: 'g', code: 'KeyG' }), false)).toBe(false) + expect(listViewSequencesLive(fakeEvent({ key: 'g', code: 'KeyG', ctrlKey: true }), false)).toBe(true) + }) +}) + +describe('keymapBindingTakenBy, the Settings warning (#972)', () => { + it('names the global shortcut a list action loses its chord to', () => { + withPlatform('darwin', () => { + expect(keymapBindingTakenBy({}, 'nav.delete', 'Mod+P', true)?.id).toBe('global.searchNotes') + expect(keymapBindingTakenBy({}, 'nav.delete', 'Meta+P', false)?.id).toBe('global.searchNotes') + expect(keymapBindingTakenBy({}, 'nav.moveDown', 'Alt+J', true)?.id).toBe('global.focusPaneDown') + }) + withPlatform('linux', () => { + expect(keymapBindingTakenBy({}, 'nav.delete', 'Ctrl+P', true)?.id).toBe('global.searchNotes') + expect(keymapBindingTakenBy({}, 'nav.delete', 'Mod+P', true)?.id).toBe('global.searchNotes') + }) + }) + + it("says nothing when the global shortcut has moved off the key", () => { + withPlatform('darwin', () => { + const overrides = { 'global.searchNotes': 'Alt+Mod+P' } + expect(keymapBindingTakenBy(overrides, 'nav.delete', 'Mod+P', true)).toBeNull() + }) + }) + + it('warns about the pane prefix only while Vim is on', () => { + withPlatform('darwin', () => { + expect(keymapBindingTakenBy({}, 'nav.delete', 'Ctrl+W', true)?.id).toBe('vim.panePrefix') + expect(keymapBindingTakenBy({}, 'nav.delete', 'Ctrl+W', false)).toBeNull() + }) + }) + + it('leaves free chords, typed keys, unbound actions and other scopes alone', () => { + withPlatform('darwin', () => { + expect(keymapBindingTakenBy({}, 'nav.delete', 'Mod+Backspace', true)).toBeNull() + expect(keymapBindingTakenBy({}, 'nav.delete', 'Ctrl+Backspace', true)).toBeNull() + expect(keymapBindingTakenBy({}, 'nav.delete', 'x', true)).toBeNull() + expect(keymapBindingTakenBy({}, 'nav.delete', '', true)).toBeNull() + // A global shortcut's own conflicts are findKeymapConflict's to report. + expect(keymapBindingTakenBy({}, 'global.commandPalette', 'Mod+P', true)).toBeNull() + }) + }) +}) diff --git a/packages/app-core/src/lib/list-view-keys.ts b/packages/app-core/src/lib/list-view-keys.ts new file mode 100644 index 000000000..c3c9d6c3d --- /dev/null +++ b/packages/app-core/src/lib/list-view-keys.ts @@ -0,0 +1,179 @@ +/** + * How the list views (Archive, Trash, Quick Notes, Tags, Tasks, Unresolved + * links, Workflows, the Timeline) read a key against their own actions' + * bindings. + * + * Each of them used to open with `if (e.metaKey || e.ctrlKey || e.altKey) + * return`, so the app's chords went past them untouched. That also threw + * away every chord a user bound to one of the view's actions: with + * `"nav.delete" = "Ctrl+Backspace"` in config.toml the Archive view never + * saw the key, and nothing said so (#972). The rule now: + * + * - A typed key (no Ctrl, Alt or Cmd) reaches the view exactly as before. + * - A chord reaches the view only when it is the key of one of the view's + * own actions, and never when an app-wide shortcut (the global and tab + * shortcuts) or, with Vim on, the pane prefix holds it: those keep their + * key everywhere. Settings → Keymap marks a binding that loses this way. + * - A chord never answers a view's hard-coded keys (arrows, Enter, Escape, + * Vim's `d`): `listViewKey` hands the view an empty key for it, so a + * binding on Alt+ArrowDown runs its action instead of moving the cursor. + * - With Vim off the typed keys stay off (only arrows, Enter and Escape are + * universal), but a chord bound to an action works in either mode. + */ +import { + appShortcutForEvent, + getKeymapDefinition, + getSequenceTokens, + isChordEvent, + isMacPlatform, + isUnboundBinding, + matchesSequenceToken, + normalizeShortcutBinding, + resolveSequenceToken, + sequenceTokenFromEvent, + type KeymapDefinition, + type KeymapId, + type KeymapOverrides +} from './keymaps' + +/** True when the event starts this action's binding: the whole binding for a + * one-key action, its first key for a sequence. */ +function eventStartsBinding( + event: KeyboardEvent, + overrides: KeymapOverrides | null | undefined, + id: KeymapId +): boolean { + const first = getSequenceTokens(overrides, id)[0] + if (!first) return false + return sequenceTokenFromEvent(event) === first +} + +/** + * The app-wide key a chord belongs to before any view or panel sees it: an + * `app`-scope shortcut (the global and tab shortcuts), or with Vim on the + * pane prefix. Null for a typed key or a chord nothing app-wide holds. + */ +export function chordHeldByApp( + event: KeyboardEvent, + overrides: KeymapOverrides | null | undefined, + vimMode: boolean +): KeymapDefinition | null { + if (!isChordEvent(event)) return null + const app = appShortcutForEvent(event, overrides) + if (app) return app + if (vimMode && matchesSequenceToken(event, overrides, 'vim.panePrefix')) { + return getKeymapDefinition('vim.panePrefix') + } + return null +} + +/** + * Whether a list view must leave this keydown alone. Replaces the views' + * blanket modifier bail: typed keys are never stepped over here, a chord is + * stepped over unless it is the key of one of `actions` and nothing app-wide + * holds it. + */ +export function listViewYieldsKey( + event: KeyboardEvent, + overrides: KeymapOverrides | null | undefined, + actions: readonly KeymapId[], + vimMode: boolean +): boolean { + if (!isChordEvent(event)) return false + if (!actions.some((id) => eventStartsBinding(event, overrides, id))) return true + return chordHeldByApp(event, overrides, vimMode) !== null +} + +/** The key a view's hard-coded checks see: the event's own key for a typed + * key, nothing for a chord. */ +export function listViewKey(event: KeyboardEvent): string { + return isChordEvent(event) ? '' : event.key +} + +/** + * Whether the event is this view action's key. A typed key counts only with + * Vim on; a chord counts in either mode (Ctrl, Alt and Cmd are not the + * single-key shortcuts Vim off turns off). + */ +export function listViewBindingMatches( + event: KeyboardEvent, + overrides: KeymapOverrides | null | undefined, + id: KeymapId, + vimMode: boolean +): boolean { + if (!vimMode && !isChordEvent(event)) return false + return matchesSequenceToken(event, overrides, id) +} + +/** Vim mode or a chord: when a view's multi-key sequences (`g g`) may run. */ +export function listViewSequencesLive(event: KeyboardEvent, vimMode: boolean): boolean { + return vimMode || isChordEvent(event) +} + +/** A key's modifiers and key as a keydown would carry them, for comparing a + * stored token with the app's shortcuts without a real event. */ +function eventForToken(token: string, mac: boolean): KeyboardEvent | null { + const resolved = resolveSequenceToken(token, mac) + const parts = resolved.split('+') + const base = parts.pop() + if (!base) return null + let key = base + if (base === 'Esc') key = 'Escape' + else if (base === 'Space') key = ' ' + else if (/^[A-Z]$/.test(base)) key = base.toLowerCase() + const code = /^[A-Za-z]$/.test(base) + ? `Key${base.toUpperCase()}` + : /^\d$/.test(base) + ? `Digit${base}` + : base + return { + key, + code, + ctrlKey: parts.includes('Ctrl'), + metaKey: parts.includes('Meta'), + altKey: parts.includes('Alt'), + shiftKey: parts.includes('Shift'), + repeat: false + } as KeyboardEvent +} + +const LIST_SCOPES = new Set(['lists', 'views']) + +/** + * What takes this action's key before the action can see it, or null when + * nothing does. Settings → Keymap shows it on the row (and the recorder + * before saving) instead of storing a binding that never fires. + * + * Only chords on list and view actions can lose this way: the list views + * step aside for an app-wide shortcut on the same chord, and for the pane + * prefix with Vim on (see listViewYieldsKey). + */ +export function keymapBindingTakenBy( + overrides: KeymapOverrides | null | undefined, + id: KeymapId, + binding: string, + vimMode: boolean, + mac: boolean = isMacPlatform() +): KeymapDefinition | null { + const definition = getKeymapDefinition(id) + if (!LIST_SCOPES.has(definition.scope)) return null + if (isUnboundBinding(binding)) return null + const first = + definition.kind === 'shortcut' ? normalizeShortcutBinding(binding) : binding.split(/\s+/)[0] + if (!first) return null + const event = eventForToken( + definition.kind === 'shortcut' ? shortcutAsToken(first, mac) : first, + mac + ) + if (!event) return null + return chordHeldByApp(event, { ...(overrides ?? {}), [id]: binding }, vimMode) +} + +/** A shortcut-format binding (`Shift+Mod+P`) spelled as a sequence token, so + * one reader serves both kinds. */ +function shortcutAsToken(binding: string, mac: boolean): string { + const parts = binding.split('+') + const base = parts.pop() ?? '' + const mods = parts.map((part) => (part === 'Mod' ? (mac ? 'Meta' : 'Ctrl') : part)) + return [...mods, base].join('+') +} diff --git a/packages/app-core/src/lib/vim-key-sequence.ts b/packages/app-core/src/lib/vim-key-sequence.ts index 777b1c8fc..2df363492 100644 --- a/packages/app-core/src/lib/vim-key-sequence.ts +++ b/packages/app-core/src/lib/vim-key-sequence.ts @@ -1,3 +1,5 @@ +import { isMacPlatform } from './keymaps' + function toVimKeyName(base: string): string { if (base === 'Space') return 'Space' if (base === 'Enter') return 'CR' @@ -25,12 +27,17 @@ function toVimSequenceToken(token: string): string | null { if (part === 'Ctrl') return 'C' if (part === 'Alt') return 'A' if (part === 'Shift') return 'S' - if (part === 'Meta' || part === 'Mod') return 'D' + if (part === 'Meta') return 'D' + // A sequence binding keeps the portable `Mod` (#972): Cmd on macOS, + // Ctrl everywhere else, as in the shortcut format. + if (part === 'Mod') return isMacPlatform() ? 'D' : 'C' return null }) .filter(Boolean) as string[] + // Ctrl+Mod on Linux and Windows is one Ctrl. + const unique = [...new Set(modifiers)] const normalizedKey = base.length === 1 ? base.toLowerCase() : keyName - return `<${[...modifiers, normalizedKey].join('-')}>` + return `<${[...unique, normalizedKey].join('-')}>` } export function toVimSequence(binding: string): string | null { From 4cb73ecfccfe9f103195857c7f5f72781cee530f Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 13:27:16 -0500 Subject: [PATCH 21/34] Fix(sidebar): x and Mod+Backspace move the sidebar's note to the Trash, and Move Note to Trash follows the sidebar (#972) The sidebar is where notes are walked with j and k, and it was the one list with no key to trash the row under the cursor: nav.delete was handled by the Archive, Trash and Workflows views only, so the mouse or the palette (which acts on the open note, not the row) were the only ways. Now, with the sidebar holding the keyboard: - `x` (nav.delete) with Vim on, and Mod+Backspace in either mode (a new action, Move sidebar note to Trash, nav.sidebarTrash, rebindable and unbindable like any other) act on the row under the cursor. With Vim off `x` does nothing, as every single-key list shortcut. - A note moves to the Trash after the same confirm the Archive view and the palette show. Escape on it leaves everything as it was. - When that note is part of a multi-selection, the whole selection goes, with one confirm ("Move 3 notes to Trash?"), through the batch path the selection menu uses, and the selection clears. - A note already in the Trash would be deleted for good, after the Trash view's confirm. The sidebar does not list trashed notes today (Trash is a row that opens its view), so this only guards the path. - A folder is left alone and a note says to use its menu. Deleting a folder takes everything inside and cannot be undone, while the same key on a note is a reversible move; one key meaning both on neighbouring rows is how a j/k walk ends in a lost folder. Any other row (a tag, the Trash row) gets "Only a note can go to the Trash from here". The cursor then sits on the row that took the note's place, or the one before it at a folder's end: the anchoring in lib/sidebar-cursor.ts already does that once the row is gone. What it did not cover is the open note: trashing it closes its tab, the next tab becomes the open note, and the sidebar's reveal effect read that as a new pick and pulled the cursor onto it. Measured in the built app: without a hold the cursor jumped from P23 to P20. The sidebar now holds its cursor while one of these actions runs. The editor is not focused afterwards. The typed `x` is handled in VimNav's sidebar handler, after the leader and pane-prefix machinery, so `Space x` still reaches a leader binding; it is consumed there, so a list view left active behind the sidebar never acts on its own row for it. The chord form is a capture listener App installs at import time, after the command keymap dispatcher (a key the user gave a command still wins) and ahead of every component, since the Archive and Trash views listen on the window too. It stands down for dialogs, menus, palettes, Settings and text fields, steps aside for a global shortcut on the same chord, and runs once per press. Move Note to Trash and Move Note to Archive, from the palette or a key the user gave them (#971), follow the same row while the sidebar holds focus and its cursor is on a note, selection rule included, instead of the open note. Elsewhere, or on a row that is not a note, they act on the open note as before. Their when() works with no note open, and a refusal says why ("This note is already in the Trash"). The sidebar's selection is React state, so it registers a small host (lib/sidebar-note-actions.ts) that hands over a snapshot, clears it after a batch and holds the cursor. Tests: the plan for a single row, a selection with and without the cursor row, a selection with folders, a trashed row, archiving; the chord listener (both platforms, Vim off, a user's nav.delete chord, focus, dialogs, text fields, a global shortcut, auto-repeat, order against a later window listener); the cursor-row runner; and the palette commands following the sidebar. --- packages/app-core/src/App.tsx | 9 +- packages/app-core/src/components/Sidebar.tsx | 60 ++ packages/app-core/src/components/VimNav.tsx | 24 +- packages/app-core/src/lib/commands.ts | 43 +- packages/app-core/src/lib/keymaps.test.ts | 19 + packages/app-core/src/lib/keymaps.ts | 13 +- .../app-core/src/lib/list-view-keys.test.ts | 7 + .../src/lib/sidebar-note-actions.test.ts | 532 ++++++++++++++++++ .../app-core/src/lib/sidebar-note-actions.ts | 363 ++++++++++++ packages/shared-domain/src/keymaps-catalog.ts | 1 + 10 files changed, 1063 insertions(+), 8 deletions(-) create mode 100644 packages/app-core/src/lib/sidebar-note-actions.test.ts create mode 100644 packages/app-core/src/lib/sidebar-note-actions.ts diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 2d35a9a6b..2a59dd184 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -71,7 +71,8 @@ import { ensureCloudAutoSyncStarted, stopCloudAutoSync } from './lib/cloud-auto- import { loadKeepRunningInTray, startTraySyncStatusReporter } from './lib/tray-runtime' import { installHarperRuntime } from './lib/harper-runtime' import { installIgnoredKeysGuard } from './lib/ignored-keys' -import { installCommandKeymapDispatcher } from './lib/command-keymap-dispatch' +import { installCommandKeymapDispatcher, isMainAppWindow } from './lib/command-keymap-dispatch' +import { installSidebarTrashKeys } from './lib/sidebar-note-actions' // The ignored-keys guard (#732) has to be on `window` before any component // registers a capture listener there, so it is installed at import time, @@ -84,6 +85,12 @@ installIgnoredKeysGuard(() => useStore.getState().ignoredKeys) // sequences are not handled here but in VimNav, after the built-in leader // keys. See lib/command-keymap-dispatch.ts. installCommandKeymapDispatcher(() => useStore.getState()) +// The sidebar's Mod+Backspace (#972) after those, ahead of every component: +// a list view left active behind a focused sidebar listens on the window too, +// and must not see the chord first. Only the main window has a sidebar. +if (typeof window !== 'undefined' && isMainAppWindow(window.location.search)) { + installSidebarTrashKeys(() => useStore.getState()) +} let editorModulePromise: Promise<typeof import('./components/Editor')> | null = null const EDITOR_MODULE_WARMUP_GRACE_MS = 40 diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 628f0a5d4..23172d8d1 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -142,6 +142,10 @@ import { type SidebarCursorAnchor, } from "../lib/sidebar-cursor"; import { buildVaultSwitcherEntries } from "../lib/vault-switcher"; +import { + registerSidebarActionHost, + type SidebarSelectionSnapshot, +} from "../lib/sidebar-note-actions"; import { appUpdateBadgeLabel, useAppUpdateState } from "../lib/app-update-state"; import { getISOWeekYear } from "../lib/template-render"; import { requestPublishNote } from "../lib/publish-note-requests"; @@ -732,6 +736,54 @@ export function Sidebar(): JSX.Element { selectedSidebarCountRef.current = selectedSidebarCount; }, [selectedSidebarCount, selectedSidebarItems, selectedSidebarKeys]); + // The keyboard's trash and archive (#972) act on this selection from + // outside the component (VimNav, the palette): hand them a snapshot of it, + // a way to clear it after a batch, and a hold on the cursor while they run. + const sidebarSelectionForActionsRef = useRef({ + count: selectedSidebarCount, + notes: selectedNoteMetas, + actionNotes: selectedNoteMetasForAction, + folderCount: selectedFolderItems.length, + }); + sidebarSelectionForActionsRef.current = { + count: selectedSidebarCount, + notes: selectedNoteMetas, + actionNotes: selectedNoteMetasForAction, + folderCount: selectedFolderItems.length, + }; + const sidebarCursorHoldsRef = useRef(0); + useEffect( + () => + registerSidebarActionHost({ + selection: (): SidebarSelectionSnapshot => { + const current = sidebarSelectionForActionsRef.current; + return { + count: current.count, + notePaths: new Set(current.notes.map((note) => note.path)), + actionNotePaths: current.actionNotes.map((note) => note.path), + folderCount: current.folderCount, + }; + }, + clearSelection: () => { + setSelectedSidebarKeys(new Set()); + setSelectionAnchorKey(null); + }, + holdCursor: async (run) => { + sidebarCursorHoldsRef.current += 1; + try { + return await run(); + } finally { + // The last store write of the action can commit after the + // promise settles; release once that render has run. + window.setTimeout(() => { + sidebarCursorHoldsRef.current -= 1; + }, 0); + } + }, + }), + [], + ); + const captureSidebarScrollAnchor = useCallback((target: EventTarget | null): void => { const scroller = sidebarScrollRef.current; const row = @@ -2884,6 +2936,14 @@ export function Sidebar(): JSX.Element { if (!sidebarSelectionChanged(revealedSelectionRef.current, selection)) { return; } + // A note trashed or archived from the sidebar closes its tab, and the + // open note becomes the next tab's: a side effect, not a pick. The cursor + // stays where the anchoring put it, on the row that took the gone row's + // place, instead of jumping to that other note. (#972) + if (sidebarCursorHoldsRef.current > 0) { + revealedSelectionRef.current = selection; + return; + } // Read live: the layout effect above may have moved the cursor in this // very commit, after this render captured sidebarCursorIndex. diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 4ae5db229..c55115d1d 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -49,6 +49,11 @@ import { import { toggleWrap, wrapLink } from '../lib/cm-format' import { commandLeaderHints, stepCommandLeader } from '../lib/command-keymaps' import { runBoundCommand } from '../lib/command-keymap-dispatch' +import { + isSidebarTrashKey, + sidebarHoldsFocus, + trashSidebarCursorRow +} from '../lib/sidebar-note-actions' import { convertTableToDatabase } from '../lib/table-to-database' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, @@ -1597,6 +1602,10 @@ export function VimNav(): JSX.Element | null { function handleSidebarKey(e: KeyboardEvent, state: ReturnType<typeof useStore.getState>): void { const key = e.key const overrides = state.keymapOverrides + // Read before the line below claims the panel: the trash keys act only + // when the sidebar already held the keyboard, where its cursor is drawn, + // never on a key that merely fell through to the sidebar. + const sidebarHeldFocus = sidebarHoldsFocus(state) if (state.focusedPanel !== 'sidebar') state.setFocusedPanel('sidebar') const items = getIndexedElements('[data-sidebar-idx]', 'sidebarIdx') const count = items.length @@ -1606,9 +1615,16 @@ export function VimNav(): JSX.Element | null { matchesSequenceToken(e, overrides, 'nav.contextMenu') || key === 'ContextMenu' || (e.shiftKey && key === 'F10') + // `x` (#972). The chord form (Mod+Backspace) is taken earlier, in either + // mode, by installSidebarTrashKeys; a typed key lands here, after the + // leader and pane-prefix handling above. + const wantsTrash = isSidebarTrashKey(e, overrides, state.vimMode) // Always consume single-char nav keys when sidebar is focused, // even if the sidebar is empty — prevents them leaking to the editor. + // `x` included, so a list view left active behind the sidebar (Archive, + // Trash), which listens on the window too, never acts on its own row + // for a key meant for the sidebar. const wantsHandledKey = matchesSequenceToken(e, overrides, 'nav.moveDown') || matchesSequenceToken(e, overrides, 'nav.moveUp') || @@ -1624,7 +1640,8 @@ export function VimNav(): JSX.Element | null { key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || - wantsContextMenu + wantsContextMenu || + wantsTrash if (wantsHandledKey) { e.preventDefault() e.stopImmediatePropagation() @@ -1634,6 +1651,11 @@ export function VimNav(): JSX.Element | null { if (count === 0) return // nothing to navigate + if (wantsTrash) { + if (sidebarHeldFocus && !e.repeat) void trashSidebarCursorRow() + return + } + if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { scrollToIndexedElement(items[Math.min(currentPos + 1, max)], 'sidebarIdx', state.setSidebarCursorIndex) return diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index e56bfc91e..12c8606bd 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -24,6 +24,7 @@ import { captureNavigationContext } from './navigation-context' import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from './move-note' import { focusPaneInDirection } from './pane-nav' import { focusSidebarPanel } from './sidebar-focus' +import { runSidebarNotePlan, sidebarCommandPlan } from './sidebar-note-actions' import { findLeaf } from './pane-layout' import { getTabStepTarget, stepActiveTab } from './buffer-navigation' import { requestPaneMode } from './pane-mode' @@ -512,14 +513,30 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.archive', - unavailableReason: () => getState().activeNote ? `Move Note to ${labels().archive} works on a note in ${labels().inbox} or ${labels().quick}` : `Move Note to ${labels().archive} needs an open note`, + // With the sidebar focused and its cursor on a note, that note (or the + // selection it is part of) is the one this acts on, not the open note + // (#972); everywhere else it is the open note, as before. + unavailableReason: () => { + const plan = sidebarCommandPlan('archive') + if (plan?.kind === 'refuse') return plan.reason + return getState().activeNote ? `Move Note to ${labels().archive} works on a note in ${labels().inbox} or ${labels().quick}` : `Move Note to ${labels().archive} needs an open note` + }, title: `Move Note to ${labels().archive}`, category: 'Note', when: () => { + const plan = sidebarCommandPlan('archive') + if (plan) return plan.kind === 'run' const f = getState().activeNote?.folder return f === 'inbox' || f === 'quick' }, - run: () => getState().archiveActive() + run: async () => { + const plan = sidebarCommandPlan('archive') + if (plan) { + await runSidebarNotePlan(plan) + return + } + await getState().archiveActive() + } }, { id: 'note.unarchive', @@ -531,12 +548,28 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.trash', - unavailableReason: () => getState().activeNote ? `This note is already in the ${labels().trash}` : `Move Note to ${labels().trash} needs an open note`, + // Follows the sidebar the same way as Move Note to Archive above (#972). + unavailableReason: () => { + const plan = sidebarCommandPlan('trash') + if (plan?.kind === 'refuse') return plan.reason + return getState().activeNote ? `This note is already in the ${labels().trash}` : `Move Note to ${labels().trash} needs an open note` + }, title: `Move Note to ${labels().trash}`, category: 'Note', keywords: 'delete', - when: () => !!getState().activeNote && getState().activeNote?.folder !== 'trash', - run: () => getState().trashActive() + when: () => { + const plan = sidebarCommandPlan('trash') + if (plan) return plan.kind === 'run' + return !!getState().activeNote && getState().activeNote?.folder !== 'trash' + }, + run: async () => { + const plan = sidebarCommandPlan('trash') + if (plan) { + await runSidebarNotePlan(plan) + return + } + await getState().trashActive() + } }, { id: 'note.restore', diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 410749a27..b2af3f2d6 100644 --- a/packages/app-core/src/lib/keymaps.test.ts +++ b/packages/app-core/src/lib/keymaps.test.ts @@ -764,3 +764,22 @@ describe('Mod in sequence bindings (#972)', () => { withPlatform('linux', () => expect(toVimSequence('Ctrl+Mod+D')).toBe('<C-d>')) }) }) + +describe('Move sidebar note to Trash (#972)', () => { + it('is a Mod+Backspace chord in either mode, rebindable and unbindable', () => { + const definition = getKeymapDefinition('nav.sidebarTrash') + expect(definition).toMatchObject({ kind: 'shortcut', defaultBinding: 'Mod+Backspace' }) + expect(definition.vimOnly).toBeUndefined() + withPlatform('darwin', () => { + const cmd = fakeEvent({ key: 'Backspace', code: 'Backspace', metaKey: true }) + expect(matchesShortcut(cmd, {}, 'nav.sidebarTrash')).toBe(true) + expect(matchesShortcut(cmd, { 'nav.sidebarTrash': '' }, 'nav.sidebarTrash')).toBe(false) + const del = fakeEvent({ key: 'Delete', code: 'Delete' }) + expect(matchesShortcut(del, { 'nav.sidebarTrash': 'Delete' }, 'nav.sidebarTrash')).toBe(true) + }) + withPlatform('linux', () => { + const ctrl = fakeEvent({ key: 'Backspace', code: 'Backspace', ctrlKey: true }) + expect(matchesShortcut(ctrl, {}, 'nav.sidebarTrash')).toBe(true) + }) + }) +}) diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index b5d655537..d9af1ac0a 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -128,6 +128,7 @@ export type KeymapId = | "nav.peekPreview" | "nav.restore" | "nav.delete" + | "nav.sidebarTrash" | "nav.toggleTask" | "nav.localEx" | "nav.newQuickNote" @@ -1384,10 +1385,20 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ group: "view-actions", title: "Delete selected result", description: - "Permanently delete or move the selected item to trash, depending on the view.", + "Permanently delete or move the selected item to trash, depending on the view. In the sidebar it moves the note under the cursor (or the selection that includes it) to the Trash, after a confirm.", defaultBinding: "x", maxTokens: 1, }, + { + id: "nav.sidebarTrash", + kind: "shortcut", + scope: "lists", + group: "view-actions", + title: "Move sidebar note to Trash", + description: + "With the sidebar focused, move the note under its cursor to the Trash after a confirm, or every selected note when the selection includes that row. Works with Vim mode on or off.", + defaultBinding: "Mod+Backspace", + }, { id: "nav.toggleTask", kind: "sequence", diff --git a/packages/app-core/src/lib/list-view-keys.test.ts b/packages/app-core/src/lib/list-view-keys.test.ts index 8a0004323..8f4889ae9 100644 --- a/packages/app-core/src/lib/list-view-keys.test.ts +++ b/packages/app-core/src/lib/list-view-keys.test.ts @@ -184,6 +184,13 @@ describe('keymapBindingTakenBy, the Settings warning (#972)', () => { }) }) + it('covers the sidebar chord too, which is a shortcut-format binding', () => { + withPlatform('darwin', () => { + expect(keymapBindingTakenBy({}, 'nav.sidebarTrash', 'Mod+Backspace', true)).toBeNull() + expect(keymapBindingTakenBy({}, 'nav.sidebarTrash', 'Mod+W', true)?.id).toBe('global.closeActiveTab') + }) + }) + it('leaves free chords, typed keys, unbound actions and other scopes alone', () => { withPlatform('darwin', () => { expect(keymapBindingTakenBy({}, 'nav.delete', 'Mod+Backspace', true)).toBeNull() diff --git a/packages/app-core/src/lib/sidebar-note-actions.test.ts b/packages/app-core/src/lib/sidebar-note-actions.test.ts new file mode 100644 index 000000000..81eddb22e --- /dev/null +++ b/packages/app-core/src/lib/sidebar-note-actions.test.ts @@ -0,0 +1,532 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./note-lifecycle-actions', () => ({ + runNoteLifecycleAction: vi.fn().mockResolvedValue('completed'), + runNoteBatchAction: vi.fn().mockResolvedValue(true), + runEmptyTrash: vi.fn() +})) + +function installZen(platform: NodeJS.Platform = 'darwin'): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + platformSync: () => platform, + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false, + supportsCliInstall: false + }) + } + }) +} + +async function load() { + vi.resetModules() + localStorage.clear() + return { + ...(await import('../store')), + ...(await import('./sidebar-note-actions')), + ...(await import('./note-lifecycle-actions')), + ...(await import('./commands')), + ...(await import('./toast')) + } +} + +const note = (folder: string, path: string) => + ({ folder, path, title: path.split('/').pop()?.replace(/\.md$/, ''), body: '' }) as never + +const NOTES = [ + { path: 'inbox/A.md', folder: 'inbox' as const }, + { path: 'inbox/B.md', folder: 'inbox' as const }, + { path: 'inbox/C.md', folder: 'inbox' as const }, + { path: 'archive/D.md', folder: 'archive' as const }, + { path: 'trash/E.md', folder: 'trash' as const }, + { path: 'trash/F.md', folder: 'trash' as const } +] + +const LABELS = { inbox: 'Inbox', quick: 'Quick Notes', archive: 'Archive', trash: 'Trash' } + +const noSelection = { count: 0, notePaths: new Set<string>(), actionNotePaths: [], folderCount: 0 } + +function selectionOf(paths: string[], folderCount = 0) { + return { + count: paths.length + folderCount, + notePaths: new Set(paths), + actionNotePaths: paths, + folderCount + } +} + +function chord(init: KeyboardEventInit & { key: string; code: string }): KeyboardEvent { + return new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) +} + +const cmdBackspace = (init: KeyboardEventInit = {}): KeyboardEvent => + chord({ key: 'Backspace', code: 'Backspace', metaKey: true, ...init }) +const plainX = (): KeyboardEvent => chord({ key: 'x', code: 'KeyX' }) + +/** Sidebar rows the way Sidebar draws them, cursor numbers included. */ +function drawSidebar(): void { + document.body.innerHTML = ` + <aside data-zen-sidebar> + <div data-sidebar-idx="9" data-sidebar-type="folder" data-sidebar-folder="inbox" data-sidebar-subpath=""></div> + <div data-sidebar-idx="10" data-sidebar-type="note" data-sidebar-path="inbox/A.md"></div> + <div data-sidebar-idx="11" data-sidebar-type="note" data-sidebar-path="inbox/B.md"></div> + <div data-sidebar-idx="12" data-sidebar-type="note" data-sidebar-path="inbox/C.md"></div> + <div data-sidebar-idx="13" data-sidebar-type="folder" data-sidebar-folder="inbox" data-sidebar-subpath="Projects"></div> + <div data-sidebar-idx="3" data-sidebar-type="trash"></div> + <div data-sidebar-idx="20" data-sidebar-type="note" data-sidebar-path="trash/E.md"></div> + </aside>` +} + +const closedState = { + whatsNewOpen: false, + settingsOpen: false, + searchOpen: false, + vaultTextSearchOpen: false, + commandPaletteOpen: false, + bufferPaletteOpen: false, + templatePaletteOpen: false, + embedDrawingPaletteOpen: false, + outlinePaletteOpen: false, + commandKeymaps: {}, + keymapOverrides: {}, + vimMode: true, + focusedPanel: 'sidebar' as string | null, + sidebarOpen: true, + sidebarCursorIndex: 10 +} + +beforeEach(() => { + vi.clearAllMocks() + installZen() + document.body.innerHTML = '' +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('planSidebarNoteAction (#972)', () => { + const plan = async ( + overrides: Partial<{ + action: 'trash' | 'archive' + path: string + selection: ReturnType<typeof selectionOf> | typeof noSelection + vimMode: boolean + permanentForTrashed: boolean + }> + ) => { + const { planSidebarNoteAction } = await load() + return planSidebarNoteAction({ + action: 'trash', + path: 'inbox/A.md', + notes: NOTES, + selection: noSelection, + labels: LABELS, + vimMode: true, + permanentForTrashed: true, + ...overrides + }) + } + + it('moves a single note row to the Trash', async () => { + expect(await plan({})).toEqual({ kind: 'run', action: 'trash', paths: ['inbox/A.md'], batch: false }) + }) + + it('takes the whole selection, with one confirm, when it includes the cursor row', async () => { + const selection = selectionOf(['inbox/A.md', 'inbox/B.md', 'inbox/C.md']) + expect(await plan({ path: 'inbox/B.md', selection })).toEqual({ + kind: 'run', + action: 'trash', + paths: ['inbox/A.md', 'inbox/B.md', 'inbox/C.md'], + batch: true + }) + }) + + it('takes only the cursor row when the selection does not include it', async () => { + const selection = selectionOf(['inbox/B.md', 'inbox/C.md']) + expect(await plan({ path: 'inbox/A.md', selection })).toEqual({ + kind: 'run', + action: 'trash', + paths: ['inbox/A.md'], + batch: false + }) + }) + + it('refuses a selection that includes folders, naming the menu key for the mode', async () => { + const selection = selectionOf(['inbox/A.md', 'inbox/B.md'], 1) + const on = await plan({ selection }) + expect(on.kind).toBe('refuse') + expect(on.kind === 'refuse' && on.reason).toContain('(m)') + const off = await plan({ selection, vimMode: false }) + expect(off.kind === 'refuse' && off.reason).toContain('(right-click)') + }) + + it('deletes a note in the Trash for good from the keys, and refuses it from the command', async () => { + expect(await plan({ path: 'trash/E.md' })).toEqual({ + kind: 'run', + action: 'delete', + paths: ['trash/E.md'], + batch: false + }) + expect(await plan({ path: 'trash/E.md', permanentForTrashed: false })).toEqual({ + kind: 'refuse', + reason: 'This note is already in the Trash' + }) + const selection = selectionOf(['trash/E.md', 'trash/F.md']) + expect(await plan({ path: 'trash/E.md', selection })).toEqual({ + kind: 'run', + action: 'delete', + paths: ['trash/E.md', 'trash/F.md'], + batch: true + }) + }) + + it('archives a note row, or the archivable part of a selection', async () => { + expect(await plan({ action: 'archive' })).toEqual({ + kind: 'run', + action: 'archive', + paths: ['inbox/A.md'], + batch: false + }) + expect(await plan({ action: 'archive', path: 'archive/D.md' })).toEqual({ + kind: 'refuse', + reason: 'This note is already in the Archive' + }) + const selection = selectionOf(['inbox/A.md', 'inbox/B.md', 'archive/D.md']) + expect(await plan({ action: 'archive', selection })).toEqual({ + kind: 'run', + action: 'archive', + paths: ['inbox/A.md', 'inbox/B.md'], + batch: true + }) + }) +}) + +describe('sidebarRowRefusal', () => { + it('points a folder at its menu, in the words of the mode, and says nothing is undone there', async () => { + const { sidebarRowRefusal } = await load() + const folder = { type: 'folder', path: null, folder: 'inbox', subpath: 'Projects' } + expect(sidebarRowRefusal(folder, LABELS, true)).toBe( + "A folder can't go to the Trash. Delete it from its menu (m); that can't be undone" + ) + expect(sidebarRowRefusal(folder, LABELS, false)).toContain('(right-click)') + const top = { type: 'folder', path: null, folder: 'inbox', subpath: '' } + expect(sidebarRowRefusal(top, LABELS, true)).toBe('Only a note can go to the Trash from here') + expect(sidebarRowRefusal({ type: 'trash', path: null, folder: null, subpath: null }, LABELS, true)).toBe( + 'Only a note can go to the Trash from here' + ) + }) +}) + +describe('the sidebar trash chord (#972)', () => { + it('runs on Cmd+Backspace with the sidebar focused, Vim on or off', async () => { + const { handleSidebarTrashChord } = await load() + for (const vimMode of [true, false]) { + const run = vi.fn() + const event = cmdBackspace() + expect(handleSidebarTrashChord(event, () => ({ ...closedState, vimMode }), run)).toBe(true) + expect(event.defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledTimes(1) + } + }) + + it('is Ctrl+Backspace on Linux and Windows', async () => { + installZen('linux') + const { handleSidebarTrashChord } = await load() + const run = vi.fn() + const ctrl = chord({ key: 'Backspace', code: 'Backspace', ctrlKey: true }) + expect(handleSidebarTrashChord(ctrl, () => closedState, run)).toBe(true) + expect(handleSidebarTrashChord(cmdBackspace(), () => closedState, run)).toBe(false) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('leaves the typed x to VimNav, and with Vim off nothing takes it', async () => { + const { handleSidebarTrashChord, isSidebarTrashKey } = await load() + const run = vi.fn() + expect(handleSidebarTrashChord(plainX(), () => closedState, run)).toBe(false) + expect(handleSidebarTrashChord(plainX(), () => ({ ...closedState, vimMode: false }), run)).toBe(false) + expect(run).not.toHaveBeenCalled() + // VimNav's own check: x is a trash key with Vim on, and not with Vim off. + expect(isSidebarTrashKey(plainX(), {}, true)).toBe(true) + expect(isSidebarTrashKey(plainX(), {}, false)).toBe(false) + }) + + it('takes a chord the user gave nav.delete, in either mode', async () => { + const { handleSidebarTrashChord } = await load() + const run = vi.fn() + const state = { + ...closedState, + vimMode: false, + keymapOverrides: { 'nav.delete': 'Ctrl+Backspace', 'nav.sidebarTrash': '' } + } + const ctrl = chord({ key: 'Backspace', code: 'Backspace', ctrlKey: true }) + expect(handleSidebarTrashChord(ctrl, () => state, run)).toBe(true) + // Unbound, the default chord is free again. + expect(handleSidebarTrashChord(cmdBackspace(), () => state, run)).toBe(false) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('does nothing unless the sidebar holds the keyboard', async () => { + const { handleSidebarTrashChord } = await load() + const run = vi.fn() + for (const focusedPanel of ['editor', null, 'tasks']) { + const event = cmdBackspace() + expect(handleSidebarTrashChord(event, () => ({ ...closedState, focusedPanel }), run)).toBe(false) + expect(event.defaultPrevented).toBe(false) + } + expect(handleSidebarTrashChord(cmdBackspace(), () => ({ ...closedState, sidebarOpen: false }), run)).toBe( + false + ) + expect(run).not.toHaveBeenCalled() + }) + + it('stands down while a dialog, menu or palette is open, and in a text field', async () => { + const { handleSidebarTrashChord } = await load() + const run = vi.fn() + for (const html of ['<div aria-modal="true"></div>', '<div data-ctx-menu></div>', '<div data-prompt-modal></div>']) { + document.body.innerHTML = html + expect(handleSidebarTrashChord(cmdBackspace(), () => closedState, run)).toBe(false) + } + document.body.innerHTML = '' + expect(handleSidebarTrashChord(cmdBackspace(), () => ({ ...closedState, commandPaletteOpen: true }), run)).toBe( + false + ) + const input = document.createElement('input') + document.body.appendChild(input) + const typed = cmdBackspace() + Object.defineProperty(typed, 'target', { value: input }) + expect(handleSidebarTrashChord(typed, () => closedState, run)).toBe(false) + expect(run).not.toHaveBeenCalled() + }) + + it('leaves a chord a global shortcut has to that shortcut', async () => { + const { handleSidebarTrashChord } = await load() + const run = vi.fn() + const state = { ...closedState, keymapOverrides: { 'nav.sidebarTrash': 'Mod+P' } } + expect(handleSidebarTrashChord(chord({ key: 'p', code: 'KeyP', metaKey: true }), () => state, run)).toBe(false) + expect(run).not.toHaveBeenCalled() + }) + + it('runs once per press, not once per auto-repeat', async () => { + const { handleSidebarTrashChord } = await load() + const run = vi.fn() + const held = cmdBackspace({ repeat: true }) + expect(handleSidebarTrashChord(held, () => closedState, run)).toBe(true) + expect(held.defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + }) + + it('is heard before a list view that registered on the window later', async () => { + const { installSidebarTrashKeys, useStore, runNoteLifecycleAction } = await load() + drawSidebar() + useStore.setState({ + notes: NOTES as never, + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: 11 + } as never) + const uninstall = installSidebarTrashKeys(() => useStore.getState()) + const archiveView = vi.fn() + window.addEventListener('keydown', archiveView, true) + try { + window.dispatchEvent(cmdBackspace()) + expect(archiveView).not.toHaveBeenCalled() + await vi.waitFor(() => expect(runNoteLifecycleAction).toHaveBeenCalledWith('inbox/B.md', 'trash')) + } finally { + window.removeEventListener('keydown', archiveView, true) + uninstall() + } + }) +}) + +describe('trashSidebarCursorRow', () => { + async function setup(cursor: number) { + const loaded = await load() + drawSidebar() + loaded.useStore.setState({ + notes: NOTES as never, + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: cursor, + vimMode: true + } as never) + return loaded + } + + it('trashes the note under the cursor, holding the cursor while it runs', async () => { + const { trashSidebarCursorRow, registerSidebarActionHost, runNoteLifecycleAction } = await setup(12) + let holds = 0 + const unregister = registerSidebarActionHost({ + selection: () => noSelection, + clearSelection: vi.fn(), + holdCursor: (run) => { + holds += 1 + return run() + } + }) + try { + expect(await trashSidebarCursorRow()).toBe(true) + expect(runNoteLifecycleAction).toHaveBeenCalledWith('inbox/C.md', 'trash') + expect(holds).toBe(1) + } finally { + unregister() + } + }) + + it('trashes the selection with one batch and clears it', async () => { + const { trashSidebarCursorRow, registerSidebarActionHost, runNoteBatchAction, runNoteLifecycleAction } = + await setup(11) + const clearSelection = vi.fn() + const unregister = registerSidebarActionHost({ + selection: () => selectionOf(['inbox/A.md', 'inbox/B.md', 'inbox/C.md']), + clearSelection, + holdCursor: (run) => run() + }) + try { + expect(await trashSidebarCursorRow()).toBe(true) + expect(runNoteBatchAction).toHaveBeenCalledWith(['inbox/A.md', 'inbox/B.md', 'inbox/C.md'], 'trash') + expect(runNoteLifecycleAction).not.toHaveBeenCalled() + expect(clearSelection).toHaveBeenCalled() + } finally { + unregister() + } + }) + + it('offers permanent delete on a note in the Trash', async () => { + const { trashSidebarCursorRow, runNoteLifecycleAction } = await setup(20) + await trashSidebarCursorRow() + expect(runNoteLifecycleAction).toHaveBeenCalledWith('trash/E.md', 'delete') + }) + + it('leaves a folder alone and says where folders are deleted', async () => { + const { trashSidebarCursorRow, runNoteLifecycleAction, runNoteBatchAction, useToastStore } = await setup(13) + expect(await trashSidebarCursorRow()).toBe(false) + expect(runNoteLifecycleAction).not.toHaveBeenCalled() + expect(runNoteBatchAction).not.toHaveBeenCalled() + expect(useToastStore.getState().toasts.map((t) => t.message)).toEqual([ + "A folder can't go to the Trash. Delete it from its menu (m); that can't be undone" + ]) + }) + + it('a cancelled confirm leaves the selection as it was', async () => { + const { trashSidebarCursorRow, registerSidebarActionHost, runNoteBatchAction } = await setup(11) + vi.mocked(runNoteBatchAction).mockResolvedValueOnce(false) + const clearSelection = vi.fn() + const unregister = registerSidebarActionHost({ + selection: () => selectionOf(['inbox/A.md', 'inbox/B.md']), + clearSelection, + holdCursor: (run) => run() + }) + try { + expect(await trashSidebarCursorRow()).toBe(false) + expect(clearSelection).not.toHaveBeenCalled() + } finally { + unregister() + } + }) +}) + +describe('Move Note to Trash follows the sidebar (#972)', () => { + async function command(id: string) { + const loaded = await load() + const cmd = loaded.buildCommands({ includeUnavailable: true }).find((c) => c.id === id) + if (!cmd) throw new Error(`no ${id}`) + return { ...loaded, cmd } + } + + it('acts on the sidebar row, not the open note, while the sidebar holds focus', async () => { + drawSidebar() + const { cmd, useStore, runNoteLifecycleAction } = await command('note.trash') + const trashActive = vi.fn() + useStore.setState({ + notes: NOTES as never, + activeNote: note('inbox', 'inbox/A.md'), + selectedPath: 'inbox/A.md', + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: 12, + trashActive + } as never) + expect(cmd.when?.()).toBe(true) + await cmd.run() + expect(runNoteLifecycleAction).toHaveBeenCalledWith('inbox/C.md', 'trash') + expect(trashActive).not.toHaveBeenCalled() + }) + + it('works on the sidebar row with no note open in the editor', async () => { + drawSidebar() + const { cmd, useStore } = await command('note.trash') + useStore.setState({ + notes: NOTES as never, + activeNote: null, + selectedPath: null, + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: 10 + } as never) + expect(cmd.when?.()).toBe(true) + }) + + it('keeps acting on the open note when the editor has focus or the cursor is not on a note', async () => { + drawSidebar() + const { cmd, useStore, runNoteLifecycleAction } = await command('note.trash') + const trashActive = vi.fn().mockResolvedValue(undefined) + useStore.setState({ + notes: NOTES as never, + activeNote: note('inbox', 'inbox/A.md'), + selectedPath: 'inbox/A.md', + focusedPanel: 'editor', + sidebarOpen: true, + sidebarCursorIndex: 12, + trashActive + } as never) + await cmd.run() + expect(trashActive).toHaveBeenCalledTimes(1) + useStore.setState({ focusedPanel: 'sidebar', sidebarCursorIndex: 13 } as never) + await cmd.run() + expect(trashActive).toHaveBeenCalledTimes(2) + expect(runNoteLifecycleAction).not.toHaveBeenCalled() + // With nothing open either, it says what is missing. + useStore.setState({ activeNote: null, selectedPath: null } as never) + expect(cmd.when?.()).toBe(false) + expect(cmd.unavailableReason?.()).toBe('Move Note to Trash needs an open note') + }) + + it('says a trashed sidebar note is in the Trash already', async () => { + drawSidebar() + const { cmd, useStore } = await command('note.trash') + useStore.setState({ + notes: NOTES as never, + activeNote: note('inbox', 'inbox/A.md'), + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: 20 + } as never) + expect(cmd.when?.()).toBe(false) + expect(cmd.unavailableReason?.()).toBe('This note is already in the Trash') + }) + + it('Move Note to Archive follows the sidebar the same way', async () => { + drawSidebar() + const { cmd, useStore, runNoteLifecycleAction } = await command('note.archive') + const archiveActive = vi.fn() + useStore.setState({ + notes: NOTES as never, + activeNote: note('inbox', 'inbox/A.md'), + selectedPath: 'inbox/A.md', + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: 11, + archiveActive + } as never) + expect(cmd.when?.()).toBe(true) + await cmd.run() + expect(runNoteLifecycleAction).toHaveBeenCalledWith('inbox/B.md', 'archive') + expect(archiveActive).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/lib/sidebar-note-actions.ts b/packages/app-core/src/lib/sidebar-note-actions.ts new file mode 100644 index 000000000..c5c3f369a --- /dev/null +++ b/packages/app-core/src/lib/sidebar-note-actions.ts @@ -0,0 +1,363 @@ +/** + * Moving notes to the Trash (and the Archive) from the sidebar by keyboard + * (#972). + * + * The keys: `x` (nav.delete) with Vim on, and Mod+Backspace + * (nav.sidebarTrash) in either mode, act on the row under the sidebar's + * cursor while the sidebar holds focus. The palette's Move Note to Trash and + * Move Note to Archive, and a key the user gave either of them (#971), follow + * the same row while the sidebar holds focus and its cursor is on a note, + * instead of the note open in the editor. + * + * What each row does: + * - A note: Move to Trash, after the same confirm the Archive view and the + * palette show. A note already in the Trash is deleted for good, after the + * Trash view's confirm (keys only; the palette command says it is there + * already). + * - A note inside a multi-selection: the whole selection, with one confirm + * ("Move 3 notes to Trash?"), through the batch path the sidebar's + * selection menu uses. + * - A folder: nothing, and a note says why. Deleting a folder takes + * everything inside with it and cannot be undone, while the same key on a + * note is a reversible move; one key meaning both on rows that sit side by + * side is how a j/k walk ends in a deleted folder. The folder menu keeps it. + * - Any other row (a tag, Tasks, the Trash row itself): nothing, and a note. + * + * The sidebar owns its selection (React state), so it registers a host here + * that hands over a snapshot of it, clears it after a batch, and holds the + * cursor in place while the action runs (closing the trashed note's tab + * changes the open note, which the sidebar would otherwise reveal). + */ +import type { NoteFolder, NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { runNoteBatchAction, runNoteLifecycleAction } from './note-lifecycle-actions' +import { resolveSystemFolderLabels } from './system-folder-labels' +import { useToastStore } from './toast' +import { isChordEvent, matchesShortcut, type KeymapOverrides } from './keymaps' +import { chordHeldByApp, listViewBindingMatches } from './list-view-keys' +import { commandKeymapsStandDown } from './command-keymap-dispatch' + +export type SidebarNoteAction = 'trash' | 'archive' + +/** What a sidebar row is, read off its data attributes. */ +export interface SidebarRowRef { + type: string | null + path: string | null + folder: string | null + subpath: string | null +} + +/** The sidebar's multi-selection, as the planner needs it. */ +export interface SidebarSelectionSnapshot { + /** Selected rows, notes and folders together. */ + count: number + /** Every selected note's path. */ + notePaths: ReadonlySet<string> + /** The selected notes a batch acts on: the selection menu's own set, which + * leaves out notes inside a selected folder. */ + actionNotePaths: readonly string[] + /** Selected folders. */ + folderCount: number +} + +export type SidebarNotePlan = + | { + kind: 'run' + action: 'trash' | 'delete' | 'archive' + paths: string[] + /** True when the selection is the target: one confirm for all, and the + * selection clears afterwards. */ + batch: boolean + } + | { kind: 'refuse'; reason: string } + +type FolderLabels = Record<NoteFolder, string> + +export function readSidebarRow(el: Element | null | undefined): SidebarRowRef | null { + if (!el) return null + return { + type: el.getAttribute('data-sidebar-type'), + path: el.getAttribute('data-sidebar-path'), + folder: el.getAttribute('data-sidebar-folder'), + subpath: el.getAttribute('data-sidebar-subpath') + } +} + +/** The row under the sidebar's cursor, or null when no row has its number. */ +export function sidebarCursorRow( + cursorIndex: number, + root: ParentNode = document +): SidebarRowRef | null { + if (!Number.isInteger(cursorIndex) || cursorIndex < 0) return null + return readSidebarRow(root.querySelector(`[data-sidebar-idx="${cursorIndex}"]`)) +} + +const isLive = (folder: NoteFolder): boolean => folder !== 'trash' +const isArchivable = (folder: NoteFolder): boolean => folder === 'inbox' || folder === 'quick' + +/** + * What an action on the note row at `path` does. `permanentForTrashed` is + * the keys' rule (a trashed note is deleted for good); the palette's Move + * Note to Trash refuses instead, since its name says what it does. + */ +export function planSidebarNoteAction(input: { + action: SidebarNoteAction + path: string + notes: readonly Pick<NoteMeta, 'path' | 'folder'>[] + selection: SidebarSelectionSnapshot | null + labels: FolderLabels + vimMode: boolean + permanentForTrashed: boolean +}): SidebarNotePlan { + const { action, path, labels } = input + const folderOf = new Map(input.notes.map((note) => [note.path, note.folder])) + const folder = folderOf.get(path) + if (!folder) return { kind: 'refuse', reason: 'That note is no longer in the vault' } + + const selection = input.selection + if (selection && selection.count > 1 && selection.notePaths.has(path)) { + if (selection.folderCount > 0) { + return { + kind: 'refuse', + reason: `The selection includes folders, which can't go to the ${labels.trash}. Use the selection's menu (${menuKey(input.vimMode)}) instead` + } + } + const candidates = selection.actionNotePaths.filter((p) => folderOf.has(p)) + if (action === 'archive') { + const archivable = candidates.filter((p) => isArchivable(folderOf.get(p)!)) + if (archivable.length === 0) { + return { + kind: 'refuse', + reason: `None of the selected notes is in ${labels.inbox} or ${labels.quick}` + } + } + return { kind: 'run', action: 'archive', paths: archivable, batch: archivable.length > 1 } + } + const live = candidates.filter((p) => isLive(folderOf.get(p)!)) + if (live.length > 0) { + return { kind: 'run', action: 'trash', paths: live, batch: live.length > 1 } + } + if (!input.permanentForTrashed) { + return { kind: 'refuse', reason: `These notes are already in the ${labels.trash}` } + } + return { kind: 'run', action: 'delete', paths: candidates, batch: candidates.length > 1 } + } + + if (action === 'archive') { + if (isArchivable(folder)) return { kind: 'run', action: 'archive', paths: [path], batch: false } + return { + kind: 'refuse', + reason: + folder === 'archive' + ? `This note is already in the ${labels.archive}` + : `Move Note to ${labels.archive} works on a note in ${labels.inbox} or ${labels.quick}` + } + } + if (isLive(folder)) return { kind: 'run', action: 'trash', paths: [path], batch: false } + if (!input.permanentForTrashed) { + return { kind: 'refuse', reason: `This note is already in the ${labels.trash}` } + } + return { kind: 'run', action: 'delete', paths: [path], batch: false } +} + +function menuKey(vimMode: boolean): string { + return vimMode ? 'm' : 'right-click' +} + +/** Why the keys do nothing on a row that is not a note. */ +export function sidebarRowRefusal( + row: SidebarRowRef | null, + labels: FolderLabels, + vimMode: boolean +): string { + if (row?.type === 'folder' && row.subpath) { + return `A folder can't go to the ${labels.trash}. Delete it from its menu (${menuKey(vimMode)}); that can't be undone` + } + return `Only a note can go to the ${labels.trash} from here` +} + +/* ------------------------------------------------------------------------ */ +/* The sidebar's side */ +/* ------------------------------------------------------------------------ */ + +export interface SidebarActionHost { + selection: () => SidebarSelectionSnapshot + clearSelection: () => void + /** Run an action with the sidebar's cursor held where its anchoring puts + * it: a change of open note while it runs is not a pick to reveal. */ + holdCursor: <T>(run: () => Promise<T>) => Promise<T> +} + +let host: SidebarActionHost | null = null + +/** The sidebar registers itself on mount; the returned function unregisters. */ +export function registerSidebarActionHost(next: SidebarActionHost): () => void { + host = next + return () => { + if (host === next) host = null + } +} + +interface SidebarFocusState { + focusedPanel: string | null + sidebarOpen: boolean + sidebarCursorIndex: number +} + +/** The sidebar holds the keyboard: the panel the keys route to. */ +export function sidebarHoldsFocus(state: SidebarFocusState): boolean { + return state.focusedPanel === 'sidebar' && state.sidebarOpen +} + +function folderLabels(): FolderLabels { + const labels = resolveSystemFolderLabels(useStore.getState().systemFolderLabels) + return { inbox: labels.inbox, quick: labels.quick, archive: labels.archive, trash: labels.trash } +} + +/** + * The plan for a palette command while the sidebar holds focus with its + * cursor on a note row, or null when the sidebar does not own the target + * (the command then acts on the open note, as it always has). + */ +export function sidebarCommandPlan(action: SidebarNoteAction): SidebarNotePlan | null { + if (typeof document === 'undefined') return null + const state = useStore.getState() + if (!sidebarHoldsFocus(state)) return null + const row = sidebarCursorRow(state.sidebarCursorIndex) + if (row?.type !== 'note' || !row.path) return null + return planSidebarNoteAction({ + action, + path: row.path, + notes: state.notes, + selection: host?.selection() ?? null, + labels: folderLabels(), + vimMode: state.vimMode, + permanentForTrashed: false + }) +} + +/** Carry out a plan: the confirm, the move, a cleared selection after a + * batch, or the note that says why nothing happened. */ +export async function runSidebarNotePlan(plan: SidebarNotePlan): Promise<boolean> { + if (plan.kind === 'refuse') { + useToastStore.getState().addToast(plan.reason, 'info') + return false + } + const run = async (): Promise<boolean> => { + if (plan.batch) { + const ok = await runNoteBatchAction(plan.paths, plan.action) + if (ok) host?.clearSelection() + return ok + } + const result = await runNoteLifecycleAction(plan.paths[0], plan.action) + return result === 'completed' + } + return host ? host.holdCursor(run) : run() +} + +/** Trash (or delete for good) what the sidebar's cursor is on, as the keys + * do. A row that is not a note gets a note saying so. */ +export async function trashSidebarCursorRow(): Promise<boolean> { + const state = useStore.getState() + const labels = folderLabels() + const row = sidebarCursorRow(state.sidebarCursorIndex) + if (row?.type !== 'note' || !row.path) { + useToastStore.getState().addToast(sidebarRowRefusal(row, labels, state.vimMode), 'info') + return false + } + return runSidebarNotePlan( + planSidebarNoteAction({ + action: 'trash', + path: row.path, + notes: state.notes, + selection: host?.selection() ?? null, + labels, + vimMode: state.vimMode, + permanentForTrashed: true + }) + ) +} + +/* ------------------------------------------------------------------------ */ +/* Keys */ +/* ------------------------------------------------------------------------ */ + +/** Whether the keydown is one of the sidebar's trash keys: Mod+Backspace + * (or what it was rebound to) in either mode, and `x` (nav.delete) with Vim + * on, or in either mode when it was rebound to a chord. */ +export function isSidebarTrashKey( + event: KeyboardEvent, + overrides: KeymapOverrides | null | undefined, + vimMode: boolean +): boolean { + return ( + matchesShortcut(event, overrides, 'nav.sidebarTrash') || + listViewBindingMatches(event, overrides, 'nav.delete', vimMode) + ) +} + +type SidebarKeyState = SidebarFocusState & + Parameters<typeof commandKeymapsStandDown>[0] & { + keymapOverrides: KeymapOverrides + vimMode: boolean + } + +/** + * The capture-phase handler for the chord forms of the trash keys. Returns + * true when it took the key. + * + * Typed keys (`x`) are VimNav's, after its leader and pane-prefix machinery, + * so `Space x` still reaches a leader binding; this listener only takes a + * chord or a named key such as Delete, in either mode. + */ +export function handleSidebarTrashChord( + event: KeyboardEvent, + getState: () => SidebarKeyState, + run: () => unknown = trashSidebarCursorRow +): boolean { + if (event.isComposing) return false + if (!isChordEvent(event) && event.key.length === 1) return false + const state = getState() + if (!sidebarHoldsFocus(state)) return false + if (!isSidebarTrashKey(event, state.keymapOverrides, state.vimMode)) return false + if (commandKeymapsStandDown(state)) return false + // A global shortcut keeps its key here too (see list-view-keys.ts). + if (chordHeldByApp(event, state.keymapOverrides, state.vimMode)) return false + // Focus in a text field or the editor is typing, whatever the store's + // panel says: Mod+Backspace there deletes text. + const target = event.target instanceof HTMLElement ? event.target : null + if ( + target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + ) { + return false + } + event.preventDefault() + event.stopImmediatePropagation() + // Holding the chord must not trash row after row. + if (event.repeat) return true + void run() + return true +} + +let installed: (() => void) | null = null + +/** + * Install the chord listener once on `window`, capture phase. App calls it at + * import time, after the command keymap dispatcher (a key the user gave a + * command still wins) and before any component's listener, so a list view + * that stays active behind a focused sidebar never sees the chord first. + */ +export function installSidebarTrashKeys(getState: () => SidebarKeyState): () => void { + if (installed) return installed + if (typeof window === 'undefined') return () => {} + const listener = (event: KeyboardEvent): void => { + handleSidebarTrashChord(event, getState) + } + window.addEventListener('keydown', listener, true) + installed = () => { + window.removeEventListener('keydown', listener, true) + installed = null + } + return installed +} diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index daedb61c8..d936ee72e 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -160,6 +160,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "nav.peekPreview", group: "view-actions", defaultBinding: "p", title: "Peek preview" }, { id: "nav.restore", group: "view-actions", defaultBinding: "r", title: "Restore trashed note" }, { id: "nav.delete", group: "view-actions", defaultBinding: "x", title: "Delete selected result" }, + { id: "nav.sidebarTrash", group: "view-actions", defaultBinding: "Mod+Backspace", title: "Move sidebar note to Trash" }, { id: "nav.toggleTask", group: "view-actions", defaultBinding: "x", title: "Toggle task" }, { id: "tasks.moveTaskUp", group: "view-actions", defaultBinding: "K", title: "Move task up" }, { id: "tasks.moveTaskDown", group: "view-actions", defaultBinding: "J", title: "Move task down" }, From 49a82972a5266e44778812900e5351ec60805b0a Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 13:27:24 -0500 Subject: [PATCH 22/34] Docs(help): the manual says how to trash from the sidebar and that list chords work (#972) - a How-To, "Trash notes from the sidebar": Mod+Backspace in either mode and `x` with Vim on, the one confirm for a selection, Escape, where the cursor lands, why folders are left to their menu, Move Note to Trash and Move Note to Archive following the sidebar, and the action's name in Settings; - a How-To, "Ctrl, Alt and Cmd keys in list views": a chord bound to a list action works with Vim on or off, `Mod` is portable, a global shortcut keeps its key, and what "Never fires" in Settings means; - `x` and Mod+Backspace in the sidebar shortcut table, which now says Mod+Backspace works with Vim off too; - the Tasks, tags and trash views note that a rebound chord works in either mode. --- packages/app-core/src/lib/help.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index efecee27c..12db29963 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -171,6 +171,16 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Use Cmd-click on macOS or Ctrl-click on Windows/Linux to toggle notes and folders in the sidebar. Use Shift-click to select a visible range. The context menu then applies to the selected set, including open in tabs, move, archive, trash, restore, delete folders, copy paths, and drag/drop moves where those actions make sense.' }, + { + title: 'Trash notes from the sidebar', + body: + 'With the sidebar focused, `Mod+Backspace` (⌘⌫ on macOS, Ctrl+Backspace on Windows and Linux) moves the note under the sidebar cursor to the Trash, after the same confirm Move Note to Trash shows; in Vim mode `x` does the same. When that note is part of a multi-selection, the whole selection goes, with one confirm (“Move 3 notes to Trash?”). Escape on the confirm leaves everything as it was, and after the move the cursor sits on the row that took the note’s place, still in the sidebar. A folder is not trashed this way, since deleting a folder takes everything inside and cannot be undone: delete it from its menu (`m`, or right-click). The palette’s Move Note to Trash and Move Note to Archive, and any key you gave them, follow the sidebar the same way: with the sidebar focused and its cursor on a note, they act on that note (or the selection it is in) rather than the note open in the editor. Rebind the chord as Move sidebar note to Trash (`nav.sidebarTrash`) under Settings → Keymap, or unbind it; `x` is Delete selected result (`nav.delete`).' + }, + { + title: 'Ctrl, Alt and Cmd keys in list views', + body: + 'An action in a list view (Archive, Trash, Quick Notes, Tags, Tasks, Unresolved Links, Workflows, the Timeline) can take a chord such as `"nav.delete" = "Ctrl+Backspace"` or `"nav.delete" = "Mod+Backspace"` in `config.toml`, or from Settings → Keymap, and so can the sidebar’s Delete selected result. Unlike the single letters, such a chord works with Vim mode on or off. `Mod` means ⌘ on macOS and Ctrl on Windows and Linux, so one config.toml serves both. A global shortcut keeps its key everywhere, though: an action bound to Mod+P would lose to Search notes, so Settings marks such a binding “Never fires” and names the shortcut that has the key.' + }, { title: 'Read a note beside its source', body: @@ -675,7 +685,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'lists-and-sidebar', title: 'Sidebar and list navigation', - description: 'These bindings work when the sidebar or note list owns focus in Vim mode.', + description: 'These bindings work when the sidebar or note list owns focus in Vim mode. Mod+Backspace works with Vim mode off too.', items: [ { keys: 'j / k', action: 'Move selection', detail: 'Move down or up one visible item.' }, { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Fast travel to the first or last visible row.' }, @@ -686,6 +696,8 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'o', action: 'Toggle folder', detail: 'Expand or collapse the selected folder in the sidebar.' }, { keys: '/', action: 'Search notes', detail: 'Open note search directly from keyboard navigation mode.' }, { keys: 'm', action: 'Open context menu', detail: 'Open the right-click menu for the selected sidebar or note-list row, including move, archive, trash, floating-window, and reveal actions where they apply.' }, + { keys: 'x', action: 'Move to Trash', detail: 'Move the note under the cursor to the Trash after a confirm, or the whole multi-selection when that note is part of it. The cursor stays in the sidebar, on the row that took the note’s place. Folders are left alone: delete one from its menu (`m`).' }, + { keys: 'Mod+Backspace', action: 'Move to Trash, Vim on or off', detail: 'The same as `x`, with Vim mode on or off: ⌘⌫ on macOS, Ctrl+Backspace on Windows and Linux. Rebind or unbind it as Move sidebar note to Trash under Settings → Keymap.' }, { keys: 'Esc', action: 'Return to editor', detail: 'Drop back into the main editor focus path.' } ] }, @@ -743,7 +755,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'tasks-tags-trash', title: 'Tasks, tags, and trash views', - description: 'These virtual views each run their own keyboard loop in the main pane, and the Vim leader works here too (for example Space h for hint mode). The single keys below belong to Vim mode: with it off only the arrows, Enter and Escape stay live everywhere, plus Shift+J/K to reorder in the list, Tab to pick a task on the calendar and Space to toggle one on the board or the calendar; everything else sits in the right-click menu.', + description: 'These virtual views each run their own keyboard loop in the main pane, and the Vim leader works here too (for example Space h for hint mode). The single keys below belong to Vim mode: with it off only the arrows, Enter and Escape stay live everywhere, plus Shift+J/K to reorder in the list, Tab to pick a task on the calendar and Space to toggle one on the board or the calendar; everything else sits in the right-click menu. An action you rebind to a chord with Ctrl, Alt or Cmd works with Vim mode on or off.', items: [ { keys: 'j / k', action: 'Move row cursor', detail: 'Step through task rows, tagged notes and their tagged blocks, or trashed notes.' }, { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Move to the first or last visible result.' }, From 9b070afed2fdc1bef5640115c012e37df01597a0 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 13:54:32 -0500 Subject: [PATCH 23/34] Fix(sidebar): with Vim off the sidebar walks like a tree, and Focus Sidebar keeps the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Vim mode off, a focused sidebar went through App's catch-all for panels, which treats every panel as a flat list: the arrows moved the cursor, Home and End jumped, Enter clicked the row, and ← or Escape handed the keyboard back to the editor. A folder could not be expanded or collapsed from the keyboard (→ did nothing, ← left the sidebar), no key opened a row's menu, and Page Up / Page Down did nothing. The handler that knows the sidebar's rows (folders, tags, date groups, databases), VimNav's handleSidebarKey, only listened with Vim on. That handler now serves the sidebar in both modes. With Vim off, a listener VimNav installs once routes the focused sidebar's keys to it, and the handler keeps the typed keys of its actions (j, k, l, h, o, m, /, gg, G, x) off in that mode through listViewBindingMatches, the rule every list follows. With Vim off: - ↑ / ↓ move the cursor, Home / End jump to the ends, and Page Up / Page Down move a screenful of rows (what the scroll box shows, less one). - → and Enter open the row, as l does: a note opens and the editor takes the keyboard, a folder expands. - ← collapses a folder, or from a note folds its folder and lands on it, as h does. It no longer leaves the sidebar; Escape does. - Shift+F10 and the Context Menu key open the row's menu. - Mod+Backspace trashes the cursor row, as before (#972). The listener stands down for chords, dialogs, menus, palettes, Settings, text fields and self-keyed surfaces, and the catch-all leaves the sidebar to it. It is installed once rather than per mode so it keeps its early place among the window's capture listeners, ahead of a list view left active behind the sidebar. Enter on a note now opens it the way Vim's Enter and l do (a kept tab), where the catch-all clicked the row (a preview tab). Vim mode is unchanged: there the gating is matchesSequenceToken, as before, and Home, End and the page keys are left alone (gg and G are its keys). The three Vim smoke suites and #972's sidebar driver pass on the built app. Reaching the sidebar with Vim off: Alt+H works. Focus Sidebar from the command palette did not, in either mode. Closing the palette gives focus back to the editor, the editor claims its panel again, and the palette's refocus, which read the panel after the command had run, then took the keyboard straight back. Measured over CDP: the sidebar took DOM focus, and the editor took it back a millisecond later. The palette now reads the panel a command moved the keyboard to as it ran, before that close lands. Commands that do not move the panel as they run see no change. A plain click on a note still opens it and hands the keyboard to the editor (#599), so Mod+Backspace right after one is the editor's delete to line start, undoable, never a trash. The clicks that mean the row keep the keyboard in the sidebar, where Mod+Backspace acts on the cursor row: Cmd-click and Shift-click (the selection), a folder row, a row's menu. Tests: with Vim off, the arrows, Home / End and the page keys move, Enter opens and hands over, → expands and ← collapses without leaving, ← steps from a note to its folder, Escape returns, the menu keys open the cursor row's menu, every typed key is left alone, chords pass, and nothing answers unless the sidebar holds the keyboard or while a dialog, a menu or a text field has it. With Vim on, j / k, G, gg, l, / and Escape as before, and Home, End and the page keys untouched. --- packages/app-core/src/App.tsx | 5 + .../src/components/CommandPalette.tsx | 18 +- .../components/VimNav-sidebar-keys.test.ts | 317 ++++++++++++++++++ packages/app-core/src/components/VimNav.tsx | 128 +++++-- packages/app-core/src/lib/help.ts | 4 +- 5 files changed, 449 insertions(+), 23 deletions(-) create mode 100644 packages/app-core/src/components/VimNav-sidebar-keys.test.ts diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 2a59dd184..514e9325a 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -345,8 +345,13 @@ function AppUpdateNotice({ * * Returns nothing; the event is consumed only when a panel actually handled it, * so unrelated keys still reach the app. + * + * The sidebar is not one of these panels: its rows are folders to expand and + * collapse, not a flat list, so ← there collapses rather than leaving, and its + * keys go through VimNav's sidebar handler in both modes. */ function handlePanelKeyWithoutVim(e: KeyboardEvent, focusedPanel: string | null): void { + if (focusedPanel === 'sidebar') return if (e.metaKey || e.ctrlKey || e.altKey) return // A dialog over the panel owns its keys: the focused panel stays set while // one is open, so arrows would move the row behind it and Enter would open diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index f20abf198..aab06455f 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -336,8 +336,22 @@ export function CommandPalette(): JSX.Element { setOpen(false) // Asked before the run: afterwards today's note exists either way. const landing = commandLanding(cmd) + const panelBefore = useStore.getState().focusedPanel try { - await cmd.run() + const ran = cmd.run() + // A command that moves the keyboard to a panel as it runs (Focus + // Sidebar) is read here, before the await: by then the palette has + // closed and given the focus back to whatever held it, and when that + // was the editor, the editor claimed its panel again. Read afterwards, + // the command looked as if it had landed on the editor, and the refocus + // below took the keyboard straight back from the sidebar. + const panelHandedTo = useStore.getState().focusedPanel + await ran + const latest = useStore.getState() + const landedPanel = + panelHandedTo !== panelBefore && panelHandedTo != null && panelHandedTo !== 'editor' + ? panelHandedTo + : latest.focusedPanel // A command that opens a note (or otherwise lands on the editor) should // move DOM focus there — not just set focusedPanel. Closing the palette // otherwise leaves focus on whatever was focused before (e.g. the @@ -355,7 +369,7 @@ export function CommandPalette(): JSX.Element { // three focus the editor. if ( shouldRefocusEditorAfterCommand( - useStore.getState(), + { ...latest, focusedPanel: landedPanel }, useCloudSyncStatusStore.getState().conflictReviewOpen || useCloudSyncStatusStore.getState().settingsConflictPromptOpen || getPublishNoteRequest() !== null diff --git a/packages/app-core/src/components/VimNav-sidebar-keys.test.ts b/packages/app-core/src/components/VimNav-sidebar-keys.test.ts new file mode 100644 index 000000000..08106114d --- /dev/null +++ b/packages/app-core/src/components/VimNav-sidebar-keys.test.ts @@ -0,0 +1,317 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' +import { dispatchKeyboardContextMenu } from '../lib/keyboard-context-menu' +import { VimNav } from './VimNav' + +// jsdom refuses the MouseEvent the real dispatch builds (its `view`); the +// menu itself is the row's business, so what matters here is which row the +// key opened it on. +vi.mock('../lib/keyboard-context-menu', async (importActual) => ({ + ...(await importActual<typeof import('../lib/keyboard-context-menu')>()), + dispatchKeyboardContextMenu: vi.fn() +})) +const menuOpenedOn = vi.mocked(dispatchKeyboardContextMenu) + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// The focused sidebar answers the universal keys with Vim mode off (arrows, +// Enter, Escape, Home / End, the page keys, the menu keys) through the same +// handler Vim mode uses, while the typed Vim keys stay off. Vim mode keeps +// every key it had. + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + platformSync: () => 'darwin', + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false + }) + } + }) +} + +const ROW_HEIGHT = 24 + +/** A sidebar the way the DOM walkers read it: Tasks, the Inbox and Projects + * folders, then the Projects notes, one row under the other. jsdom has no + * layout, so every row is given its box by hand. */ +function renderSidebar(noteCount = 8): void { + const notes = Array.from( + { length: noteCount }, + (_, i) => + `<button data-sidebar-idx="${20 + i}" data-sidebar-type="note" data-sidebar-path="inbox/Projects/P${i + 1}.md">P${i + 1}</button>` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `<div class="zn-sidebar-scroll"> + <button data-sidebar-idx="1" data-sidebar-type="tasks">Tasks</button> + <div role="button" data-sidebar-idx="9" data-sidebar-type="folder" data-sidebar-folder="inbox" data-sidebar-subpath="" data-sidebar-key="inbox:">Inbox</div> + <div role="button" data-sidebar-idx="12" data-sidebar-type="folder" data-sidebar-folder="inbox" data-sidebar-subpath="Projects" data-sidebar-key="inbox:Projects">Projects</div> + ${notes} + </div>` + ) + document.querySelectorAll<HTMLElement>('[data-sidebar-idx]').forEach((el, i) => { + el.getClientRects = (() => [{ width: 200, height: ROW_HEIGHT }] as unknown as DOMRectList) as never + el.getBoundingClientRect = (() => + ({ + top: i * ROW_HEIGHT, + bottom: (i + 1) * ROW_HEIGHT, + left: 0, + right: 200, + width: 200, + height: ROW_HEIGHT + }) as DOMRect) as never + el.scrollIntoView = (() => {}) as never + }) + // Five rows show at a time. + Object.defineProperty(document.querySelector('.zn-sidebar-scroll'), 'clientHeight', { + configurable: true, + value: ROW_HEIGHT * 5 + }) +} + +const rowIdx = (path: string): number => + Number(document.querySelector<HTMLElement>(`[data-sidebar-path="${path}"]`)?.dataset.sidebarIdx) + +function press(key: string, code: string, init: KeyboardEventInit = {}): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init }) + act(() => { + document.body.dispatchEvent(event) + }) + return event +} + +const cursor = (): number => useStore.getState().sidebarCursorIndex + +describe('sidebar keys in VimNav', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + let selectNote: ReturnType<typeof vi.fn> + let toggleCollapseFolder: ReturnType<typeof vi.fn> + let setView: ReturnType<typeof vi.fn> + let setSearchOpen: ReturnType<typeof vi.fn> + + const mount = (vimMode: boolean): void => { + useStore.setState({ vimMode } as never) + act(() => root.render(createElement(VimNav))) + } + + beforeEach(() => { + installZen() + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + renderSidebar() + selectNote = vi.fn().mockResolvedValue(undefined) + toggleCollapseFolder = vi.fn() + setView = vi.fn() + setSearchOpen = vi.fn() + menuOpenedOn.mockClear() + useStore.setState({ + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: rowIdx('inbox/Projects/P1.md'), + collapsedFolders: [], + selectNote, + toggleCollapseFolder, + setView, + setSearchOpen, + editorViewRef: null + } as never) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + useStore.setState(originalState, true) + }) + + describe('with Vim mode off', () => { + it('moves the cursor with the arrows and takes the key', () => { + mount(false) + const down = press('ArrowDown', 'ArrowDown') + expect(down.defaultPrevented).toBe(true) + expect(cursor()).toBe(rowIdx('inbox/Projects/P2.md')) + press('ArrowDown', 'ArrowDown') + press('ArrowUp', 'ArrowUp') + expect(cursor()).toBe(rowIdx('inbox/Projects/P2.md')) + }) + + it('jumps to the ends with Home and End, and a screenful with the page keys', () => { + mount(false) + press('End', 'End') + expect(cursor()).toBe(rowIdx('inbox/Projects/P8.md')) + press('Home', 'Home') + expect(cursor()).toBe(1) + // Five rows show; a page is four, so the row left behind stays in view. + press('PageDown', 'PageDown') + expect(cursor()).toBe(rowIdx('inbox/Projects/P2.md')) + press('PageDown', 'PageDown') + expect(cursor()).toBe(rowIdx('inbox/Projects/P6.md')) + press('PageDown', 'PageDown') + expect(cursor()).toBe(rowIdx('inbox/Projects/P8.md')) + press('PageUp', 'PageUp') + expect(cursor()).toBe(rowIdx('inbox/Projects/P4.md')) + }) + + it('opens the note under the cursor with Enter and hands the keyboard to the editor', () => { + mount(false) + press('ArrowDown', 'ArrowDown') + expect(press('Enter', 'Enter').defaultPrevented).toBe(true) + expect(selectNote).toHaveBeenCalledWith('inbox/Projects/P2.md') + expect(useStore.getState().focusedPanel).toBe('editor') + }) + + it('expands a folder with ArrowRight and collapses it with ArrowLeft, staying in the sidebar', () => { + useStore.setState({ collapsedFolders: ['inbox:Projects'], sidebarCursorIndex: 12 } as never) + mount(false) + press('ArrowRight', 'ArrowRight') + expect(toggleCollapseFolder).toHaveBeenCalledWith('inbox:Projects') + expect(useStore.getState().focusedPanel).toBe('sidebar') + + toggleCollapseFolder.mockClear() + useStore.setState({ collapsedFolders: [] } as never) + press('ArrowLeft', 'ArrowLeft') + expect(toggleCollapseFolder).toHaveBeenCalledWith('inbox:Projects') + // ← used to hand the keyboard back to the editor from any row. + expect(useStore.getState().focusedPanel).toBe('sidebar') + }) + + it('steps from a note to its folder with ArrowLeft', () => { + useStore.setState({ collapsedFolders: ['inbox:Projects'] } as never) + mount(false) + press('ArrowDown', 'ArrowDown') + press('ArrowLeft', 'ArrowLeft') + expect(cursor()).toBe(12) + expect(useStore.getState().focusedPanel).toBe('sidebar') + }) + + it('returns to the editor with Escape', () => { + mount(false) + expect(press('Escape', 'Escape').defaultPrevented).toBe(true) + expect(useStore.getState().focusedPanel).toBe('editor') + }) + + it('opens the row menu with the Context Menu key and Shift+F10', () => { + mount(false) + const row = document.querySelector('[data-sidebar-path="inbox/Projects/P1.md"]') + expect(press('ContextMenu', 'ContextMenu').defaultPrevented).toBe(true) + expect(press('F10', 'F10', { shiftKey: true }).defaultPrevented).toBe(true) + expect(menuOpenedOn.mock.calls).toEqual([[row], [row]]) + }) + + it.each([ + ['j', 'KeyJ'], + ['k', 'KeyK'], + ['l', 'KeyL'], + ['h', 'KeyH'], + ['o', 'KeyO'], + ['x', 'KeyX'], + ['d', 'KeyD'], + ['m', 'KeyM'], + ['g', 'KeyG'], + ['G', 'KeyG'], + ['/', 'Slash'] + ])('leaves the typed key %s alone', (key, code) => { + mount(false) + const before = cursor() + const event = press(key, code, key === 'G' ? { shiftKey: true } : {}) + if (key === 'g') press('g', 'KeyG') + expect(event.defaultPrevented).toBe(false) + expect(cursor()).toBe(before) + expect(selectNote).not.toHaveBeenCalled() + expect(toggleCollapseFolder).not.toHaveBeenCalled() + expect(setSearchOpen).not.toHaveBeenCalled() + expect(menuOpenedOn).not.toHaveBeenCalled() + expect(useStore.getState().focusedPanel).toBe('sidebar') + }) + + it('leaves chords to the app', () => { + mount(false) + expect(press('ArrowDown', 'ArrowDown', { altKey: true }).defaultPrevented).toBe(false) + expect(press('ArrowDown', 'ArrowDown', { metaKey: true }).defaultPrevented).toBe(false) + expect(cursor()).toBe(rowIdx('inbox/Projects/P1.md')) + }) + + it('does nothing unless the sidebar holds the keyboard', () => { + useStore.setState({ focusedPanel: 'editor' } as never) + mount(false) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(false) + expect(cursor()).toBe(rowIdx('inbox/Projects/P1.md')) + }) + + it('stands down while a dialog or menu is open', () => { + mount(false) + const dialog = document.createElement('div') + dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(false) + expect(press('Enter', 'Enter').defaultPrevented).toBe(false) + dialog.remove() + const menu = document.createElement('div') + menu.setAttribute('data-ctx-menu', '') + document.body.append(menu) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(false) + expect(cursor()).toBe(rowIdx('inbox/Projects/P1.md')) + expect(selectNote).not.toHaveBeenCalled() + }) + + it('leaves a text field its keys', () => { + mount(false) + const input = document.createElement('input') + document.querySelector('.zn-sidebar-scroll')!.append(input) + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', bubbles: true, cancelable: true }) + act(() => { + input.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(false) + expect(cursor()).toBe(rowIdx('inbox/Projects/P1.md')) + }) + }) + + describe('with Vim mode on', () => { + it('keeps j / k, G and gg', () => { + mount(true) + press('j', 'KeyJ') + press('j', 'KeyJ') + expect(cursor()).toBe(rowIdx('inbox/Projects/P3.md')) + press('k', 'KeyK') + expect(cursor()).toBe(rowIdx('inbox/Projects/P2.md')) + press('G', 'KeyG', { shiftKey: true }) + expect(cursor()).toBe(rowIdx('inbox/Projects/P8.md')) + press('g', 'KeyG') + press('g', 'KeyG') + expect(cursor()).toBe(1) + }) + + it('keeps l to open, the arrows, Escape and /', () => { + mount(true) + press('ArrowDown', 'ArrowDown') + press('l', 'KeyL') + expect(selectNote).toHaveBeenCalledWith('inbox/Projects/P2.md') + useStore.setState({ focusedPanel: 'sidebar' } as never) + press('/', 'Slash') + expect(setSearchOpen).toHaveBeenCalledWith(true) + press('Escape', 'Escape') + expect(useStore.getState().focusedPanel).toBe('editor') + }) + + it('leaves Home, End and the page keys as they were', () => { + mount(true) + for (const key of ['Home', 'End', 'PageDown', 'PageUp']) { + expect(press(key, key).defaultPrevented).toBe(false) + } + expect(cursor()).toBe(rowIdx('inbox/Projects/P1.md')) + }) + }) +}) diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index c55115d1d..ccf2ec6b9 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -44,8 +44,11 @@ import { matchesShortcut, matchesShortcutBinding, UNBOUND_BINDING, - sequenceTokenFromEvent + sequenceTokenFromEvent, + type KeymapId } from '../lib/keymaps' +import { listViewBindingMatches } from '../lib/list-view-keys' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { toggleWrap, wrapLink } from '../lib/cm-format' import { commandLeaderHints, stepCommandLeader } from '../lib/command-keymaps' import { runBoundCommand } from '../lib/command-keymap-dispatch' @@ -1597,11 +1600,54 @@ export function VimNav(): JSX.Element | null { whichKeyHintTimeoutMs ]) // ← stable dep, handler never re-registers unnecessarily + // With Vim off the listener above is not installed, yet a focused sidebar + // still answers the keys every list answers: the arrows, Enter, Escape, + // Home / End, the page keys and the menu keys. They go through the same + // handleSidebarKey, which keeps the typed Vim keys off in this mode. + // Installed once rather than per mode, so it keeps its early place among + // the window's capture listeners: a list view left active behind the + // sidebar (Archive, Trash) listens there too, and must not take the + // sidebar's arrows first. + useEffect(() => { + const handler = (e: KeyboardEvent): void => { + const state = useStore.getState() + if (state.vimMode || !sidebarHoldsFocus(state)) return + // A chord is the app's (the trash chord has a listener of its own). + if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return + if ( + state.searchOpen || + state.vaultTextSearchOpen || + state.settingsOpen || + state.whatsNewOpen || + state.commandPaletteOpen || + state.bufferPaletteOpen || + isOverlayOrDialogOpen() || + document.querySelector('[data-workflow-import]') + ) { + return + } + const target = e.target instanceof HTMLElement ? e.target : null + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable || + target.closest(SELF_KEYED_SURFACES)) + ) { + return + } + handleSidebarKey(e, state) + } + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + }, []) + // ---- Key handlers (called from the single persistent handler) -------- function handleSidebarKey(e: KeyboardEvent, state: ReturnType<typeof useStore.getState>): void { const key = e.key const overrides = state.keymapOverrides + const vimMode = state.vimMode // Read before the line below claims the panel: the trash keys act only // when the sidebar already held the keyboard, where its cursor is drawn, // never on a key that merely fell through to the sidebar. @@ -1611,14 +1657,33 @@ export function VimNav(): JSX.Element | null { const count = items.length const max = count - 1 const currentPos = findPositionByIndex(items, 'sidebarIdx', state.sidebarCursorIndex) + // This handler serves the sidebar in both modes. With Vim off the typed + // keys of these actions (j, k, l, h, o, m, /, gg, G) stay off, as in + // every list: only the arrows, Enter, Escape, the page keys and the + // menu keys answer. With Vim on this is matchesSequenceToken. + const bound = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const wantsContextMenu = - matchesSequenceToken(e, overrides, 'nav.contextMenu') || + bound('nav.contextMenu') || key === 'ContextMenu' || (e.shiftKey && key === 'F10') // `x` (#972). The chord form (Mod+Backspace) is taken earlier, in either // mode, by installSidebarTrashKeys; a typed key lands here, after the // leader and pane-prefix handling above. - const wantsTrash = isSidebarTrashKey(e, overrides, state.vimMode) + const wantsTrash = isSidebarTrashKey(e, overrides, vimMode) + // With Vim off, where gg and G are off, Home and End jump to the ends and + // the page keys move a screenful of rows. Vim mode leaves all four as + // they were. + const pageMove = vimMode + ? null + : key === 'Home' + ? 'first' + : key === 'End' + ? 'last' + : key === 'PageDown' + ? 'pageDown' + : key === 'PageUp' + ? 'pageUp' + : null // Always consume single-char nav keys when sidebar is focused, // even if the sidebar is empty — prevents them leaking to the editor. @@ -1626,14 +1691,14 @@ export function VimNav(): JSX.Element | null { // Trash), which listens on the window too, never acts on its own row // for a key meant for the sidebar. const wantsHandledKey = - matchesSequenceToken(e, overrides, 'nav.moveDown') || - matchesSequenceToken(e, overrides, 'nav.moveUp') || - matchesSequenceToken(e, overrides, 'nav.jumpBottom') || - sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0] || - matchesSequenceToken(e, overrides, 'nav.openSideItem') || - matchesSequenceToken(e, overrides, 'nav.back') || - matchesSequenceToken(e, overrides, 'nav.toggleFolder') || - matchesSequenceToken(e, overrides, 'nav.filter') || + bound('nav.moveDown') || + bound('nav.moveUp') || + bound('nav.jumpBottom') || + (vimMode && sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0]) || + bound('nav.openSideItem') || + bound('nav.back') || + bound('nav.toggleFolder') || + bound('nav.filter') || key === 'Enter' || key === 'Escape' || key === 'ArrowDown' || @@ -1641,7 +1706,8 @@ export function VimNav(): JSX.Element | null { key === 'ArrowLeft' || key === 'ArrowRight' || wantsContextMenu || - wantsTrash + wantsTrash || + pageMove !== null if (wantsHandledKey) { e.preventDefault() e.stopImmediatePropagation() @@ -1656,19 +1722,34 @@ export function VimNav(): JSX.Element | null { return } - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (pageMove) { + const target = + pageMove === 'first' + ? 0 + : pageMove === 'last' + ? max + : moveIndex( + currentPos, + count, + (pageMove === 'pageDown' ? 1 : -1) * sidebarPageRows(items[currentPos]) + ) + scrollToIndexedElement(items[target], 'sidebarIdx', state.setSidebarCursorIndex) + return + } + if (bound('nav.moveDown') || key === 'ArrowDown') { scrollToIndexedElement(items[Math.min(currentPos + 1, max)], 'sidebarIdx', state.setSidebarCursorIndex) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (bound('nav.moveUp') || key === 'ArrowUp') { scrollToIndexedElement(items[Math.max(currentPos - 1, 0)], 'sidebarIdx', state.setSidebarCursorIndex) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (bound('nav.jumpBottom')) { scrollToIndexedElement(items[max], 'sidebarIdx', state.setSidebarCursorIndex) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -1686,15 +1767,15 @@ export function VimNav(): JSX.Element | null { ) { return } - if (key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openSideItem') || key === 'ArrowRight') { + if (key === 'Enter' || bound('nav.openSideItem') || key === 'ArrowRight') { activateSidebarItem(items[currentPos], state) return } - if (matchesSequenceToken(e, overrides, 'nav.back') || key === 'ArrowLeft') { + if (bound('nav.back') || key === 'ArrowLeft') { collapseSidebarItem(items[currentPos], state) return } - if (matchesSequenceToken(e, overrides, 'nav.toggleFolder')) { + if (bound('nav.toggleFolder')) { toggleSidebarItem(items[currentPos], state) return } @@ -1702,7 +1783,7 @@ export function VimNav(): JSX.Element | null { focusEditor() return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (bound('nav.filter')) { state.setSearchOpen(true) return } @@ -1712,6 +1793,15 @@ export function VimNav(): JSX.Element | null { } } + // A page in the sidebar is the rows its scroll box shows, less one, so the + // row the cursor leaves is still on screen where it lands. + function sidebarPageRows(row: HTMLElement | undefined): number { + const rowHeight = row?.getBoundingClientRect().height ?? 0 + const boxHeight = row?.closest<HTMLElement>('.zn-sidebar-scroll')?.clientHeight ?? 0 + if (rowHeight <= 0 || boxHeight <= 0) return 1 + return Math.max(1, Math.floor(boxHeight / rowHeight) - 1) + } + function handleNoteListKey(e: KeyboardEvent, state: ReturnType<typeof useStore.getState>): void { const key = e.key const overrides = state.keymapOverrides diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 12db29963..e2e608106 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -589,7 +589,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+Alt+M', action: 'Add comment', detail: 'Start a comment on the selected text (or the current line) without reaching for the mouse.' }, { keys: 'a', action: 'Reply in a comment thread', detail: 'Comments panel: open a reply box under the selected comment; `Mod+Enter` sends it, `Esc` cancels.' }, { keys: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions: they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.) Both walk the same cycle, in the order the panels appear on screen: sidebar → note list → editor → connections → comments → outline → calendar → timeline, and back again.' }, - { keys: '↑ / ↓ / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus, the arrows move its row cursor, Home and End jump to the ends, Enter opens the row under the cursor, and Esc (or ←) returns focus to the editor. These work with Vim mode off; the single-key motions (j / k, gg / G) stay Vim-only.' }, + { keys: '↑ / ↓ / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus, the arrows move its row cursor, Home and End jump to the ends, Enter opens the row under the cursor, and Esc (or ←) returns focus to the editor. The sidebar, reached with Alt+H or Focus Sidebar in the command palette, works like a tree: → opens the row or expands its folder, ← collapses a folder, or from a note folds its folder and lands on it (Esc still returns to the editor), Page Up / Page Down move a screenful of rows, and Shift+F10 or the Context Menu key opens the row’s menu. These work with Vim mode off; the single-key motions (j / k, gg / G) stay Vim-only.' }, { keys: 'Mod+.', action: 'Toggle Zen mode', detail: 'Hide or restore the app chrome so only the active editor, preview, or split view stays on screen.' }, { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab. With Settings open it closes Settings instead, and in a floating window or the quick capture window it closes that window, like its close button. While a dialog, palette or menu is open it does nothing, so it never closes the tab behind it; press Esc to dismiss those first. With Vim mode on, Linux and Windows keep Ctrl+W as the pane prefix while a tab is open: close the tab with :q or :bd, from the tab menu, or rebind this.' }, { keys: 'Ctrl+Tab', action: 'Switch to previous note', detail: 'Switch to the most recently used note. Press again to alternate between the last two notes.' }, @@ -685,7 +685,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'lists-and-sidebar', title: 'Sidebar and list navigation', - description: 'These bindings work when the sidebar or note list owns focus in Vim mode. Mod+Backspace works with Vim mode off too.', + description: 'These bindings work when the sidebar or note list owns focus in Vim mode. With Vim mode off the sidebar keeps the arrows, Enter, Esc, Home / End, Page Up / Page Down and Shift+F10 (see Move inside a focused panel), and Mod+Backspace works there too.', items: [ { keys: 'j / k', action: 'Move selection', detail: 'Move down or up one visible item.' }, { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Fast travel to the first or last visible row.' }, From 39ac61b0412ff1123307e805e028515678323cd3 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 13:54:39 -0500 Subject: [PATCH 24/34] Fix(sidebar): the cursor shows on a selected row When the sidebar's cursor sat on a row that was part of the multi-selection, the row drew the selection's tint and nothing else: NoteLeaf and TreeRow pick one class for a selected row and never looked at the cursor. A cursor walking through a selection vanished on every selected row, and x or Mod+Backspace then acted on a row that showed no cursor. #972's driver measured it without meaning to: with three notes Cmd-clicked, no row in the sidebar wore the cursor. A selected row under the cursor now keeps the tint and adds the cursor's ring, `vim-cursor-on-selected` (an inset 2px ring in the theme accent at 65%), the class the note list already uses for the same case. The ring stacks with the tint instead of replacing it, follows each theme's accent, and needs no new token or arbitrary value. Both row kinds share one helper. Only a class changes, so the windowed sidebar's DOM contract (the data-sidebar-* attributes the keys and range-select read) is untouched. Checked in the built app with P20 to P22 selected and the cursor on P21, in Gruvbox dark, Apple light, Catppuccin Latte (the loudest accent, peach) and Catppuccin Mocha: the cursor row carries the ring over the same tint as its selected neighbours, and they carry none. Tests: the helper adds the ring only under the cursor and keeps the tint either way, and the stylesheet's ring is an inset accent outline with no background of its own. --- .../Sidebar.selection-cursor.test.ts | 34 +++++++++++++++++++ packages/app-core/src/components/Sidebar.tsx | 16 +++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 packages/app-core/src/components/Sidebar.selection-cursor.test.ts diff --git a/packages/app-core/src/components/Sidebar.selection-cursor.test.ts b/packages/app-core/src/components/Sidebar.selection-cursor.test.ts new file mode 100644 index 000000000..53d0baa66 --- /dev/null +++ b/packages/app-core/src/components/Sidebar.selection-cursor.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { sidebarSelectedRowClass } from './Sidebar' + +const stylesSource = readFileSync(new URL('../styles/index.css', import.meta.url), 'utf8') + +function rule(selector: string): string { + const match = stylesSource.match(new RegExp(`\\.${selector}\\s*\\{[^}]*\\}`, 's')) + expect(match, `missing rule for .${selector}`).not.toBeNull() + return match?.[0] ?? '' +} + +// A sidebar row that is in the multi-selection and under the cursor at once +// used to draw only the selection, so the cursor vanished on it. +describe('the sidebar cursor on a selected row', () => { + it('keeps the selection tint and adds the cursor ring when the cursor is on the row', () => { + const classes = sidebarSelectedRowClass(true).split(/\s+/) + expect(classes).toContain('vim-cursor-on-selected') + expect(classes).toContain('bg-accent/[0.09]') + }) + + it('draws the selection alone when the cursor is elsewhere', () => { + const classes = sidebarSelectedRowClass(false) + expect(classes).not.toMatch(/vim-cursor/) + expect(classes.split(/\s+/)).toContain('bg-accent/[0.09]') + }) + + it('draws the ring as an inset accent outline that stacks with the tint', () => { + const ring = rule('vim-cursor-on-selected') + expect(ring).toMatch(/box-shadow:\s*inset 0 0 0 2px rgb\(var\(--z-accent\)/) + // A background of its own would replace the selection's tint again. + expect(ring).not.toMatch(/background/) + }) +}) diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 23172d8d1..4196be904 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -342,6 +342,18 @@ function noteSelectionKey(path: string): string { return `note:${encodeURIComponent(path)}`; } +/** + * A row in the multi-selection keeps the selection's tint, and when the + * cursor is on it too, the cursor's ring is drawn over that tint. The tint + * used to replace the cursor's style outright, so a cursor walking through a + * selection vanished on every selected row, and the trash keys acted on a + * row you could not see. The note list draws its cursor on a selected row + * the same way; a background of the cursor's own would only fight the tint. + */ +export function sidebarSelectedRowClass(cursorHere: boolean | undefined): string { + return `${cursorHere ? "vim-cursor-on-selected " : ""}bg-accent/[0.09] text-ink-900`; +} + function folderSelectionKey(folder: NoteFolder, subpath: string): string { return `folder:${folder}:${encodeURIComponent(subpath)}`; } @@ -5318,7 +5330,7 @@ const NoteLeaf = memo(function NoteLeaf({ ? "text-accent" : "bg-paper-300/70 text-ink-900 font-medium" : selected - ? "bg-accent/[0.09] text-ink-900" + ? sidebarSelectedRowClass(vimHighlight) : vimHighlight ? "vim-cursor" : "text-ink-700 hover:bg-paper-200/70", @@ -5643,7 +5655,7 @@ function TreeRow({ : dropTarget ? "bg-accent/20 text-ink-900 ring-1 ring-accent/60" : selected - ? "bg-accent/[0.09] text-ink-900" + ? sidebarSelectedRowClass(vimHighlight) : vimHighlight ? "vim-cursor" : "text-ink-800 hover:bg-paper-200/70", From 701988d242dc5b567cc6f8f3aa0054c2f3cdd7f3 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 14:31:54 -0500 Subject: [PATCH 25/34] Fix(panels): with Vim off the note list, connections, outline and comments answer their keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Vim mode off, these four panels went through App's catch-all for panels, which treats each as a flat list: ↑ / ↓, Home / End, Enter (not in comments), and ← or Escape back to the editor. Measured in the built app against Vim on, they lacked: - Note list: no menu keys (Shift+F10, the Context Menu key); ← went to the editor where h goes to the sidebar; → did nothing; End stopped at the last row on screen instead of the last note, because the list renders only the rows near the viewport. - Connections and outline: → did not open the row. - Comments: →, Enter, Backspace and Delete did nothing. - All four: Page Up / Page Down did nothing. The handlers that know these panels are VimNav's, and they only listened with Vim on. As 9b070afe did for the sidebar, each now serves its panel in both modes: the listener VimNav installs once for Vim off routes the focused panel's keys to the handler Vim mode uses, and the handler keeps the typed keys of its actions off in that mode through listViewBindingMatches (the comments panel's own letters, o e r a d n +, through a vimMode check). The catch-all served only these panels and the sidebar, so it is gone, with the panel-rows helpers only it used. With Vim off, in each panel: - ↑ / ↓ move, Home / End jump to the ends, Page Up / Page Down move a screenful. - → and Enter open the row, as l does: the note list and connections open the note and hand the keyboard to the editor, the outline jumps to the heading, comments jump to the comment's text. - ← goes back as h does: from the note list to the sidebar, from the other three to the editor. Escape returns to the editor. - Shift+F10 and the Context Menu key open the row's menu in the note list, the one panel of the four whose rows have one. - Backspace and Delete delete the selected comment, after a confirm. - Letters do nothing. A page in the note list, which renders only the rows near the viewport, is the rows its box shows, less one, so the row the cursor leaves stays on screen. It is counted by the step from one row to the next and the height inside the box's padding: the note list's rows sit 4 px apart in a padded box, and counting them by their own height paged one row too far. The sidebar's page goes through the same helper and stays what it was (its rows touch and its box has no padding). Connections, the outline and comments render every card, and the cards differ in height, so a page there is the furthest card that still fits on one screen with the one the cursor leaves: a tall card never makes the cursor skip one the screen has not shown. Enter on a note in the note list now opens it the way Vim's Enter and l do (a kept tab), where the catch-all clicked the row (a preview tab). Two deliberate changes with Vim on: - Deleting a comment from the keyboard asks first, in both modes: d, Backspace and Delete with Vim on, Backspace and Delete with Vim off ("Delete this comment? This cannot be undone."). A deleted comment has no Trash and no undo, and Backspace is the key most often pressed by accident. Escape on the confirm leaves the comment and the cursor where they were. The card's own Delete button, a deliberate click, still deletes at once, without asking. - Enter or l on a file row in the note list (an image beside the notes) opens it in a file tab, the way its click does. Vim mode handed the file to selectNote, which loaded the PNG into the editor as a note's text; the catch-all clicked the row, so Vim off got it right and would have inherited the bug. Otherwise Vim mode is unchanged: its gating is still matchesSequenceToken, and Home, End and the page keys are left alone (gg and G are its keys). The Vim-off listener also yields to the surfaces Vim mode yields to by DOM focus (the calendar and timeline panels, the selection toolbar, an Excalidraw canvas): the store can still name a right-side panel while one of them holds focus. Reaching the panels: Alt+H and Alt+L reach each one with Vim off and the panel keeps the keyboard. The palette has no Focus command for any of the four (it has Focus Sidebar and Focus Editor; Focus Pane Left and its siblings move between editor splits), so the refocus 9b070afe fixed for Focus Sidebar has nothing to hit here. Checked in the built app over CDP, Vim off, in each panel: reached with Alt+H / Alt+L, walked, paged (the row left behind still on screen), End and Home, → opens, ← goes back, Escape returns, letters do nothing; Shift+F10 and the Context Menu key open the note list's menu; Backspace and Delete in comments ask, Escape keeps the comment, Enter deletes it on disk; the card's Delete button deletes without asking (58 checks). Vim on: j / k, G, gg, m, h, l in each panel, Page Down and End left alone, d and Backspace ask, l on a file row opens a file tab (24 checks). The three Vim smoke suites pass on the same build. Tests: per panel, with Vim off the arrows, Home / End and the page keys move, → and Enter open, ← and Escape go back, every typed key is left alone, and nothing answers under a dialog or a menu or from a text field; the note list's menu keys open the cursor row's menu and its file row opens through its click; comments' Backspace and Delete ask and delete only on yes. With Vim on, the letters as before, Home, End and the page keys untouched, d / Backspace / Delete ask. The page helpers: the page keys only with Vim off and never as a chord, a page counted by row step inside the padding, and a tall card never skipped. 17 of the new panel tests fail on the code before this change. The manual says how each panel answers with Vim off, and that deleting a comment from the keyboard asks first. --- packages/app-core/src/App.tsx | 77 +- .../app-core/src/components/CommentsPanel.tsx | 2 +- .../src/components/ConnectionsPanel.tsx | 6 +- packages/app-core/src/components/NoteList.tsx | 3 + .../app-core/src/components/OutlinePanel.tsx | 2 +- .../src/components/VimNav-panel-keys.test.ts | 741 ++++++++++++++++++ packages/app-core/src/components/VimNav.tsx | 322 +++++--- packages/app-core/src/lib/confirm-trash.ts | 13 + packages/app-core/src/lib/help.ts | 6 +- packages/app-core/src/lib/panel-rows.test.ts | 166 ++-- packages/app-core/src/lib/panel-rows.ts | 149 ++-- 11 files changed, 1163 insertions(+), 324 deletions(-) create mode 100644 packages/app-core/src/components/VimNav-panel-keys.test.ts diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 514e9325a..22dd7168a 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -42,13 +42,6 @@ import { import { selectActiveBuffer, stepActiveTab } from './lib/buffer-navigation' import { resolveCloseShortcut } from './lib/close-shortcut' import { focusPaneOrEdgePanel } from './lib/pane-nav' -import { - activatePanelRow, - isRowPanel, - moveCommentCursor, - movePanelCursor, - type CursorMove -} from './lib/panel-rows' import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' import { @@ -334,65 +327,6 @@ function AppUpdateNotice({ ) } -/** - * Keyboard handling inside a focused panel when Vim mode is off. - * - * VimNav owns panel keys, but its listener only exists in Vim mode — so without - * it, pane navigation could hand focus to a panel with no way to move inside it. - * This covers the keys that are universal everywhere else in the app: ↑/↓ move - * the row cursor, Home/End jump to the ends, Enter activates the row, Escape (or - * ←) hands focus back to the editor. Single-letter motions stay Vim-only. - * - * Returns nothing; the event is consumed only when a panel actually handled it, - * so unrelated keys still reach the app. - * - * The sidebar is not one of these panels: its rows are folders to expand and - * collapse, not a flat list, so ← there collapses rather than leaving, and its - * keys go through VimNav's sidebar handler in both modes. - */ -function handlePanelKeyWithoutVim(e: KeyboardEvent, focusedPanel: string | null): void { - if (focusedPanel === 'sidebar') return - if (e.metaKey || e.ctrlKey || e.altKey) return - // A dialog over the panel owns its keys: the focused panel stays set while - // one is open, so arrows would move the row behind it and Enter would open - // that row instead of pressing the dialog's focused button (#960). - if (isOverlayOrDialogOpen()) return - // Text entry inside a panel (the outline filter, a comment draft) keeps its keys. - const target = e.target instanceof HTMLElement ? e.target : null - if (target) { - const tag = target.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return - } - const isComments = focusedPanel === 'comments' - if (!isComments && !isRowPanel(focusedPanel)) return - - const move: CursorMove | null = - e.key === 'ArrowDown' ? 'down' : e.key === 'ArrowUp' ? 'up' : e.key === 'Home' ? 'first' : e.key === 'End' ? 'last' : null - - const consume = (): void => { - e.preventDefault() - e.stopImmediatePropagation() - } - - if (move) { - const moved = isComments ? moveCommentCursor(move) : movePanelCursor(focusedPanel, move) - if (moved) consume() - return - } - if (e.key === 'Enter') { - // The comments panel has no single "open" action per card, so Enter is left - // to the card's own focused control there. - if (isComments) return - if (activatePanelRow(focusedPanel)) consume() - return - } - if (e.key === 'Escape' || e.key === 'ArrowLeft') { - consume() - useStore.getState().setFocusedPanel('editor') - focusEditorNormalMode() - } -} - function App(): JSX.Element { const mountedAtRef = useRef(performance.now()) const workspaceReadyLoggedRef = useRef(false) @@ -1198,14 +1132,9 @@ function App(): JSX.Element { return } - // With Vim mode ON, VimNav owns every key inside a focused panel. With it - // OFF that listener isn't installed at all, so `Alt+hjkl` could put focus - // in a panel there was then no way to drive — you could reach Connections - // or the Outline and not move a row. The universal keys (arrows, Enter, - // Escape) work here regardless of mode; the single-letter motions (j/k, - // gg/G) stay Vim-only, as everywhere else in the app. - if (state.vimMode) return - handlePanelKeyWithoutVim(e, state.focusedPanel) + // Keys inside a focused panel (the sidebar, the note list, connections, + // the outline, comments) are VimNav's in both modes: with Vim off it + // routes them to the same handlers, with the typed Vim keys turned off. } window.addEventListener('keydown', handler) window.addEventListener('keydown', focusPaneHandler, true) diff --git a/packages/app-core/src/components/CommentsPanel.tsx b/packages/app-core/src/components/CommentsPanel.tsx index f6334082f..8ea900337 100644 --- a/packages/app-core/src/components/CommentsPanel.tsx +++ b/packages/app-core/src/components/CommentsPanel.tsx @@ -333,7 +333,7 @@ export function CommentsPanel({ )} </div> - <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4"> + <div data-panel-scroll className="min-h-0 flex-1 overflow-y-auto px-4 py-4"> {comments.length === 0 && !draft ? ( <div className="rounded-lg border border-dashed border-paper-300/75 bg-paper-100/42 px-4 py-6 text-center"> <FeedbackIcon className="mx-auto text-ink-400" width={20} height={20} /> diff --git a/packages/app-core/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx index ca4381fe0..bf507950a 100644 --- a/packages/app-core/src/components/ConnectionsPanel.tsx +++ b/packages/app-core/src/components/ConnectionsPanel.tsx @@ -301,7 +301,11 @@ export function ConnectionsPanel({ )} </div> - <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3" onScroll={() => setConnectionPreview(null)}> + <div + data-panel-scroll + className="min-h-0 flex-1 overflow-y-auto px-3 py-3" + onScroll={() => setConnectionPreview(null)} + > <ConnectionSection title="Links From Here" subtitle="Notes, files and missing wikilinks from this page." diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index 2cb3e9a34..7d0c0a496 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -768,6 +768,7 @@ export function NoteList(): JSX.Element { <div ref={scrollRef} data-notelist-count={view.kind === 'assets' ? assetFiles.length : orderedFolderEntries.length} + data-panel-scroll className="min-h-0 flex-1 overflow-y-auto px-2 py-2" onScroll={(event) => setListScrollTop(event.currentTarget.scrollTop)} > @@ -968,6 +969,7 @@ function NoteRow({ } {...(noteListIdx != null ? { 'data-notelist-idx': noteListIdx, + 'data-notelist-type': 'note', 'data-notelist-path': note.path } : {})} > @@ -1015,6 +1017,7 @@ function FolderAssetRow({ {...(noteListIdx != null ? { 'data-notelist-idx': noteListIdx, + 'data-notelist-type': 'asset', 'data-notelist-path': asset.path } : {})} diff --git a/packages/app-core/src/components/OutlinePanel.tsx b/packages/app-core/src/components/OutlinePanel.tsx index 834d7c94d..fa6700a41 100644 --- a/packages/app-core/src/components/OutlinePanel.tsx +++ b/packages/app-core/src/components/OutlinePanel.tsx @@ -93,7 +93,7 @@ export function OutlinePanel({ note, activeLine, onJump, fitWidth }: Props): JSX /> )} </div> - <div className="min-h-0 flex-1 overflow-y-auto px-2 py-2"> + <div data-panel-scroll className="min-h-0 flex-1 overflow-y-auto px-2 py-2"> {filtered.length === 0 ? ( <div className="px-2 py-4 text-center text-xs text-ink-400"> {items.length === 0 ? 'Nothing to show.' : 'No matches.'} diff --git a/packages/app-core/src/components/VimNav-panel-keys.test.ts b/packages/app-core/src/components/VimNav-panel-keys.test.ts new file mode 100644 index 000000000..ddb4a63f0 --- /dev/null +++ b/packages/app-core/src/components/VimNav-panel-keys.test.ts @@ -0,0 +1,741 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' +import { dispatchKeyboardContextMenu } from '../lib/keyboard-context-menu' +import { getConfirmRequest, settleConfirmRequest } from '../lib/confirm-requests' +import { VimNav } from './VimNav' + +// jsdom refuses the MouseEvent the real dispatch builds (its `view`); the +// menu itself is the row's business, so what matters here is which row the +// key opened it on. +vi.mock('../lib/keyboard-context-menu', async (importActual) => ({ + ...(await importActual<typeof import('../lib/keyboard-context-menu')>()), + dispatchKeyboardContextMenu: vi.fn() +})) +const menuOpenedOn = vi.mocked(dispatchKeyboardContextMenu) + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// The note list, connections, outline and comments panels answer the +// universal keys with Vim mode off (arrows, Enter, Escape, Home / End, the +// page keys, the menu keys where a row has a menu, Backspace / Delete in +// comments) through the handler Vim mode uses, with the typed Vim keys off. +// Vim mode keeps every key it had; deleting a comment from the keyboard +// asks first in both modes. + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + platformSync: () => 'darwin', + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false + }) + } + }) +} + +const ROW_HEIGHT = 24 + +/** jsdom has no layout: give each row its box by hand, one under the other, + * and the scroll box a height of five rows. */ +function layOut(selector: string, heights?: number[]): void { + let top = 0 + document.querySelectorAll<HTMLElement>(selector).forEach((el, i) => { + const rowTop = top + const height = heights?.[i] ?? ROW_HEIGHT + el.getClientRects = (() => [{ width: 200, height }] as unknown as DOMRectList) as never + el.getBoundingClientRect = (() => + ({ top: rowTop, bottom: rowTop + height, left: 0, right: 200, width: 200, height }) as DOMRect) as never + el.scrollIntoView = (() => {}) as never + top += height + }) + const box = document.querySelector('[data-panel-scroll]') + if (box) Object.defineProperty(box, 'clientHeight', { configurable: true, value: ROW_HEIGHT * 5 }) +} + +function press(key: string, code: string, init: KeyboardEventInit = {}, target: EventTarget = document.body): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init }) + act(() => { + target.dispatchEvent(event) + }) + return event +} + +const TYPED_KEYS: Array<[string, string]> = [ + ['j', 'KeyJ'], + ['k', 'KeyK'], + ['l', 'KeyL'], + ['h', 'KeyH'], + ['o', 'KeyO'], + ['m', 'KeyM'], + ['p', 'KeyP'], + ['e', 'KeyE'], + ['r', 'KeyR'], + ['a', 'KeyA'], + ['d', 'KeyD'], + ['n', 'KeyN'], + ['x', 'KeyX'], + ['+', 'Equal'], + ['g', 'KeyG'], + ['G', 'KeyG'], + ['/', 'Slash'] +] + +const pressTyped = (key: string, code: string): KeyboardEvent => { + const event = press(key, code, key === 'G' ? { shiftKey: true } : {}) + if (key === 'g') press('g', 'KeyG') + return event +} + +/** A dialog over the panel, the way the shared Modal and ContextMenu mark + * theirs; returns the remover. */ +function openOverlay(kind: 'dialog' | 'menu'): () => void { + const el = document.createElement('div') + if (kind === 'dialog') el.setAttribute('aria-modal', 'true') + else el.setAttribute('data-ctx-menu', '') + document.body.append(el) + return () => el.remove() +} + +let host: HTMLDivElement +let root: Root +let originalState: ReturnType<typeof useStore.getState> +let selectNote: ReturnType<typeof vi.fn> +let setSearchOpen: ReturnType<typeof vi.fn> +let setConnectionPreview: ReturnType<typeof vi.fn> + +const mount = (vimMode: boolean): void => { + useStore.setState({ vimMode } as never) + act(() => root.render(createElement(VimNav))) +} + +beforeEach(() => { + installZen() + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + selectNote = vi.fn().mockResolvedValue(undefined) + setSearchOpen = vi.fn() + setConnectionPreview = vi.fn() + menuOpenedOn.mockClear() + useStore.setState({ + sidebarOpen: true, + selectNote, + setSearchOpen, + setConnectionPreview, + editorViewRef: null + } as never) +}) + +afterEach(() => { + const pending = getConfirmRequest() + if (pending) settleConfirmRequest(pending, false) + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + useStore.setState(originalState, true) +}) + +const focused = (): string | null => useStore.getState().focusedPanel + +// --------------------------------------------------------------------------- +// Note list +// --------------------------------------------------------------------------- + +describe('note list keys in VimNav', () => { + const TOTAL = 40 + const RENDERED = 10 + let fileClicks: number + + /** The column as NoteList renders it: forty entries, the first ten on + * screen (the list is virtual), the last of those a file. */ + beforeEach(() => { + const rows = Array.from({ length: RENDERED }, (_, i) => + i === RENDERED - 1 + ? `<button data-notelist-idx="${i}" data-notelist-type="asset" data-notelist-path="inbox/Projects/diagram.png">diagram.png</button>` + : `<button data-notelist-idx="${i}" data-notelist-type="note" data-notelist-path="inbox/Projects/P${i}.md">P${i}</button>` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `<div data-notelist-count="${TOTAL}" data-panel-scroll>${rows}</div>` + ) + layOut('[data-notelist-idx]') + fileClicks = 0 + document + .querySelector('[data-notelist-type="asset"]')! + .addEventListener('click', () => (fileClicks += 1)) + useStore.setState({ focusedPanel: 'notelist', noteListCursorIndex: 0 } as never) + }) + + const cursor = (): number => useStore.getState().noteListCursorIndex + + describe('with Vim mode off', () => { + it('moves with the arrows, jumps with Home and End, and pages a screenful', () => { + mount(false) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(true) + press('ArrowDown', 'ArrowDown') + press('ArrowUp', 'ArrowUp') + expect(cursor()).toBe(1) + // Five rows show; a page is four, so the row left behind stays in view. + press('PageDown', 'PageDown') + expect(cursor()).toBe(5) + press('PageUp', 'PageUp') + expect(cursor()).toBe(1) + // End reaches the last entry, not the last row on screen. + press('End', 'End') + expect(cursor()).toBe(TOTAL - 1) + press('PageUp', 'PageUp') + expect(cursor()).toBe(TOTAL - 5) + press('Home', 'Home') + expect(cursor()).toBe(0) + }) + + it('opens the note with ArrowRight or Enter and hands the keyboard to the editor', () => { + mount(false) + press('ArrowDown', 'ArrowDown') + expect(press('ArrowRight', 'ArrowRight').defaultPrevented).toBe(true) + expect(selectNote).toHaveBeenCalledWith('inbox/Projects/P1.md') + expect(focused()).toBe('editor') + + useStore.setState({ focusedPanel: 'notelist' } as never) + press('ArrowDown', 'ArrowDown') + press('Enter', 'Enter') + expect(selectNote).toHaveBeenLastCalledWith('inbox/Projects/P2.md') + }) + + it('opens a file row the way its click does, never as a note', () => { + useStore.setState({ noteListCursorIndex: RENDERED - 1 } as never) + mount(false) + press('Enter', 'Enter') + expect(fileClicks).toBe(1) + expect(selectNote).not.toHaveBeenCalled() + }) + + it('goes back to the sidebar with ArrowLeft, as h does', () => { + mount(false) + expect(press('ArrowLeft', 'ArrowLeft').defaultPrevented).toBe(true) + expect(focused()).toBe('sidebar') + }) + + it('returns to the editor with Escape', () => { + mount(false) + expect(press('Escape', 'Escape').defaultPrevented).toBe(true) + expect(focused()).toBe('editor') + }) + + it('opens the row menu with Shift+F10 and the Context Menu key', () => { + useStore.setState({ noteListCursorIndex: 2 } as never) + mount(false) + const row = document.querySelector('[data-notelist-idx="2"]') + expect(press('F10', 'F10', { shiftKey: true }).defaultPrevented).toBe(true) + expect(press('ContextMenu', 'ContextMenu').defaultPrevented).toBe(true) + expect(menuOpenedOn.mock.calls).toEqual([[row], [row]]) + }) + + it.each(TYPED_KEYS)('leaves the typed key %s alone', (key, code) => { + mount(false) + const event = pressTyped(key, code) + expect(event.defaultPrevented).toBe(false) + expect(cursor()).toBe(0) + expect(selectNote).not.toHaveBeenCalled() + expect(setSearchOpen).not.toHaveBeenCalled() + expect(menuOpenedOn).not.toHaveBeenCalled() + expect(focused()).toBe('notelist') + }) + + it('leaves chords, other panels and text fields their keys', () => { + mount(false) + expect(press('ArrowDown', 'ArrowDown', { altKey: true }).defaultPrevented).toBe(false) + const input = document.createElement('input') + document.body.append(input) + expect(press('ArrowDown', 'ArrowDown', {}, input).defaultPrevented).toBe(false) + useStore.setState({ focusedPanel: 'editor' } as never) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(false) + expect(cursor()).toBe(0) + }) + + it('stands down while a dialog or a menu is open', () => { + mount(false) + for (const kind of ['dialog', 'menu'] as const) { + const close = openOverlay(kind) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(false) + expect(press('Enter', 'Enter').defaultPrevented).toBe(false) + expect(press('F10', 'F10', { shiftKey: true }).defaultPrevented).toBe(false) + close() + } + expect(cursor()).toBe(0) + expect(selectNote).not.toHaveBeenCalled() + expect(menuOpenedOn).not.toHaveBeenCalled() + }) + }) + + describe('with Vim mode on', () => { + it('keeps j / k, G, gg, l, h, m and /', () => { + mount(true) + press('j', 'KeyJ') + press('j', 'KeyJ') + press('k', 'KeyK') + expect(cursor()).toBe(1) + press('G', 'KeyG', { shiftKey: true }) + expect(cursor()).toBe(TOTAL - 1) + press('g', 'KeyG') + press('g', 'KeyG') + expect(cursor()).toBe(0) + press('m', 'KeyM') + expect(menuOpenedOn).toHaveBeenCalledTimes(1) + press('/', 'Slash') + expect(setSearchOpen).toHaveBeenCalledWith(true) + press('h', 'KeyH') + expect(focused()).toBe('sidebar') + useStore.setState({ focusedPanel: 'notelist' } as never) + press('l', 'KeyL') + expect(selectNote).toHaveBeenCalledWith('inbox/Projects/P0.md') + }) + + it('opens a file row the way its click does with l and Enter too', () => { + // Vim mode used to hand the file to selectNote, which opened the image + // as a note's text. + useStore.setState({ noteListCursorIndex: RENDERED - 1 } as never) + mount(true) + press('l', 'KeyL') + useStore.setState({ focusedPanel: 'notelist' } as never) + press('Enter', 'Enter') + expect(fileClicks).toBe(2) + expect(selectNote).not.toHaveBeenCalled() + }) + + it('leaves Home, End and the page keys as they were', () => { + mount(true) + for (const key of ['Home', 'End', 'PageDown', 'PageUp']) { + expect(press(key, key).defaultPrevented).toBe(false) + } + expect(cursor()).toBe(0) + }) + }) +}) + +// --------------------------------------------------------------------------- +// Connections +// --------------------------------------------------------------------------- + +describe('connections keys in VimNav', () => { + beforeEach(() => { + const rows = Array.from( + { length: 12 }, + (_, i) => + `<button data-connections-idx="${i}" data-connections-type="note" data-connections-path="inbox/C${i}.md">C${i}</button>` + ).join('') + document.body.insertAdjacentHTML('beforeend', `<aside data-connections-panel><div data-panel-scroll>${rows}</div></aside>`) + layOut('[data-connections-idx]') + useStore.setState({ + focusedPanel: 'connections', + connectionsCursorIndex: 0, + notes: [{ path: 'inbox/C0.md', title: 'C0' }] + } as never) + }) + + const cursor = (): number => useStore.getState().connectionsCursorIndex + + describe('with Vim mode off', () => { + it('moves with the arrows, jumps with Home and End, and pages a screenful', () => { + mount(false) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(true) + expect(cursor()).toBe(1) + press('PageDown', 'PageDown') + expect(cursor()).toBe(5) + press('End', 'End') + expect(cursor()).toBe(11) + press('PageUp', 'PageUp') + expect(cursor()).toBe(7) + press('Home', 'Home') + expect(cursor()).toBe(0) + press('ArrowUp', 'ArrowUp') + expect(cursor()).toBe(0) + }) + + it('opens the row with ArrowRight and with Enter', () => { + mount(false) + press('ArrowDown', 'ArrowDown') + expect(press('ArrowRight', 'ArrowRight').defaultPrevented).toBe(true) + expect(selectNote).toHaveBeenCalledWith('inbox/C1.md') + expect(focused()).toBe('editor') + useStore.setState({ focusedPanel: 'connections' } as never) + press('Enter', 'Enter') + expect(selectNote).toHaveBeenCalledTimes(2) + }) + + it('returns to the editor with ArrowLeft and Escape, as h does', () => { + mount(false) + press('ArrowLeft', 'ArrowLeft') + expect(focused()).toBe('editor') + useStore.setState({ focusedPanel: 'connections' } as never) + press('Escape', 'Escape') + expect(focused()).toBe('editor') + }) + + it('has no row menu, so the menu keys pass', () => { + mount(false) + expect(press('F10', 'F10', { shiftKey: true }).defaultPrevented).toBe(false) + expect(press('ContextMenu', 'ContextMenu').defaultPrevented).toBe(false) + expect(menuOpenedOn).not.toHaveBeenCalled() + }) + + it.each(TYPED_KEYS)('leaves the typed key %s alone', (key, code) => { + mount(false) + expect(pressTyped(key, code).defaultPrevented).toBe(false) + expect(cursor()).toBe(0) + expect(selectNote).not.toHaveBeenCalled() + expect(setConnectionPreview).not.toHaveBeenCalledWith(expect.objectContaining({ path: 'inbox/C0.md' })) + expect(focused()).toBe('connections') + }) + + it('stands down while a dialog or a menu is open', () => { + mount(false) + for (const kind of ['dialog', 'menu'] as const) { + const close = openOverlay(kind) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(false) + expect(press('ArrowRight', 'ArrowRight').defaultPrevented).toBe(false) + close() + } + expect(cursor()).toBe(0) + expect(selectNote).not.toHaveBeenCalled() + }) + }) + + describe('with Vim mode on', () => { + it('keeps j / k, G, gg, l and p', () => { + mount(true) + press('j', 'KeyJ') + press('k', 'KeyK') + press('j', 'KeyJ') + expect(cursor()).toBe(1) + press('G', 'KeyG', { shiftKey: true }) + expect(cursor()).toBe(11) + press('g', 'KeyG') + press('g', 'KeyG') + expect(cursor()).toBe(0) + press('p', 'KeyP') + expect(setConnectionPreview).toHaveBeenLastCalledWith(expect.objectContaining({ path: 'inbox/C0.md' })) + useStore.setState({ focusedPanel: 'connections' } as never) + press('l', 'KeyL') + expect(selectNote).toHaveBeenCalledWith('inbox/C0.md') + }) + + it('leaves Home, End and the page keys as they were', () => { + mount(true) + for (const key of ['Home', 'End', 'PageDown', 'PageUp']) { + expect(press(key, key).defaultPrevented).toBe(false) + } + expect(cursor()).toBe(0) + }) + }) +}) + +// --------------------------------------------------------------------------- +// Outline +// --------------------------------------------------------------------------- + +describe('outline keys in VimNav', () => { + let jumped: number[] + + beforeEach(() => { + const rows = Array.from( + { length: 12 }, + (_, i) => `<li><button data-outline-idx="${i}" data-outline-line="${i * 4 + 1}">Section ${i}</button></li>` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `<section data-outline-panel><input placeholder="Filter…" /><div data-panel-scroll><ul>${rows}</ul></div></section>` + ) + layOut('[data-outline-idx]') + jumped = [] + document.querySelectorAll<HTMLElement>('[data-outline-idx]').forEach((el) => { + el.addEventListener('click', () => jumped.push(Number(el.dataset.outlineIdx))) + }) + useStore.setState({ focusedPanel: 'outline', outlineCursorIndex: 0 } as never) + }) + + const cursor = (): number => useStore.getState().outlineCursorIndex + + describe('with Vim mode off', () => { + it('moves with the arrows, jumps with Home and End, and pages a screenful', () => { + mount(false) + press('ArrowDown', 'ArrowDown') + press('ArrowDown', 'ArrowDown') + expect(cursor()).toBe(2) + press('PageDown', 'PageDown') + expect(cursor()).toBe(6) + press('End', 'End') + expect(cursor()).toBe(11) + press('Home', 'Home') + expect(cursor()).toBe(0) + }) + + it('jumps to the heading with ArrowRight and with Enter', () => { + mount(false) + press('ArrowDown', 'ArrowDown') + expect(press('ArrowRight', 'ArrowRight').defaultPrevented).toBe(true) + press('Enter', 'Enter') + expect(jumped).toEqual([1, 1]) + }) + + it('returns to the editor with ArrowLeft and Escape, as h does', () => { + mount(false) + press('ArrowLeft', 'ArrowLeft') + expect(focused()).toBe('editor') + useStore.setState({ focusedPanel: 'outline' } as never) + press('Escape', 'Escape') + expect(focused()).toBe('editor') + }) + + it('leaves the heading filter its keys', () => { + mount(false) + const input = document.querySelector('input')! + expect(press('ArrowDown', 'ArrowDown', {}, input).defaultPrevented).toBe(false) + expect(press('End', 'End', {}, input).defaultPrevented).toBe(false) + expect(cursor()).toBe(0) + }) + + it.each(TYPED_KEYS)('leaves the typed key %s alone', (key, code) => { + mount(false) + expect(pressTyped(key, code).defaultPrevented).toBe(false) + expect(cursor()).toBe(0) + expect(jumped).toEqual([]) + expect(focused()).toBe('outline') + }) + + it('stands down while a dialog or a menu is open', () => { + mount(false) + for (const kind of ['dialog', 'menu'] as const) { + const close = openOverlay(kind) + expect(press('PageDown', 'PageDown').defaultPrevented).toBe(false) + expect(press('Enter', 'Enter').defaultPrevented).toBe(false) + close() + } + expect(cursor()).toBe(0) + expect(jumped).toEqual([]) + }) + }) + + describe('with Vim mode on', () => { + it('keeps j / k, G, gg, l and h', () => { + mount(true) + press('j', 'KeyJ') + press('j', 'KeyJ') + press('k', 'KeyK') + expect(cursor()).toBe(1) + press('l', 'KeyL') + expect(jumped).toEqual([1]) + press('G', 'KeyG', { shiftKey: true }) + expect(cursor()).toBe(11) + press('g', 'KeyG') + press('g', 'KeyG') + expect(cursor()).toBe(0) + press('h', 'KeyH') + expect(focused()).toBe('editor') + }) + + it('leaves Home, End and the page keys as they were', () => { + mount(true) + for (const key of ['Home', 'End', 'PageDown', 'PageUp']) { + expect(press(key, key).defaultPrevented).toBe(false) + } + expect(cursor()).toBe(0) + }) + }) +}) + +// --------------------------------------------------------------------------- +// Comments +// --------------------------------------------------------------------------- + +describe('comments keys in VimNav', () => { + let clicks: string[] + + beforeEach(() => { + const cards = Array.from( + { length: 8 }, + (_, i) => `<article data-comments-idx="${i}" data-comment-id="c${i}" data-comment-card-id="c${i}"> + <p>Comment ${i}</p> + <div data-comment-card-control> + ${['jump', 'reply', 'edit', 'resolve', 'delete'] + .map((action) => `<button data-comment-card-control data-comment-action="${action}">${action}</button>`) + .join('')} + </div> + </article>` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `<aside data-comments-panel tabindex="-1"><button data-comments-new>New</button><div data-panel-scroll>${cards}</div></aside>` + ) + // Cards are taller than rows: three show at a time (120 px of 40 px cards). + layOut('[data-comments-idx]', Array(8).fill(40)) + clicks = [] + document.querySelectorAll<HTMLElement>('[data-comments-idx]').forEach((card) => { + card.addEventListener('click', (event) => { + const action = (event.target as HTMLElement).dataset.commentAction + clicks.push(`${card.dataset.commentId}:${action ?? 'card'}`) + }) + }) + document + .querySelector('[data-comments-new]')! + .addEventListener('click', () => clicks.push('new')) + useStore.setState({ focusedPanel: 'comments', activeCommentId: 'c0' } as never) + }) + + const active = (): string | null => useStore.getState().activeCommentId + const confirmTitle = (): string | null => getConfirmRequest()?.options.title ?? null + const answerConfirm = async (yes: boolean): Promise<void> => { + const request = getConfirmRequest() + expect(request).not.toBeNull() + await act(async () => { + settleConfirmRequest(request!, yes) + await Promise.resolve() + await Promise.resolve() + }) + } + + describe('with Vim mode off', () => { + it('moves with the arrows, jumps with Home and End, and pages a screenful', () => { + mount(false) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(true) + expect(active()).toBe('c1') + // Three 40 px cards show; the page keeps the card left behind on screen. + press('PageDown', 'PageDown') + expect(active()).toBe('c3') + press('End', 'End') + expect(active()).toBe('c7') + press('PageUp', 'PageUp') + expect(active()).toBe('c5') + press('Home', 'Home') + expect(active()).toBe('c0') + }) + + it('opens the comment (jumps to its text) with Enter and with ArrowRight', () => { + mount(false) + press('ArrowDown', 'ArrowDown') + expect(press('Enter', 'Enter').defaultPrevented).toBe(true) + expect(press('ArrowRight', 'ArrowRight').defaultPrevented).toBe(true) + expect(clicks).toEqual(['c1:card', 'c1:card']) + }) + + it('leaves Enter to a focused card button', () => { + mount(false) + const reply = document.querySelector<HTMLElement>('[data-comment-id="c0"] [data-comment-action="reply"]')! + expect(press('Enter', 'Enter', {}, reply).defaultPrevented).toBe(false) + expect(clicks).toEqual([]) + }) + + it('asks before Backspace or Delete deletes, and deletes only on yes', async () => { + mount(false) + press('ArrowDown', 'ArrowDown') + expect(press('Backspace', 'Backspace').defaultPrevented).toBe(true) + expect(confirmTitle()).toBe('Delete this comment?') + expect(clicks).toEqual([]) + await answerConfirm(false) + expect(clicks).toEqual([]) + expect(active()).toBe('c1') + + press('Delete', 'Delete') + expect(confirmTitle()).toBe('Delete this comment?') + await answerConfirm(true) + expect(clicks).toEqual(['c1:delete']) + }) + + it('returns to the editor with ArrowLeft and Escape, as h does', () => { + mount(false) + press('ArrowLeft', 'ArrowLeft') + expect(focused()).toBe('editor') + useStore.setState({ focusedPanel: 'comments' } as never) + press('Escape', 'Escape') + expect(focused()).toBe('editor') + }) + + it.each(TYPED_KEYS)('leaves the typed key %s alone', (key, code) => { + mount(false) + expect(pressTyped(key, code).defaultPrevented).toBe(false) + expect(active()).toBe('c0') + expect(clicks).toEqual([]) + expect(getConfirmRequest()).toBeNull() + expect(setSearchOpen).not.toHaveBeenCalled() + expect(focused()).toBe('comments') + }) + + it('leaves a comment draft its keys', () => { + mount(false) + const draft = document.createElement('textarea') + document.querySelector('[data-comments-panel]')!.append(draft) + expect(press('Backspace', 'Backspace', {}, draft).defaultPrevented).toBe(false) + expect(press('ArrowDown', 'ArrowDown', {}, draft).defaultPrevented).toBe(false) + expect(getConfirmRequest()).toBeNull() + expect(active()).toBe('c0') + }) + + it('stands down while a dialog or a menu is open', () => { + mount(false) + for (const kind of ['dialog', 'menu'] as const) { + const close = openOverlay(kind) + expect(press('ArrowDown', 'ArrowDown').defaultPrevented).toBe(false) + expect(press('Backspace', 'Backspace').defaultPrevented).toBe(false) + expect(press('Enter', 'Enter').defaultPrevented).toBe(false) + close() + } + expect(active()).toBe('c0') + expect(clicks).toEqual([]) + expect(getConfirmRequest()).toBeNull() + }) + }) + + describe('with Vim mode on', () => { + it('keeps j / k, G, gg, o, e, r, a and n', () => { + mount(true) + press('j', 'KeyJ') + press('j', 'KeyJ') + press('k', 'KeyK') + expect(active()).toBe('c1') + press('G', 'KeyG', { shiftKey: true }) + expect(active()).toBe('c7') + press('g', 'KeyG') + press('g', 'KeyG') + expect(active()).toBe('c0') + press('o', 'KeyO') + press('e', 'KeyE') + press('r', 'KeyR') + press('a', 'KeyA') + press('n', 'KeyN') + expect(clicks).toEqual(['c0:card', 'c0:edit', 'c0:resolve', 'c0:reply', 'new']) + }) + + it('asks before d, Backspace or Delete deletes', async () => { + mount(true) + for (const [key, code] of [ + ['d', 'KeyD'], + ['Backspace', 'Backspace'], + ['Delete', 'Delete'] + ]) { + expect(press(key, code).defaultPrevented).toBe(true) + expect(confirmTitle()).toBe('Delete this comment?') + await answerConfirm(false) + } + expect(clicks).toEqual([]) + press('d', 'KeyD') + await answerConfirm(true) + expect(clicks).toEqual(['c0:delete']) + }) + + it('leaves Home, End and the page keys as they were', () => { + mount(true) + for (const key of ['Home', 'End', 'PageDown', 'PageUp']) { + expect(press(key, key).defaultPrevented).toBe(false) + } + expect(active()).toBe('c0') + }) + }) +}) diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index ccf2ec6b9..b193ba5f3 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -28,6 +28,9 @@ import { getIndexedElementByIndex, getIndexedElements, getIndexedValue, + pageMoveForKey, + pageRowCount, + pageTargetPosition, scrollToIndexedElement, scrollToIndexedIndex, type IndexedDatasetKey @@ -58,6 +61,7 @@ import { trashSidebarCursorRow } from '../lib/sidebar-note-actions' import { convertTableToDatabase } from '../lib/table-to-database' +import { confirmDeleteComment } from '../lib/confirm-trash' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, dispatchKeyboardContextMenu, @@ -1600,18 +1604,33 @@ export function VimNav(): JSX.Element | null { whichKeyHintTimeoutMs ]) // ← stable dep, handler never re-registers unnecessarily - // With Vim off the listener above is not installed, yet a focused sidebar - // still answers the keys every list answers: the arrows, Enter, Escape, - // Home / End, the page keys and the menu keys. They go through the same - // handleSidebarKey, which keeps the typed Vim keys off in this mode. + // With Vim off the listener above is not installed, yet a focused panel + // (the sidebar, the note list, connections, the outline, comments) still + // answers the keys every list answers: the arrows, Enter, Escape, Home / + // End, the page keys, and the menu keys where its rows have a menu. They + // go through the same handler Vim mode uses for that panel, which keeps + // the typed Vim keys off in this mode, so each key means in both modes + // what it means in the panel (→ opens, ← goes back the way h does). // Installed once rather than per mode, so it keeps its early place among // the window's capture listeners: a list view left active behind the - // sidebar (Archive, Trash) listens there too, and must not take the - // sidebar's arrows first. + // panel (Archive, Trash) listens there too, and must not take the + // panel's arrows first. useEffect(() => { const handler = (e: KeyboardEvent): void => { const state = useStore.getState() - if (state.vimMode || !sidebarHoldsFocus(state)) return + if (state.vimMode) return + const handlePanelKey = sidebarHoldsFocus(state) + ? handleSidebarKey + : state.focusedPanel === 'notelist' + ? handleNoteListKey + : state.focusedPanel === 'connections' + ? handleConnectionsKey + : state.focusedPanel === 'outline' + ? handleOutlineKey + : state.focusedPanel === 'comments' + ? handleCommentsKey + : null + if (!handlePanelKey) return // A chord is the app's (the trash chord has a listener of its own). if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return if ( @@ -1627,16 +1646,21 @@ export function VimNav(): JSX.Element | null { return } const target = e.target instanceof HTMLElement ? e.target : null + // Surfaces with keys of their own keep them while they hold DOM focus, + // whichever panel the store names: Vim mode yields to the same ones. if ( target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || - target.closest(SELF_KEYED_SURFACES)) + target.closest(SELF_KEYED_SURFACES) || + target.closest( + `${EXCALIDRAW_SURFACE}, [data-calendar-panel], [data-timeline-panel], [data-selection-toolbar]` + )) ) { return } - handleSidebarKey(e, state) + handlePanelKey(e, state) } window.addEventListener('keydown', handler, true) return () => window.removeEventListener('keydown', handler, true) @@ -1673,17 +1697,7 @@ export function VimNav(): JSX.Element | null { // With Vim off, where gg and G are off, Home and End jump to the ends and // the page keys move a screenful of rows. Vim mode leaves all four as // they were. - const pageMove = vimMode - ? null - : key === 'Home' - ? 'first' - : key === 'End' - ? 'last' - : key === 'PageDown' - ? 'pageDown' - : key === 'PageUp' - ? 'pageUp' - : null + const pageMove = pageMoveForKey(e, vimMode) // Always consume single-char nav keys when sidebar is focused, // even if the sidebar is empty — prevents them leaking to the editor. @@ -1731,7 +1745,7 @@ export function VimNav(): JSX.Element | null { : moveIndex( currentPos, count, - (pageMove === 'pageDown' ? 1 : -1) * sidebarPageRows(items[currentPos]) + (pageMove === 'pageDown' ? 1 : -1) * pageRowCount(items, currentPos) ) scrollToIndexedElement(items[target], 'sidebarIdx', state.setSidebarCursorIndex) return @@ -1793,41 +1807,37 @@ export function VimNav(): JSX.Element | null { } } - // A page in the sidebar is the rows its scroll box shows, less one, so the - // row the cursor leaves is still on screen where it lands. - function sidebarPageRows(row: HTMLElement | undefined): number { - const rowHeight = row?.getBoundingClientRect().height ?? 0 - const boxHeight = row?.closest<HTMLElement>('.zn-sidebar-scroll')?.clientHeight ?? 0 - if (rowHeight <= 0 || boxHeight <= 0) return 1 - return Math.max(1, Math.floor(boxHeight / rowHeight) - 1) - } - function handleNoteListKey(e: KeyboardEvent, state: ReturnType<typeof useStore.getState>): void { const key = e.key const overrides = state.keymapOverrides + const vimMode = state.vimMode + // Both modes, as in the sidebar: with Vim off the typed keys (j, k, l, h, + // m, /, gg, G) stay off, and Home / End and the page keys stand in for + // gg and G. + const bound = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const items = getIndexedElements('[data-notelist-idx]', 'notelistIdx') const count = getNoteListItemCount(items.length) const max = count - 1 const currentIndex = clampIndex(state.noteListCursorIndex, count) - const wantsContextMenu = - matchesSequenceToken(e, overrides, 'nav.contextMenu') || - wantsNativeContextMenuKey(e) + const wantsContextMenu = bound('nav.contextMenu') || wantsNativeContextMenuKey(e) + const pageMove = pageMoveForKey(e, vimMode) const wantsHandledKey = - matchesSequenceToken(e, overrides, 'nav.moveDown') || - matchesSequenceToken(e, overrides, 'nav.moveUp') || - matchesSequenceToken(e, overrides, 'nav.jumpBottom') || - sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0] || - matchesSequenceToken(e, overrides, 'nav.openSideItem') || - matchesSequenceToken(e, overrides, 'nav.back') || - matchesSequenceToken(e, overrides, 'nav.filter') || + bound('nav.moveDown') || + bound('nav.moveUp') || + bound('nav.jumpBottom') || + (vimMode && sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0]) || + bound('nav.openSideItem') || + bound('nav.back') || + bound('nav.filter') || key === 'Enter' || key === 'Escape' || key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || - wantsContextMenu + wantsContextMenu || + pageMove !== null if (wantsHandledKey) { e.preventDefault() e.stopImmediatePropagation() @@ -1837,7 +1847,23 @@ export function VimNav(): JSX.Element | null { if (count === 0) return - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (pageMove) { + // The list renders only the rows near the viewport, so a page is + // counted in rows and the list scrolls the landing row into view. + const rows = pageRowCount( + items, + items.findIndex((item) => getIndexedValue(item, 'notelistIdx') === currentIndex) + ) + const target = + pageMove === 'first' + ? 0 + : pageMove === 'last' + ? max + : moveIndex(currentIndex, count, (pageMove === 'pageDown' ? 1 : -1) * rows) + scrollToIndexedIndex(items, 'notelistIdx', target, state.setNoteListCursorIndex) + return + } + if (bound('nav.moveDown') || key === 'ArrowDown') { scrollToIndexedIndex( items, 'notelistIdx', @@ -1846,7 +1872,7 @@ export function VimNav(): JSX.Element | null { ) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (bound('nav.moveUp') || key === 'ArrowUp') { scrollToIndexedIndex( items, 'notelistIdx', @@ -1855,11 +1881,12 @@ export function VimNav(): JSX.Element | null { ) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (bound('nav.jumpBottom')) { scrollToIndexedIndex(items, 'notelistIdx', max, state.setNoteListCursorIndex) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -1877,20 +1904,26 @@ export function VimNav(): JSX.Element | null { ) { return } - if (key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openSideItem') || key === 'ArrowRight') { + if (key === 'Enter' || bound('nav.openSideItem') || key === 'ArrowRight') { const el = getIndexedElementByIndex(items, 'notelistIdx', currentIndex) if (!el) { scrollToIndexedIndex(items, 'notelistIdx', currentIndex, state.setNoteListCursorIndex) return } - const path = el?.dataset.notelistPath + // A file beside the notes opens the way its click does, in a tab of + // its own: selectNote would load an image or a PDF as a note's text. + if (el.dataset.notelistType === 'asset') { + el.click() + return + } + const path = el.dataset.notelistPath if (path) { void state.selectNote(path) focusEditor() } return } - if (matchesSequenceToken(e, overrides, 'nav.back') || key === 'ArrowLeft') { + if (bound('nav.back') || key === 'ArrowLeft') { if (state.sidebarOpen) state.setFocusedPanel('sidebar') return } @@ -1898,7 +1931,7 @@ export function VimNav(): JSX.Element | null { focusEditor() return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (bound('nav.filter')) { state.setSearchOpen(true) return } @@ -1916,24 +1949,30 @@ export function VimNav(): JSX.Element | null { function handleConnectionsKey(e: KeyboardEvent, state: ReturnType<typeof useStore.getState>): void { const key = e.key const overrides = state.keymapOverrides + const vimMode = state.vimMode + // Both modes: with Vim off the typed keys (j, k, l, h, p, gg, G) stay + // off, and Home / End and the page keys stand in for gg and G. + const bound = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const items = getIndexedElements('[data-connections-idx]', 'connectionsIdx') const count = items.length const max = count - 1 const currentPos = findPositionByIndex(items, 'connectionsIdx', state.connectionsCursorIndex) + const pageMove = pageMoveForKey(e, vimMode) const wantsHandledKey = - matchesSequenceToken(e, overrides, 'nav.moveDown') || - matchesSequenceToken(e, overrides, 'nav.moveUp') || - matchesSequenceToken(e, overrides, 'nav.jumpBottom') || - sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0] || - matchesSequenceToken(e, overrides, 'nav.openSideItem') || - matchesSequenceToken(e, overrides, 'nav.back') || - matchesSequenceToken(e, overrides, 'nav.peekPreview') || + bound('nav.moveDown') || + bound('nav.moveUp') || + bound('nav.jumpBottom') || + (vimMode && sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0]) || + bound('nav.openSideItem') || + bound('nav.back') || + bound('nav.peekPreview') || key === 'Enter' || key === 'Escape' || key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || - key === 'ArrowRight' + key === 'ArrowRight' || + pageMove !== null if (wantsHandledKey) { e.preventDefault() e.stopImmediatePropagation() @@ -1942,14 +1981,25 @@ export function VimNav(): JSX.Element | null { } if (count === 0) { - if (key === 'Escape' || matchesSequenceToken(e, overrides, 'nav.back') || key === 'ArrowLeft') { + if (key === 'Escape' || bound('nav.back') || key === 'ArrowLeft') { state.setConnectionPreview(null) focusEditor() } return } - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (pageMove) { + state.setConnectionPreview(null) + const target = + pageMove === 'first' + ? 0 + : pageMove === 'last' + ? max + : pageTargetPosition(items, currentPos, pageMove === 'pageDown' ? 1 : -1) + scrollToIndexedElement(items[target], 'connectionsIdx', state.setConnectionsCursorIndex) + return + } + if (bound('nav.moveDown') || key === 'ArrowDown') { state.setConnectionPreview(null) scrollToIndexedElement( items[Math.min(currentPos + 1, max)], @@ -1958,7 +2008,7 @@ export function VimNav(): JSX.Element | null { ) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (bound('nav.moveUp') || key === 'ArrowUp') { state.setConnectionPreview(null) scrollToIndexedElement( items[Math.max(currentPos - 1, 0)], @@ -1967,12 +2017,13 @@ export function VimNav(): JSX.Element | null { ) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (bound('nav.jumpBottom')) { state.setConnectionPreview(null) scrollToIndexedElement(items[max], 'connectionsIdx', state.setConnectionsCursorIndex) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -1991,16 +2042,16 @@ export function VimNav(): JSX.Element | null { ) { return } - if (key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openSideItem') || key === 'ArrowRight') { + if (key === 'Enter' || bound('nav.openSideItem') || key === 'ArrowRight') { state.setConnectionPreview(null) activateConnectionItem(items[currentPos], state) return } - if (matchesSequenceToken(e, overrides, 'nav.peekPreview')) { + if (bound('nav.peekPreview')) { openConnectionPreview(items[currentPos], state) return } - if (matchesSequenceToken(e, overrides, 'nav.back') || key === 'ArrowLeft' || key === 'Escape') { + if (bound('nav.back') || key === 'ArrowLeft' || key === 'Escape') { state.setConnectionPreview(null) focusEditor() return @@ -2011,11 +2062,15 @@ export function VimNav(): JSX.Element | null { * Outline panel navigation, mirroring the connections panel: j/k (or the * arrows) walk the headings, Enter / l jumps the editor to the one under the * cursor, h / Escape hands focus back. Before #477 the Outline was the one - * right-side panel keyboard navigation couldn't reach at all. + * right-side panel keyboard navigation couldn't reach at all. With Vim off + * the typed keys stay off; the arrows, Enter, Escape, Home / End and the + * page keys remain. */ function handleOutlineKey(e: KeyboardEvent, state: ReturnType<typeof useStore.getState>): void { const key = e.key const overrides = state.keymapOverrides + const vimMode = state.vimMode + const bound = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) const target = e.target instanceof HTMLElement ? e.target : null // The heading filter is a real text field — let it keep its own keys. if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return @@ -2023,43 +2078,56 @@ export function VimNav(): JSX.Element | null { const items = getIndexedElements('[data-outline-idx]', 'outlineIdx') const max = items.length - 1 const currentPos = findPositionByIndex(items, 'outlineIdx', state.outlineCursorIndex) + const pageMove = pageMoveForKey(e, vimMode) const wantsHandledKey = - matchesSequenceToken(e, overrides, 'nav.moveDown') || - matchesSequenceToken(e, overrides, 'nav.moveUp') || - matchesSequenceToken(e, overrides, 'nav.jumpBottom') || - sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0] || - matchesSequenceToken(e, overrides, 'nav.openSideItem') || - matchesSequenceToken(e, overrides, 'nav.back') || + bound('nav.moveDown') || + bound('nav.moveUp') || + bound('nav.jumpBottom') || + (vimMode && sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0]) || + bound('nav.openSideItem') || + bound('nav.back') || key === 'Enter' || key === 'Escape' || key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || - key === 'ArrowRight' + key === 'ArrowRight' || + pageMove !== null if (!wantsHandledKey) return e.preventDefault() e.stopImmediatePropagation() if (items.length === 0) { - if (key === 'Escape' || matchesSequenceToken(e, overrides, 'nav.back') || key === 'ArrowLeft') { + if (key === 'Escape' || bound('nav.back') || key === 'ArrowLeft') { focusEditor() } return } - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (pageMove) { + const next = + pageMove === 'first' + ? 0 + : pageMove === 'last' + ? max + : pageTargetPosition(items, currentPos, pageMove === 'pageDown' ? 1 : -1) + scrollToIndexedElement(items[next], 'outlineIdx', state.setOutlineCursorIndex) + return + } + if (bound('nav.moveDown') || key === 'ArrowDown') { scrollToIndexedElement(items[Math.min(currentPos + 1, max)], 'outlineIdx', state.setOutlineCursorIndex) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (bound('nav.moveUp') || key === 'ArrowUp') { scrollToIndexedElement(items[Math.max(currentPos - 1, 0)], 'outlineIdx', state.setOutlineCursorIndex) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (bound('nav.jumpBottom')) { scrollToIndexedElement(items[max], 'outlineIdx', state.setOutlineCursorIndex) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -2075,13 +2143,13 @@ export function VimNav(): JSX.Element | null { ) { return } - if (key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openSideItem') || key === 'ArrowRight') { + if (key === 'Enter' || bound('nav.openSideItem') || key === 'ArrowRight') { // The row owns the jump (EditorPane wires it to its own pane's view), so // click it rather than re-deriving the target line here. items[currentPos]?.click() return } - if (matchesSequenceToken(e, overrides, 'nav.back') || key === 'ArrowLeft' || key === 'Escape') { + if (bound('nav.back') || key === 'ArrowLeft' || key === 'Escape') { focusEditor() } } @@ -2089,6 +2157,13 @@ export function VimNav(): JSX.Element | null { function handleCommentsKey(e: KeyboardEvent, state: ReturnType<typeof useStore.getState>): void { const key = e.key const overrides = state.keymapOverrides + const vimMode = state.vimMode + // Both modes. With Vim off the typed keys (j, k, l, h, o, e, r, a, d, n, + // +, /, gg, G) stay off: the arrows, Enter, Escape, Home / End, the page + // keys and Backspace / Delete remain. The card's own buttons (Reply, + // Edit, Resolve) do the rest. + const bound = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) + const typed = (letter: string): boolean => vimMode && key === letter const target = e.target instanceof HTMLElement ? e.target : null const nativeButtonActivation = !!target?.closest('[data-comment-card-control]') && @@ -2099,29 +2174,30 @@ export function VimNav(): JSX.Element | null { const count = items.length const max = count - 1 const currentPos = findCommentPosition(items, state.activeCommentId) + const pageMove = pageMoveForKey(e, vimMode) + const wantsDelete = typed('d') || key === 'Backspace' || key === 'Delete' const wantsHandledKey = - matchesSequenceToken(e, overrides, 'nav.moveDown') || - matchesSequenceToken(e, overrides, 'nav.moveUp') || - matchesSequenceToken(e, overrides, 'nav.jumpBottom') || - sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0] || - matchesSequenceToken(e, overrides, 'nav.openSideItem') || - matchesSequenceToken(e, overrides, 'nav.back') || - matchesSequenceToken(e, overrides, 'nav.filter') || + bound('nav.moveDown') || + bound('nav.moveUp') || + bound('nav.jumpBottom') || + (vimMode && sequenceTokenFromEvent(e) === getSequenceTokens(overrides, 'nav.jumpTop')[0]) || + bound('nav.openSideItem') || + bound('nav.back') || + bound('nav.filter') || key === 'Enter' || - key === 'o' || - key === 'e' || - key === 'r' || - key === 'a' || - key === 'd' || - key === 'n' || - key === '+' || - key === 'Backspace' || - key === 'Delete' || + typed('o') || + typed('e') || + typed('r') || + typed('a') || + typed('n') || + typed('+') || + wantsDelete || key === 'Escape' || key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || - key === 'ArrowRight' + key === 'ArrowRight' || + pageMove !== null if (!wantsHandledKey) return e.preventDefault() @@ -2129,34 +2205,45 @@ export function VimNav(): JSX.Element | null { if (state.focusedPanel !== 'comments') state.setFocusedPanel('comments') document.querySelector<HTMLElement>('[data-comments-panel]')?.focus({ preventScroll: true }) - if (key === 'Escape' || matchesSequenceToken(e, overrides, 'nav.back') || key === 'ArrowLeft') { + if (key === 'Escape' || bound('nav.back') || key === 'ArrowLeft') { focusEditor() return } - if (key === 'n' || key === '+') { + if (typed('n') || typed('+')) { document.querySelector<HTMLElement>('[data-comments-new]')?.click() return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (bound('nav.filter')) { state.setSearchOpen(true) return } if (count === 0) return - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (pageMove) { + const next = + pageMove === 'first' + ? 0 + : pageMove === 'last' + ? max + : pageTargetPosition(items, currentPos, pageMove === 'pageDown' ? 1 : -1) + scrollToCommentElement(items[next], state) + return + } + if (bound('nav.moveDown') || key === 'ArrowDown') { scrollToCommentElement(items[Math.min(currentPos + 1, max)], state) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (bound('nav.moveUp') || key === 'ArrowUp') { scrollToCommentElement(items[Math.max(currentPos - 1, 0)], state) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (bound('nav.jumpBottom')) { scrollToCommentElement(items[max], state) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -2176,31 +2263,26 @@ export function VimNav(): JSX.Element | null { } const current = items[currentPos] - if ( - key === 'Enter' || - key === 'o' || - key === 'ArrowRight' || - matchesSequenceToken(e, overrides, 'nav.openSideItem') - ) { + if (key === 'Enter' || typed('o') || key === 'ArrowRight' || bound('nav.openSideItem')) { activateCommentItem(current) return } - if (key === 'e') { + if (typed('e')) { clickCommentAction(current, 'edit') return } - if (key === 'r') { + if (typed('r')) { clickCommentAction(current, 'resolve') return } // `a` answers in the thread (#738); the composer inside the card takes // focus, so the next keys type the reply. - if (key === 'a') { + if (typed('a')) { clickCommentAction(current, 'reply') return } - if (key === 'd' || key === 'Backspace' || key === 'Delete') { - clickCommentAction(current, 'delete') + if (wantsDelete) { + deleteCommentFromKeyboard(current) } } @@ -2516,6 +2598,24 @@ export function VimNav(): JSX.Element | null { el?.querySelector<HTMLElement>(`[data-comment-action="${action}"]`)?.click() } + // A comment deleted from the keyboard is gone for good (no Trash, no + // undo), and Backspace is the key most often pressed by accident, so the + // keys ask first, in both modes. Cancelling leaves the comment and the + // cursor where they were. The card's own Delete button, a deliberate + // click, does not ask. The card is found again after the dialog, by id: + // the panel may have re-rendered while it was open. + function deleteCommentFromKeyboard(el: HTMLElement | undefined): void { + const id = el?.dataset.commentId + if (!id) return + void confirmDeleteComment().then((confirmed) => { + if (!confirmed) return + const card = document.querySelector<HTMLElement>( + `[data-comments-idx][data-comment-id="${escapeForAttr(id)}"]` + ) + clickCommentAction(card ?? undefined, 'delete') + }) + } + function openContextMenuForIndexedElement(el: HTMLElement | undefined): void { if (!el) return dispatchKeyboardContextMenu(el) diff --git a/packages/app-core/src/lib/confirm-trash.ts b/packages/app-core/src/lib/confirm-trash.ts index 9718058e9..4b63716f6 100644 --- a/packages/app-core/src/lib/confirm-trash.ts +++ b/packages/app-core/src/lib/confirm-trash.ts @@ -25,3 +25,16 @@ export function confirmDeletePermanently(title?: string | null): Promise<boolean danger: true }) } + +/** Deleting a comment from the Comments panel's keys (d with Vim on, Backspace + * or Delete in either mode). A comment has no Trash to come back from, and + * Backspace is the key most often pressed by accident. The card's own Delete + * button does not ask. */ +export function confirmDeleteComment(): Promise<boolean> { + return confirmApp({ + title: 'Delete this comment?', + description: 'This cannot be undone.', + confirmLabel: 'Delete comment', + danger: true + }) +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index e2e608106..2ba413e15 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -302,7 +302,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Comments attach to selected text', body: - 'Select text in the editor and press `Mod+Alt+M` (or open the text menu with `m`) to start a comment, and toggle the Comments panel itself with `Mod+Shift+C`. ZenNotes stores note comments beside the note in vault metadata, then highlights the anchored text and line when the comment is active. In the panel, move with `j` / `k` and use `a` to reply, `e` to edit, `r` to resolve, and `d` to delete. Comments thread: a reply sits under the comment it answers, and each one names who wrote it, so a note can be discussed the way a pull request is. An AI assistant connected through MCP can take part: `list_comments` reads the threads, `reply_to_comment` answers in one, `add_comment` starts a new thread anchored to a passage, and `resolve_comment` closes it, each signed with the assistant\'s name (Claude, Claude Code, Codex) so its replies stand apart from yours. `zn comment list|add|reply|resolve` does the same from a terminal.' + 'Select text in the editor and press `Mod+Alt+M` (or open the text menu with `m`) to start a comment, and toggle the Comments panel itself with `Mod+Shift+C`. ZenNotes stores note comments beside the note in vault metadata, then highlights the anchored text and line when the comment is active. In the panel, move with `j` / `k` and use `a` to reply, `e` to edit, `r` to resolve, and `d` (or Backspace / Delete) to delete. Deleting from the keyboard asks first, since a deleted comment cannot be brought back; the card’s own Delete button does not ask. With Vim mode off the letters are off: the arrows move, Enter or → jumps to the comment’s text, Backspace or Delete deletes after the same confirm, and the card’s buttons do the rest. Comments thread: a reply sits under the comment it answers, and each one names who wrote it, so a note can be discussed the way a pull request is. An AI assistant connected through MCP can take part: `list_comments` reads the threads, `reply_to_comment` answers in one, `add_comment` starts a new thread anchored to a passage, and `resolve_comment` closes it, each signed with the assistant\'s name (Claude, Claude Code, Codex) so its replies stand apart from yours. `zn comment list|add|reply|resolve` does the same from a terminal.' }, { title: 'The home view is where you land', @@ -589,7 +589,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+Alt+M', action: 'Add comment', detail: 'Start a comment on the selected text (or the current line) without reaching for the mouse.' }, { keys: 'a', action: 'Reply in a comment thread', detail: 'Comments panel: open a reply box under the selected comment; `Mod+Enter` sends it, `Esc` cancels.' }, { keys: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions: they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.) Both walk the same cycle, in the order the panels appear on screen: sidebar → note list → editor → connections → comments → outline → calendar → timeline, and back again.' }, - { keys: '↑ / ↓ / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus, the arrows move its row cursor, Home and End jump to the ends, Enter opens the row under the cursor, and Esc (or ←) returns focus to the editor. The sidebar, reached with Alt+H or Focus Sidebar in the command palette, works like a tree: → opens the row or expands its folder, ← collapses a folder, or from a note folds its folder and lands on it (Esc still returns to the editor), Page Up / Page Down move a screenful of rows, and Shift+F10 or the Context Menu key opens the row’s menu. These work with Vim mode off; the single-key motions (j / k, gg / G) stay Vim-only.' }, + { keys: '↑ / ↓ / → / ← / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus (the sidebar, the note list, connections, the outline or comments), the arrows move its row cursor, Enter or → opens the row under the cursor, Esc returns focus to the editor, and ← goes back the way `h` does there: from the note list to the sidebar, from connections, the outline and comments to the editor. Shift+F10 or the Context Menu key opens the row’s menu in the sidebar and the note list. In comments, Enter or → jumps to the comment’s text, and Backspace or Delete deletes it after a confirm. With Vim mode off, Home and End jump to the ends and Page Up / Page Down move a screenful of rows; the single-key motions (j / k, gg / G) and the panels’ letter keys stay Vim-only. The sidebar, reached with Alt+H or Focus Sidebar in the command palette, works like a tree: → opens the row or expands its folder, ← collapses a folder, or from a note folds its folder and lands on it (Esc still returns to the editor).' }, { keys: 'Mod+.', action: 'Toggle Zen mode', detail: 'Hide or restore the app chrome so only the active editor, preview, or split view stays on screen.' }, { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab. With Settings open it closes Settings instead, and in a floating window or the quick capture window it closes that window, like its close button. While a dialog, palette or menu is open it does nothing, so it never closes the tab behind it; press Esc to dismiss those first. With Vim mode on, Linux and Windows keep Ctrl+W as the pane prefix while a tab is open: close the tab with :q or :bd, from the tab menu, or rebind this.' }, { keys: 'Ctrl+Tab', action: 'Switch to previous note', detail: 'Switch to the most recently used note. Press again to alternate between the last two notes.' }, @@ -685,7 +685,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'lists-and-sidebar', title: 'Sidebar and list navigation', - description: 'These bindings work when the sidebar or note list owns focus in Vim mode. With Vim mode off the sidebar keeps the arrows, Enter, Esc, Home / End, Page Up / Page Down and Shift+F10 (see Move inside a focused panel), and Mod+Backspace works there too.', + description: 'These bindings work when the sidebar or note list owns focus in Vim mode. With Vim mode off both keep the arrows, Enter, Esc, Home / End, Page Up / Page Down and Shift+F10 (see Move inside a focused panel), and Mod+Backspace works in the sidebar too.', items: [ { keys: 'j / k', action: 'Move selection', detail: 'Move down or up one visible item.' }, { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Fast travel to the first or last visible row.' }, diff --git a/packages/app-core/src/lib/panel-rows.test.ts b/packages/app-core/src/lib/panel-rows.test.ts index 17101ed02..3caf2fac1 100644 --- a/packages/app-core/src/lib/panel-rows.test.ts +++ b/packages/app-core/src/lib/panel-rows.test.ts @@ -32,12 +32,12 @@ vi.mock('../store', () => ({ })) import { - activatePanelRow, findPositionByIndex, getIndexedElements, isRowPanel, - moveCommentCursor, - movePanelCursor + pageMoveForKey, + pageRowCount, + pageTargetPosition } from './panel-rows' /** jsdom has no layout: every element reports no client rects (which the row @@ -78,77 +78,119 @@ describe('panel row cursors (#477 follow-up)', () => { expect(isRowPanel(null)).toBe(false) }) - it('moves the cursor down and up, stopping at both ends', () => { - renderRows('data-outline-idx', 3) - expect(movePanelCursor('outline', 'down')).toBe(true) - expect(storeMock.state.outlineCursorIndex).toBe(1) - movePanelCursor('outline', 'down') - movePanelCursor('outline', 'down') - expect(storeMock.state.outlineCursorIndex).toBe(2) // clamped at the last row - movePanelCursor('outline', 'up') - expect(storeMock.state.outlineCursorIndex).toBe(1) - movePanelCursor('outline', 'up') - movePanelCursor('outline', 'up') - expect(storeMock.state.outlineCursorIndex).toBe(0) // clamped at the first row + it('re-seats a cursor left past the end of a shrunken list', () => { + renderRows('data-outline-idx', 2) + const items = getIndexedElements('[data-outline-idx]', 'outlineIdx') + // Headings were edited away while the panel was not looking. + expect(findPositionByIndex(items, 'outlineIdx', 7)).toBe(1) }) +}) - it('jumps to the first and last rows', () => { - renderRows('data-outline-idx', 5) - movePanelCursor('outline', 'last') - expect(storeMock.state.outlineCursorIndex).toBe(4) - movePanelCursor('outline', 'first') - expect(storeMock.state.outlineCursorIndex).toBe(0) +/** Rows of the given heights, one under the other, in a scroll box that + * shows `boxHeight` pixels. */ +function renderStack(heights: number[], boxHeight: number): HTMLElement[] { + document.body.innerHTML = `<div data-panel-scroll>${heights + .map((_, i) => `<button data-comments-idx="${i}">card ${i}</button>`) + .join('')}</div>` + Object.defineProperty(document.querySelector('[data-panel-scroll]'), 'clientHeight', { + configurable: true, + value: boxHeight }) + let top = 0 + const rows = [...document.querySelectorAll<HTMLElement>('[data-comments-idx]')] + rows.forEach((el, i) => { + const rowTop = top + const height = heights[i] + el.getBoundingClientRect = (() => + ({ top: rowTop, bottom: rowTop + height, left: 0, right: 200, width: 200, height }) as DOMRect) as never + top += height + }) + return rows +} + +describe('page keys in a panel', () => { + const keydown = (key: string, init: KeyboardEventInit = {}): KeyboardEvent => + new KeyboardEvent('keydown', { key, ...init }) - it('reports no move when the panel has no rows, so the key stays unhandled', () => { - expect(movePanelCursor('outline', 'down')).toBe(false) - expect(activatePanelRow('outline')).toBe(false) + it('reads Home, End and the page keys with Vim off only', () => { + expect(pageMoveForKey(keydown('Home'), false)).toBe('first') + expect(pageMoveForKey(keydown('End'), false)).toBe('last') + expect(pageMoveForKey(keydown('PageDown'), false)).toBe('pageDown') + expect(pageMoveForKey(keydown('PageUp'), false)).toBe('pageUp') + expect(pageMoveForKey(keydown('ArrowDown'), false)).toBeNull() + expect(pageMoveForKey(keydown('G', { shiftKey: true }), false)).toBeNull() + // Vim mode keeps gg and G, and leaves these four alone. + expect(pageMoveForKey(keydown('PageDown'), true)).toBeNull() + expect(pageMoveForKey(keydown('Home'), true)).toBeNull() + // A chord is never a page key. + expect(pageMoveForKey(keydown('PageDown', { metaKey: true }), false)).toBeNull() + expect(pageMoveForKey(keydown('End', { altKey: true }), false)).toBeNull() }) - it('re-seats a cursor left past the end of a shrunken list', () => { - renderRows('data-outline-idx', 2) - storeMock.state.outlineCursorIndex = 7 // headings were edited away while we were gone - const items = getIndexedElements('[data-outline-idx]', 'outlineIdx') - expect(findPositionByIndex(items, 'outlineIdx', 7)).toBe(1) - movePanelCursor('outline', 'up') - expect(storeMock.state.outlineCursorIndex).toBe(0) + it('counts a page of same-height rows as the rows on screen, less one', () => { + const rows = renderStack([24, 24, 24, 24, 24, 24, 24, 24], 24 * 5) + expect(pageRowCount(rows, 0)).toBe(4) + expect(pageRowCount(rows, 7)).toBe(4) + // No rows, no box or no layout: one row, never zero. + expect(pageRowCount([], 0)).toBe(1) + Object.defineProperty(document.querySelector('[data-panel-scroll]'), 'clientHeight', { + configurable: true, + value: 0 + }) + expect(pageRowCount(rows, 0)).toBe(1) }) - it('activates the row under the cursor by clicking it', () => { - renderRows('data-outline-idx', 3) - const clicked: number[] = [] - document.querySelectorAll<HTMLElement>('[data-outline-idx]').forEach((el) => { - el.addEventListener('click', () => clicked.push(Number(el.dataset.outlineIdx))) + it('measures a row by its step to the next, inside the box’s padding', () => { + // The note list: 72 px rows 76 px apart in a box with 8 px of padding + // above and below, 808 px tall. Ten rows fit (792 / 76), so a page is + // nine; by the rows' own height it would be ten, one too far. + document.body.innerHTML = `<div data-panel-scroll style="padding: 8px 0">${Array.from( + { length: 14 }, + (_, i) => `<button data-notelist-idx="${i}">row ${i}</button>` + ).join('')}</div>` + Object.defineProperty(document.querySelector('[data-panel-scroll]'), 'clientHeight', { + configurable: true, + value: 808 + }) + const rows = [...document.querySelectorAll<HTMLElement>('[data-notelist-idx]')] + rows.forEach((el, i) => { + el.getBoundingClientRect = (() => + ({ top: 8 + i * 76, bottom: 8 + i * 76 + 72, left: 0, right: 200, width: 200, height: 72 }) as DOMRect) as never }) - storeMock.state.outlineCursorIndex = 2 - expect(activatePanelRow('outline')).toBe(true) - expect(clicked).toEqual([2]) + expect(pageRowCount(rows, 3)).toBe(9) + // A section header between two rows does not stretch the step. + rows.slice(5).forEach((el, n) => { + const top = 8 + (5 + n) * 76 + 120 + el.getBoundingClientRect = (() => + ({ top, bottom: top + 72, left: 0, right: 200, width: 200, height: 72 }) as DOMRect) as never + }) + expect(pageRowCount(rows, 5)).toBe(9) + }) + + it('gives the same page as the row count when the rows share a height', () => { + const rows = renderStack(Array(12).fill(24), 24 * 5) + expect(pageTargetPosition(rows, 0, 1)).toBe(4) + expect(pageTargetPosition(rows, 4, 1)).toBe(8) + expect(pageTargetPosition(rows, 10, 1)).toBe(11) + expect(pageTargetPosition(rows, 11, -1)).toBe(7) + expect(pageTargetPosition(rows, 2, -1)).toBe(0) }) - it('keeps each panel on its own cursor', () => { - document.body.innerHTML = ` - <button data-outline-idx="0">h1</button> - <button data-outline-idx="1">h2</button> - <button data-connections-idx="0">note a</button> - <button data-connections-idx="1">note b</button> - ` - layOutRows() - movePanelCursor('outline', 'down') - expect(storeMock.state.outlineCursorIndex).toBe(1) - expect(storeMock.state.connectionsCursorIndex).toBe(0) - movePanelCursor('connections', 'down') - expect(storeMock.state.connectionsCursorIndex).toBe(1) - expect(storeMock.state.outlineCursorIndex).toBe(1) + it('never pages past a tall card the screen has not shown', () => { + // A 300 px box: from card 0 (80), the 260 px card 2 does not fit with it. + const rows = renderStack([80, 60, 260, 40, 40], 300) + expect(pageTargetPosition(rows, 0, 1)).toBe(1) + // From the tall card itself it still moves one card. + expect(pageTargetPosition(rows, 2, 1)).toBe(3) + expect(pageTargetPosition(rows, 3, 1)).toBe(4) + expect(pageTargetPosition(rows, 4, -1)).toBe(3) + expect(pageTargetPosition(rows, 3, -1)).toBe(2) }) - it('moves the comments selection by comment id', () => { - renderRows('data-comments-idx', 3, 'data-comment-id="c%i"') - expect(moveCommentCursor('down')).toBe(true) - expect(storeMock.state.activeCommentId).toBe('c1') - moveCommentCursor('down') - moveCommentCursor('down') - expect(storeMock.state.activeCommentId).toBe('c2') - moveCommentCursor('first') - expect(storeMock.state.activeCommentId).toBe('c0') + it('stays put at the ends and on an empty list', () => { + const rows = renderStack([24, 24, 24], 24 * 5) + expect(pageTargetPosition(rows, 2, 1)).toBe(2) + expect(pageTargetPosition(rows, 0, -1)).toBe(0) + expect(pageTargetPosition([], 0, 1)).toBe(0) }) }) diff --git a/packages/app-core/src/lib/panel-rows.ts b/packages/app-core/src/lib/panel-rows.ts index 470855306..a8f17498d 100644 --- a/packages/app-core/src/lib/panel-rows.ts +++ b/packages/app-core/src/lib/panel-rows.ts @@ -7,8 +7,8 @@ * That mechanism used to exist three times over (VimNav's closures, pane-nav's * own copy, and nothing at all for non-Vim mode), which is how the two pane * navigations drifted apart in the first place (#477). It lives here once now, - * so the Vim handlers, pane navigation, and the always-on key handling all - * agree about where a cursor is and where it goes next. + * so VimNav's panel handlers (which serve the panels in both modes) and pane + * navigation agree about where a cursor is and where it goes next. */ import { useStore } from '../store' @@ -21,7 +21,7 @@ export type IndexedDatasetKey = /** Panels whose rows are addressed by a numeric cursor held in the store. The * comments panel is deliberately absent: it tracks the active comment by id, - * not by row index, so it gets its own pair of helpers below. */ + * not by row index, and VimNav walks it by that id. */ export type RowPanel = 'sidebar' | 'notelist' | 'connections' | 'outline' export const ROW_PANEL_DEFS: Record< @@ -131,80 +131,87 @@ export function rowCursor( } } -export type CursorMove = 'up' | 'down' | 'first' | 'last' +/** What a page key asks of a panel's cursor. */ +export type PageMove = 'first' | 'last' | 'pageDown' | 'pageUp' /** - * Move a row panel's cursor and scroll the landing row into view. Returns false - * when the panel has no rows, so callers can leave the key unhandled rather than - * swallowing it. + * Home, End, Page Up and Page Down in a focused panel, with Vim mode off: the + * ends and a screenful of rows. Vim mode leaves all four as they were (gg and + * G are its keys), so this is null there, and for a chord. */ -export function movePanelCursor(panel: RowPanel, move: CursorMove): boolean { - const state = useStore.getState() - const { selector, datasetKey } = ROW_PANEL_DEFS[panel] - const items = getIndexedElements(selector, datasetKey) - if (items.length === 0) return false - const { index, setIndex } = rowCursor(panel, state) - const current = findPositionByIndex(items, datasetKey, index) - const next = - move === 'up' - ? Math.max(current - 1, 0) - : move === 'down' - ? Math.min(current + 1, items.length - 1) - : move === 'first' - ? 0 - : items.length - 1 - scrollToIndexedElement(items[next], datasetKey, setIndex) - return true -} - -/** Activate the row under the cursor by clicking it — the row itself owns what - * "open" means (jump to a heading, open a note, reveal a comment). */ -export function activatePanelRow(panel: RowPanel): boolean { - const state = useStore.getState() - const { selector, datasetKey } = ROW_PANEL_DEFS[panel] - const items = getIndexedElements(selector, datasetKey) - if (items.length === 0) return false - const { index } = rowCursor(panel, state) - const row = items[findPositionByIndex(items, datasetKey, index)] - if (!row) return false - row.click() - return true +export function pageMoveForKey(event: KeyboardEvent, vimMode: boolean): PageMove | null { + if (vimMode || event.metaKey || event.ctrlKey || event.altKey) return null + switch (event.key) { + case 'Home': + return 'first' + case 'End': + return 'last' + case 'PageDown': + return 'pageDown' + case 'PageUp': + return 'pageUp' + default: + return null + } } -// --------------------------------------------------------------------------- -// Comments — same rows, but the cursor is the active comment's id -// --------------------------------------------------------------------------- - -const COMMENT_ROW_SELECTOR = '[data-comments-idx]' +/** The box a panel's rows scroll in: the sidebar's, or one a panel marks. */ +const PANEL_SCROLL_BOX = '.zn-sidebar-scroll, [data-panel-scroll]' -function getCommentRows(): HTMLElement[] { - return getIndexedElements(COMMENT_ROW_SELECTOR, 'commentsIdx') -} - -function commentPosition(items: HTMLElement[], activeCommentId: string | null): number { - if (items.length === 0 || !activeCommentId) return 0 - const exact = items.findIndex((item) => item.dataset.commentId === activeCommentId) - return exact >= 0 ? exact : 0 +/** + * A page in a list of same-height rows (the sidebar, the note list): the rows + * the scroll box shows, less one, so the row the cursor leaves is still on + * screen where it lands. A count rather than a row, because both lists render + * only the rows near the viewport. A row is measured by its step to the next + * one, gap included (the smallest step among the rows on screen, since a + * section header makes some steps longer), and the box by the height inside + * its padding: the note list's rows are 4 px apart in a padded box, and + * counting them by their own height paged one row too far. + */ +export function pageRowCount(items: HTMLElement[], position: number): number { + const row = items[position] ?? items[0] + const box = row?.closest<HTMLElement>(PANEL_SCROLL_BOX) + if (!row || !box) return 1 + let pitch = Infinity + for (let i = 1; i < items.length; i++) { + const step = items[i].getBoundingClientRect().top - items[i - 1].getBoundingClientRect().top + if (step > 2 && step < pitch) pitch = step + } + if (!Number.isFinite(pitch)) pitch = row.getBoundingClientRect().height + const style = window.getComputedStyle(box) + const inner = + box.clientHeight - (parseFloat(style.paddingTop) || 0) - (parseFloat(style.paddingBottom) || 0) + if (pitch <= 0 || inner <= 0) return 1 + return Math.max(1, Math.floor(inner / pitch) - 1) } -/** Move the comments panel's selection, mirroring `movePanelCursor`. */ -export function moveCommentCursor(move: CursorMove): boolean { - const state = useStore.getState() - const items = getCommentRows() - if (items.length === 0) return false - const current = commentPosition(items, state.activeCommentId) - const next = - move === 'up' - ? Math.max(current - 1, 0) - : move === 'down' - ? Math.min(current + 1, items.length - 1) - : move === 'first' - ? 0 - : items.length - 1 - const el = items[next] - if (!el) return false - const commentId = el.dataset.commentId - if (commentId) state.setActiveCommentId(commentId) - el.scrollIntoView({ block: 'nearest' }) - return true +/** + * A page in a list whose rows are all rendered and may differ in height (the + * connections and comments cards, the outline): the furthest row that still + * fits on one screen together with the row the cursor leaves. With rows of + * one height this is the page `pageRowCount` gives, and a tall card never + * makes the cursor skip a row the screen has not shown. At least one row; + * clamped at the ends. + */ +export function pageTargetPosition( + items: HTMLElement[], + current: number, + direction: 1 | -1 +): number { + const last = items.length - 1 + if (last < 0) return 0 + const from = Math.max(0, Math.min(current, last)) + const step = Math.max(0, Math.min(from + direction, last)) + const row = items[from] + const boxHeight = row?.closest<HTMLElement>(PANEL_SCROLL_BOX)?.clientHeight ?? 0 + if (!row || boxHeight <= 0) return step + const leaving = row.getBoundingClientRect() + let target = step + for (let i = from + direction; i >= 0 && i <= last; i += direction) { + const rect = items[i].getBoundingClientRect() + const span = direction === 1 ? rect.bottom - leaving.top : leaving.bottom - rect.top + if (span > boxHeight) break + target = i + } + return target } From 4b7deaffcf3e74fd6901eac6887b037e8f1bc79a Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 14:36:13 -0500 Subject: [PATCH 26/34] Fix(palette): with Vim mode off, no row names a Vim key With Vim mode off the command palette still showed keys that only Vim mode has: leader chords (Space d for today's note, Space l s for Favorite), the Ctrl+W pane prefix on the Focus Pane commands, the fold keys (z c, z M), Ctrl+O / Ctrl+I on Go Back and Go Forward, and ex commands (:w, :format, :cover!, :split, :tasks, :tag, :unresolved). None of those keys does anything with Vim off, which is the leak the Vim-off hint audit closed everywhere else on screen. A few rows already guarded their leader chord by hand; most did not. The palette's key helpers now carry the rule once: leader chords, the pane prefix, ex commands and the vim.* keys show only with Vim mode on. Go Back and Go Forward show their global keys (Alt+Left / Alt+Right) with Vim off instead of nothing, since those work in both modes. Keys a user gave a command (#971) are untouched by this. Test: with Vim off no palette row shows a leader chord, the pane prefix, an ex command or a Vim-group key (17 rows did before), with Vim on they still show, and Go Back / Go Forward show the global keys. --- packages/app-core/src/lib/commands.test.ts | 47 +++++++++++++++++++++ packages/app-core/src/lib/commands.ts | 48 ++++++++++++---------- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index b807a9fd3..46cd92cf1 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -677,3 +677,50 @@ describe('a key the user gave a command (#971)', () => { expect(reason('note.unarchive')).toBe('Unarchive Note only works on a note in the Archive') }) }) + +describe('Vim-only keys in the palette', () => { + it('names no leader chord, pane prefix, ex command or Vim key with Vim mode off', async () => { + const { buildCommands, useStore } = await loadCommands() + const { getKeymapDisplay } = await import('./keymaps') + useStore.setState({ vimMode: false, activeNote: null }) + const overrides = useStore.getState().keymapOverrides + const leader = getKeymapDisplay(overrides, 'vim.leaderPrefix') + const pane = getKeymapDisplay(overrides, 'vim.panePrefix') + const vimKeys = ( + ['vim.foldCurrent', 'vim.unfoldCurrent', 'vim.foldAll', 'vim.unfoldAll', 'vim.historyBack', 'vim.historyForward'] as const + ).map((id) => getKeymapDisplay(overrides, id)) + const leaks = buildCommands({ includeUnavailable: true }) + .filter((command) => { + const shown = command.shortcut ?? '' + if (!shown) return false + return ( + shown.startsWith(':') || + (!!leader && shown.startsWith(`${leader} `)) || + (!!pane && shown.startsWith(`${pane} `)) || + vimKeys.includes(shown) + ) + }) + .map((command) => `${command.id}: ${command.shortcut}`) + expect(leaks).toEqual([]) + }) + + it('still names them with Vim mode on', async () => { + const { buildCommands, useStore } = await loadCommands() + useStore.setState({ vimMode: true }) + const shown = new Map(buildCommands({ includeUnavailable: true }).map((c) => [c.id, c.shortcut ?? ''])) + expect(shown.get('note.format')).toBe(':format') + expect(shown.get('note.favorite')).toMatch(/\S+ \S+ \S+/) + }) +}) + +describe('Go Back and Go Forward keys', () => { + it('show the global keys with Vim mode off', async () => { + const { buildCommands, useStore } = await loadCommands() + const { getKeymapDisplay } = await import('./keymaps') + useStore.setState({ vimMode: false }) + const overrides = useStore.getState().keymapOverrides + const shown = new Map(buildCommands({ includeUnavailable: true }).map((c) => [c.id, c.shortcut ?? ''])) + expect(shown.get('nav.back')).toBe(getKeymapDisplay(overrides, 'global.historyBack')) + expect(shown.get('nav.forward')).toBe(getKeymapDisplay(overrides, 'global.historyForward')) + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 12c8606bd..3a96e81af 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -168,9 +168,13 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // such a note opens to read, one the command creates is written in. const periodicNoteExists = (find: typeof findDailyNoteForDate): boolean => !!find(getState().notes, getState().vaultSettings, new Date()) + // Leader chords, the Ctrl+W pane prefix and ex commands exist only in Vim + // mode, so with it off the palette names none of them: a row must not + // advertise a key that does nothing (the Vim-off hint rule). + const vimOnly = (display: string): string => (getState().vimMode ? display : '') const leaderShortcut = (id: KeymapId): string => - getLeaderChordDisplay(getState().keymapOverrides, id) - const paneShortcut = (id: KeymapId): string => chord('vim.panePrefix', id) + vimOnly(getLeaderChordDisplay(getState().keymapOverrides, id)) + const paneShortcut = (id: KeymapId): string => vimOnly(chord('vim.panePrefix', id)) const searchShortcut = (): string => { const state = getState() const primary = shortcut('global.searchNotes') @@ -399,7 +403,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'note.save', title: 'Save Note', category: 'Note', - shortcut: ':w', + shortcut: vimOnly(':w'), keywords: 'persist write', when: hasSelection, run: () => getState().persistActive() @@ -408,7 +412,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'note.format', title: 'Format Markdown', category: 'Note', - shortcut: ':format', + shortcut: vimOnly(':format'), keywords: 'prettier', when: hasSelection, run: () => getState().formatActiveNote() @@ -441,7 +445,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: isActiveNoteFavorite() ? 'Remove Note from Favorites' : 'Add Note to Favorites', category: 'Note', keywords: 'favourite star bookmark home sidebar', - shortcut: chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderToggleFavorite'), + shortcut: vimOnly(chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderToggleFavorite')), when: () => { const active = getState().activeNote return !!active && active.folder !== 'trash' @@ -455,7 +459,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: activeNoteCover() ? 'Change Cover…' : 'Add Cover…', category: 'Note', keywords: 'banner header image picture gallery gradient', - shortcut: chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCover'), + shortcut: vimOnly(chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCover')), when: () => !!coverNotePath(), run: () => { openCoverPicker() @@ -467,7 +471,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Add Cover from Unsplash…', category: 'Note', keywords: 'banner header image picture photo stock unsplash', - shortcut: chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCoverUnsplash'), + shortcut: vimOnly(chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCoverUnsplash')), when: () => !!coverNotePath() && unsplashSearchAvailable(), run: () => { openUnsplashCoverPicker() @@ -479,7 +483,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Paste Cover from Clipboard', category: 'Note', keywords: 'banner header image screenshot', - shortcut: ':cover paste', + shortcut: vimOnly(':cover paste'), when: () => !!coverNotePath(), run: async () => { const path = coverNotePath() @@ -492,7 +496,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Reposition Cover', category: 'Note', keywords: 'banner header image move focal', - shortcut: ':cover reposition', + shortcut: vimOnly(':cover reposition'), when: () => !!activeNoteCover(), run: () => { startCoverReposition() @@ -504,7 +508,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Remove Cover', category: 'Note', keywords: 'banner header image delete', - shortcut: ':cover!', + shortcut: vimOnly(':cover!'), when: () => !!activeNoteCover(), run: async () => { const path = coverNotePath() @@ -1120,7 +1124,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'fold.heading', title: 'Fold Heading or List Item at Cursor', category: 'Editor', - shortcut: shortcut('vim.foldCurrent'), + shortcut: vimOnly(shortcut('vim.foldCurrent')), keywords: 'collapse fold heading section list item task bullet children callout', when: hasEditorNote, run: () => runFoldCommand('foldAtCursor') @@ -1129,7 +1133,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'fold.unfold-heading', title: 'Unfold Heading or List Item at Cursor', category: 'Editor', - shortcut: shortcut('vim.unfoldCurrent'), + shortcut: vimOnly(shortcut('vim.unfoldCurrent')), keywords: 'expand unfold heading section list item task bullet children callout', when: hasEditorNote, run: () => runFoldCommand('unfoldAtCursor') @@ -1138,7 +1142,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'fold.all', title: 'Fold All Headings and Lists', category: 'Editor', - shortcut: shortcut('vim.foldAll'), + shortcut: vimOnly(shortcut('vim.foldAll')), keywords: 'collapse fold all every heading list item task outline callout', when: hasEditorNote, run: () => runFoldCommand('foldAllOutline') @@ -1147,7 +1151,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'fold.unfold-all', title: 'Unfold All Headings and Lists', category: 'Editor', - shortcut: shortcut('vim.unfoldAll'), + shortcut: vimOnly(shortcut('vim.unfoldAll')), keywords: 'expand unfold all every reset heading list item task outline callout', when: hasEditorNote, run: () => runFoldCommand('unfoldAll') @@ -1321,7 +1325,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'nav.back', title: 'Go Back', category: 'Tabs', - shortcut: shortcut('vim.historyBack'), + shortcut: getState().vimMode ? shortcut('vim.historyBack') : shortcut('global.historyBack'), keywords: 'history previous', opensToRead: () => true, run: () => getState().jumpToPreviousNote() @@ -1330,7 +1334,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'nav.forward', title: 'Go Forward', category: 'Tabs', - shortcut: shortcut('vim.historyForward'), + shortcut: getState().vimMode ? shortcut('vim.historyForward') : shortcut('global.historyForward'), keywords: 'history next', opensToRead: () => true, run: () => getState().jumpToNextNote() @@ -1352,7 +1356,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'split.right', title: 'Split Right', category: 'Panes', - shortcut: ':vsplit', + shortcut: vimOnly(':vsplit'), keywords: 'vsplit vertical', when: hasSelection, run: () => { @@ -1370,7 +1374,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'split.down', title: 'Split Down', category: 'Panes', - shortcut: ':split', + shortcut: vimOnly(':split'), keywords: 'split horizontal', when: hasSelection, run: () => { @@ -1511,7 +1515,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma unavailableReason: () => `The ${labels().tasks} view is already open`, title: `Open ${labels().tasks}`, category: 'View', - shortcut: ':tasks', + shortcut: vimOnly(':tasks'), keywords: 'tasks todo checklist due waiting done vault', when: () => !isTasksViewActive(getState()), run: () => getState().openTasksView() @@ -1521,7 +1525,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma unavailableReason: () => 'Workflows are off: turn them on under Settings, Workflows', title: 'Open Workflows', category: 'View', - shortcut: ':workflows', + shortcut: vimOnly(':workflows'), keywords: 'workflow automation pipeline graph canvas nodes run dry', // Hidden entirely when the feature is switched off in Settings, so the // palette never offers a command that would open nothing. @@ -1544,7 +1548,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma unavailableReason: () => 'The Unresolved Links view is already open', title: 'Open Unresolved Links', category: 'View', - shortcut: getState().vimMode ? leaderShortcut('vim.leaderUnresolvedLinks') : ':unresolved', + shortcut: leaderShortcut('vim.leaderUnresolvedLinks'), keywords: 'unresolved links missing broken dangling wikilinks notes to write later create orphan dead', when: () => !isUnresolvedLinksViewActive(getState()), @@ -1555,7 +1559,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma unavailableReason: () => 'The Tags view is already open', title: 'Open Tags', category: 'View', - shortcut: ':tag', + shortcut: vimOnly(':tag'), keywords: 'tags browse filter vault', when: () => !isTagsViewActive(getState()), run: () => getState().openTagView() From aeaa41d4a177c55c67bbc00aa5a558df78888ac3 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 14:36:13 -0500 Subject: [PATCH 27/34] Fix(comments): deleting a comment from the keyboard leaves the cursor on the next one Deleting the active comment clears activeCommentId in the store, and the comments panel puts a cleared cursor on the first comment, so after d / Backspace / Delete the cursor jumped back to the top of the list and a run of deletes could not walk down it. The keyboard delete now notes the comment after the deleted one in the panel's order (the one before it, when the last goes) and makes it active once the delete has run. Cancelling the confirm still leaves the cursor where it was, and the card's own Delete button is unchanged. Tests: after deleting c1 the cursor is on c2; after deleting the last comment it is on the one before. Both fail on the previous code. --- .../src/components/VimNav-panel-keys.test.ts | 12 ++++++++++++ packages/app-core/src/components/VimNav.tsx | 15 +++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/app-core/src/components/VimNav-panel-keys.test.ts b/packages/app-core/src/components/VimNav-panel-keys.test.ts index ddb4a63f0..341f13196 100644 --- a/packages/app-core/src/components/VimNav-panel-keys.test.ts +++ b/packages/app-core/src/components/VimNav-panel-keys.test.ts @@ -647,6 +647,18 @@ describe('comments keys in VimNav', () => { expect(confirmTitle()).toBe('Delete this comment?') await answerConfirm(true) expect(clicks).toEqual(['c1:delete']) + // The cursor takes the next comment, not the first one. + expect(active()).toBe('c2') + }) + + it('puts the cursor on the previous comment after deleting the last one', async () => { + mount(false) + press('End', 'End') + expect(active()).toBe('c7') + press('Delete', 'Delete') + await answerConfirm(true) + expect(clicks).toEqual(['c7:delete']) + expect(active()).toBe('c6') }) it('returns to the editor with ArrowLeft and Escape, as h does', () => { diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index b193ba5f3..6b5d2b884 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -2609,10 +2609,17 @@ export function VimNav(): JSX.Element | null { if (!id) return void confirmDeleteComment().then((confirmed) => { if (!confirmed) return - const card = document.querySelector<HTMLElement>( - `[data-comments-idx][data-comment-id="${escapeForAttr(id)}"]` - ) - clickCommentAction(card ?? undefined, 'delete') + const items = getCommentItems() + const at = items.findIndex((item) => item.dataset.commentId === id) + const card = at >= 0 ? items[at] : undefined + // Deleting the active comment clears it in the store, and a cleared + // cursor lands on the first comment. The cursor takes the next one in + // the panel's order instead (the previous one after the last), so a + // run of deletes walks down the list rather than back to the top. + const neighbour = at >= 0 ? (items[at + 1] ?? items[at - 1]) : undefined + clickCommentAction(card, 'delete') + const next = neighbour?.dataset.commentId + if (next) useStore.getState().setActiveCommentId(next) }) } From 1063a4f411442f7491f945a8c6ca2b76ba3bdec0 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 15:25:55 -0500 Subject: [PATCH 28/34] Feat(covers): Unsplash search with a key of your own, straight from your machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adib changed the Unsplash design: each person pastes their own Unsplash Access Key and the app asks api.unsplash.com directly. The zennotes.org proxy and its one shared key are gone from the app (the website's feat/unsplash-covers stays unmerged), so the release waits on nothing from Unsplash, and one person's searches never spend anyone else's hour. The key goes in under Settings, Typography, in a new Covers section that now also holds the Note covers placement row. Paste and press Enter (or Save): the key is checked first with one request, GET /photos?per_page=1, and kept unless Unsplash answers 401, so a mistyped key or a Secret Key is refused on the spot ("Check that you copied the Access Key, not the Secret Key"). A key that could not be checked (no connection, the hour spent) is kept, and the line says why. Test asks again and says what is left of the hour ("48 of 50 requests left this hour"); Clear removes it. The field is write-only, so a saved key is never drawn back. The buttons stay enabled while a request is out, and when the one that had the keyboard disables itself (Clear, or Save once the field empties) the field takes the keyboard rather than the page. Settings search finds the row by "unsplash", "cover" and "api key", and Set Unsplash Access Key… in the palette opens Settings in the field. Desktop main keeps the key where the Cloud credential lives: sealed by Electron safeStorage in the secrets file under userData (secret-store), never in config.toml, vault.json, a log, an error, or anything the renderer keeps. The renderer hears only whether a key is set, where it is kept and whether it could be. Without OS encryption (a Linux compositor Chromium does not recognize, as on Niri) the key is refused rather than written in the clear, and the row suggests --password-store=gnome-libsecret. Search, the check and the download ping go from main to api.unsplash.com with Authorization: Client-ID <key> and Accept-Version: v1, with no cookies, a bare user agent, no language and no referrer; the key travels in the header, never the URL. Search asks for landscape, safe-search photos, 24 a page, and the ping is a GET of the photo's download_location with the key. The 40-a-minute cap per run stays, and now keeps a renderer loop from spending the person's own 50 an hour. The answer is Unsplash's own JSON, so shared-domain unsplash.ts reads it directly (user.links.html, links.download_location, alt_description) and keeps the proxy's rules: images only from images.unsplash.com, links only to unsplash.com, a ping only to an api.unsplash.com download endpoint, no Unsplash+ photo. The client itself (status, save, test, clear, search, ping, the cap and the argument checks) moved to shared-domain as createUnsplashClient, so desktop main and the web client run the same code with their own fetch and key store. ZENNOTES_UNSPLASH_PROXY_URL becomes ZENNOTES_UNSPLASH_API_URL, honored as before only unpackaged or with ZEN_PERF=1. Without a key the Unsplash tab sends nothing and shows a setup panel in place of photos: what the key is for, the three steps (create a New Application at unsplash.com/developers, copy its Access Key, paste it in Settings), Open Settings and the developers page. The field keeps focus as it does over the tiles: ↑/↓ choose, Enter runs, so Space l u then Enter is the whole way to the key field, with Vim on or off. A refused key (401) reads "Unsplash did not accept your access key" with Change key; a spent hour (403 with X-Ratelimit-Remaining at 0) explains that a new Unsplash app allows 50 requests an hour until Unsplash approves it for production, or names the allowance Unsplash reports; no connection is a plain sentence. The web client keeps its key in the browser, under its own localStorage entry that only the bridge reads, and calls Unsplash from the browser. api.unsplash.com answers any origin, allows the Authorization header in its preflight and exposes X-Ratelimit-Remaining, and znserver's CSP already allows https: in connect-src, so the key never reaches the self-hosted server or zennotes.org. A browser has no keychain to offer, the web client's own session is a cookie and not in that storage, and the key is worth an hour of photo searches and can be revoked at unsplash.com/oauth/applications, so the tab stays on the web rather than going dark there. The phones still have no tab. Not here: the website's docs and privacy text are on its feat/unsplash-own-key branch. Verified in the built app over CDP (56 checks), both stores isolated and --use-mock-keychain so the real Keychain is never touched, against a local stand-in for api.unsplash.com: Space l u and :cover unsplash fog without a key showing the panel and sending nothing; ↓ and Enter opening unsplash.com/developers; Enter on Open Settings landing in the highlighted key field; a malformed key refused without a request and a wrong one refused after one, neither stored; the right key saved sealed (the secrets file holds it encrypted, no file under the run holds it as typed, nor the page or localStorage); Tab to Test; a search with Client-ID, no cookie, referrer or key in the URL, and the Unsplash+ photo left out; Enter writing the banner and credit, one ping with the key, the credit on hover; the rate-limited, refused-key (Change key into Settings, then Test) and offline messages; Clear from the keyboard and the panel coming back; the palette command; Settings search by unsplash, cover and api key. Tests: the key store against a stand-in safeStorage (round trip, cleared, sealed on disk, refused without encryption, never in config or an answer), the API client (headers and query, 401, 403 with and without allowance left, network, the ping's target, the check, save, clear, the cap, argument checks), the no-key tab (the panel, no search, Enter into Settings, ↓ Enter to the developers page, Change key, a key that works), and the messages. shared-domain 2,198, app-core 3,992, desktop 1,250 and quicklook 18 pass; typecheck clean. How to test locally: build the desktop app and run electron apps/desktop/out/main/index.js. Open a note and press Space l u: the tab shows how to get a key. Press Enter, paste the Access Key of an app made at unsplash.com/developers and press Enter: the line says Saved, with the requests left this hour. Escape, Space l u, type "fog", pick a photo with Enter: the note gains banner, banner_credit and banner_credit_url, and hovering the cover shows "Photo by ... on Unsplash". Back in Settings, Clear: the tab asks for a key again. --- apps/desktop/src/main/index.ts | 14 +- apps/desktop/src/main/secret-store.ts | 49 +++ apps/desktop/src/main/unsplash.test.ts | 158 +++++++--- apps/desktop/src/main/unsplash.ts | 101 ++---- apps/desktop/src/preload/index.ts | 11 +- apps/web/src/bridge/http-bridge.ts | 64 +++- .../NoteCoverPicker-unsplash-key.test.ts | 169 ++++++++++ .../src/components/NoteCoverPicker.tsx | 191 +++++++++++- .../app-core/src/components/SettingsModal.tsx | 74 ++++- .../src/components/UnsplashKeySetting.tsx | 149 +++++++++ packages/app-core/src/lib/commands.ts | 13 + packages/app-core/src/lib/help.ts | 2 +- .../app-core/src/lib/settings-navigation.ts | 9 +- .../app-core/src/lib/unsplash-key.test.ts | 42 +++ packages/app-core/src/lib/unsplash-key.ts | 152 +++++++++ .../app-core/src/lib/unsplash-search.test.ts | 4 +- packages/app-core/src/lib/unsplash-search.ts | 17 +- packages/bridge-contract/src/bridge.ts | 22 +- packages/bridge-contract/src/ipc.ts | 4 + packages/bridge-contract/src/unsplash.ts | 54 +++- .../shared-domain/src/commands-catalog.ts | 1 + packages/shared-domain/src/unsplash.test.ts | 293 +++++++++++++---- packages/shared-domain/src/unsplash.ts | 294 ++++++++++++++---- 23 files changed, 1596 insertions(+), 291 deletions(-) create mode 100644 packages/app-core/src/components/NoteCoverPicker-unsplash-key.test.ts create mode 100644 packages/app-core/src/components/UnsplashKeySetting.tsx create mode 100644 packages/app-core/src/lib/unsplash-key.test.ts create mode 100644 packages/app-core/src/lib/unsplash-key.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 18f58d158..4abffcd35 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -297,7 +297,7 @@ import { installRaycastExtension, } from "./raycast-integration"; import { sendUsageCount } from "./usage-count"; -import { searchUnsplash, trackUnsplashDownload } from "./unsplash"; +import { unsplash } from "./unsplash"; import { hideTray, parseTraySyncStatus, @@ -3823,9 +3823,17 @@ function registerIpc(): void { handle(IPC.APP_UPDATER_GET_STATE, () => getAppUpdateState()); handle(IPC.APP_UPDATER_CHECK, async () => await checkForAppUpdates()); handle(IPC.APP_USAGE_COUNT, async (_e, event: unknown) => await sendUsageCount(event)); - handle(IPC.UNSPLASH_SEARCH, async (_e, query: unknown, page: unknown) => await searchUnsplash(query, page)); + handle(IPC.UNSPLASH_SEARCH, async (_e, query: unknown, page: unknown) => await unsplash.search(query, page)); handle(IPC.UNSPLASH_TRACK_DOWNLOAD, async (_e, downloadLocation: unknown) => { - await trackUnsplashDownload(downloadLocation); + await unsplash.trackDownload(downloadLocation); + }); + // The key goes in and never comes back out: status answers only whether + // one is set. + handle(IPC.UNSPLASH_KEY_STATUS, async () => await unsplash.status()); + handle(IPC.UNSPLASH_KEY_SAVE, async (_e, key: unknown) => await unsplash.save(key)); + handle(IPC.UNSPLASH_KEY_TEST, async () => await unsplash.test()); + handle(IPC.UNSPLASH_KEY_CLEAR, async () => { + await unsplash.clear(); }); handle(IPC.APP_UPDATER_CHECK_WITH_UI, async () => { await runMenuUpdateCheck(); diff --git a/apps/desktop/src/main/secret-store.ts b/apps/desktop/src/main/secret-store.ts index 6ea8a4cc2..fc2a3e0e3 100644 --- a/apps/desktop/src/main/secret-store.ts +++ b/apps/desktop/src/main/secret-store.ts @@ -343,3 +343,52 @@ export async function deleteCloudAccountKeySecrets(baseUrl: string): Promise<voi for (const name of stale) delete fallback[name] }) } + +const UNSPLASH_KEYTAR_SERVICE = 'ZenNotes Unsplash' +const UNSPLASH_ACCESS_KEY_NAME = 'unsplash:access-key' + +/** + * The person's own Unsplash Access Key, for the cover picker's Unsplash tab. + * Kept only in the OS-protected store, like the Cloud credential: never in + * config.toml, vault.json or anything the renderer keeps (main tells the + * renderer only whether there is one). + */ +export async function getUnsplashAccessKeySecret(): Promise<string | null> { + const keytar = await loadKeytar() + if (keytar) return keytar.getPassword(UNSPLASH_KEYTAR_SERVICE, UNSPLASH_ACCESS_KEY_NAME) + const encoded = (await loadFallbackSecrets())[UNSPLASH_ACCESS_KEY_NAME] + return encoded ? decodeSecret(encoded) : null +} + +export async function setUnsplashAccessKeySecret(key: string): Promise<boolean> { + const normalized = key.trim() + if (!normalized) return false + const keytar = await loadKeytar() + if (keytar) { + await keytar.setPassword(UNSPLASH_KEYTAR_SERVICE, UNSPLASH_ACCESS_KEY_NAME, normalized) + return true + } + const encoded = encodeSecret(normalized) + if (!encoded) return false + await updateFallbackSecrets((fallback) => { + fallback[UNSPLASH_ACCESS_KEY_NAME] = encoded + }) + return true +} + +export async function deleteUnsplashAccessKeySecret(): Promise<void> { + const keytar = await loadKeytar() + if (keytar) { + await keytar.deletePassword(UNSPLASH_KEYTAR_SERVICE, UNSPLASH_ACCESS_KEY_NAME) + return + } + if (!(UNSPLASH_ACCESS_KEY_NAME in (await loadFallbackSecrets()))) return + await updateFallbackSecrets((fallback) => { + delete fallback[UNSPLASH_ACCESS_KEY_NAME] + }) +} + +/** Whether a secret saved now would be encrypted by the OS. */ +export function canStoreSecretsSecurely(): boolean { + return safeStorage.isEncryptionAvailable() +} diff --git a/apps/desktop/src/main/unsplash.test.ts b/apps/desktop/src/main/unsplash.test.ts index 6fa8e5078..63593b229 100644 --- a/apps/desktop/src/main/unsplash.test.ts +++ b/apps/desktop/src/main/unsplash.test.ts @@ -1,55 +1,141 @@ -import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('electron', () => ({ app: { isPackaged: true }, net: { fetch: vi.fn() } })) +const machine = vi.hoisted(() => ({ userData: '', encryption: true })) +vi.mock('electron', () => ({ + app: { isPackaged: true, getPath: () => machine.userData }, + net: { fetch: vi.fn() }, + // A stand-in for the OS cipher. What matters is that the secrets file never + // holds the key as written, and that a key comes back out unchanged. + safeStorage: { + isEncryptionAvailable: () => machine.encryption, + encryptString: (text: string) => Buffer.from(`sealed:${Buffer.from(text).toString('hex').split('').reverse().join('')}`), + decryptString: (sealed: Buffer) => { + const body = sealed.toString() + if (!body.startsWith('sealed:')) throw new Error('not sealed') + return Buffer.from(body.slice('sealed:'.length).split('').reverse().join(''), 'hex').toString() + } + } +})) + +import { PORTABLE_PREF_KEYS } from '@shared/app-config' import type { UnsplashFetch } from '@shared/unsplash' -import { createUnsplashClient, unsplashProxyBaseUrl, UNSPLASH_REQUESTS_PER_MINUTE } from './unsplash' +import { + deleteUnsplashAccessKeySecret, + getUnsplashAccessKeySecret, + setUnsplashAccessKeySecret +} from './secret-store' +import { createDesktopUnsplashClient, unsplashApiBaseUrl } from './unsplash' -const ok = (json: unknown) => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => json }) +const KEY = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcde' +const ok = (json: unknown) => ({ + ok: true, + status: 200, + headers: { get: (name: string) => ({ 'x-ratelimit-limit': '50', 'x-ratelimit-remaining': '49' })[name.toLowerCase()] ?? null }, + json: async () => json +}) -describe('unsplashProxyBaseUrl', () => { - it('is zennotes.org unless a test run names another', () => { - expect(unsplashProxyBaseUrl({}, true)).toBe('https://zennotes.org/api/v1/unsplash') - expect(unsplashProxyBaseUrl({ ZENNOTES_UNSPLASH_PROXY_URL: 'http://127.0.0.1:7/u/' }, false)).toBe( - 'http://127.0.0.1:7/u' - ) +/** Every file under `dir`, read as text. */ +async function filesUnder(dir: string): Promise<Array<{ path: string; text: string }>> { + const out: Array<{ path: string; text: string }> = [] + for (const name of await readdir(dir)) { + const path = join(dir, name) + if ((await stat(path)).isDirectory()) out.push(...(await filesUnder(path))) + else out.push({ path, text: await readFile(path, 'utf8') }) + } + return out +} + +beforeEach(async () => { + machine.userData = await mkdtemp(join(tmpdir(), 'zn-unsplash-key-')) + machine.encryption = true +}) + +afterEach(async () => { + await rm(machine.userData, { recursive: true, force: true }) +}) + +describe('unsplashApiBaseUrl', () => { + it('is api.unsplash.com unless a test run names a stand-in', () => { + expect(unsplashApiBaseUrl({}, true)).toBe('https://api.unsplash.com') + expect(unsplashApiBaseUrl({ ZENNOTES_UNSPLASH_API_URL: 'http://127.0.0.1:7/' }, false)).toBe('http://127.0.0.1:7') // A packaged copy honors the override only for a test run. - expect(unsplashProxyBaseUrl({ ZENNOTES_UNSPLASH_PROXY_URL: 'http://127.0.0.1:7/u' }, true)).toBe( - 'https://zennotes.org/api/v1/unsplash' - ) - expect(unsplashProxyBaseUrl({ ZENNOTES_UNSPLASH_PROXY_URL: 'http://127.0.0.1:7/u', ZEN_PERF: '1' }, true)).toBe( - 'http://127.0.0.1:7/u' + expect(unsplashApiBaseUrl({ ZENNOTES_UNSPLASH_API_URL: 'http://127.0.0.1:7' }, true)).toBe('https://api.unsplash.com') + expect(unsplashApiBaseUrl({ ZENNOTES_UNSPLASH_API_URL: 'http://127.0.0.1:7', ZEN_PERF: '1' }, true)).toBe( + 'http://127.0.0.1:7' ) }) }) -describe('createUnsplashClient', () => { - it('sends a search with no cookies, a bare user agent and no language', async () => { +describe('the Unsplash key in the secure store', () => { + it('round-trips, sealed by the OS on disk', async () => { + expect(await getUnsplashAccessKeySecret()).toBeNull() + expect(await setUnsplashAccessKeySecret(` ${KEY} `)).toBe(true) + expect(await getUnsplashAccessKeySecret()).toBe(KEY) + const files = await filesUnder(machine.userData) + expect(files.map((f) => f.path)).toEqual([join(machine.userData, 'remote-workspace-secrets.json')]) + const sealed = JSON.parse(files[0]!.text) as Record<string, string> + expect(Object.keys(sealed)).toEqual(['unsplash:access-key']) + expect(files[0]!.text).not.toContain(KEY) + }) + + it('is gone once cleared, leaving the other secrets alone', async () => { + await setUnsplashAccessKeySecret(KEY) + await deleteUnsplashAccessKeySecret() + expect(await getUnsplashAccessKeySecret()).toBeNull() + const text = await readFile(join(machine.userData, 'remote-workspace-secrets.json'), 'utf8') + expect(JSON.parse(text)).toEqual({}) + // Clearing again is fine. + await expect(deleteUnsplashAccessKeySecret()).resolves.toBeUndefined() + }) + + it('is refused, and nothing written, without OS encryption', async () => { + machine.encryption = false + expect(await setUnsplashAccessKeySecret(KEY)).toBe(false) + expect(await readdir(machine.userData)).toEqual([]) + }) +}) + +describe('the desktop Unsplash client', () => { + it('never puts the key in config, prefs or any answer the renderer sees', async () => { + const fetch = vi.fn<UnsplashFetch>(async () => ok([])) + const client = createDesktopUnsplashClient(fetch, () => 'https://api.unsplash.com') + const saved = await client.save(KEY) + expect(saved).toEqual({ saved: true, check: { ok: true, remaining: 49, limit: 50 } }) + const status = await client.status() + expect(status).toEqual({ set: true, store: 'system', canStore: true }) + for (const answer of [saved, status, await client.test(), await client.search('fog', 1)]) { + expect(JSON.stringify(answer)).not.toContain(KEY) + } + // The only file is the sealed secrets file, and the key is in no file as written. + for (const file of await filesUnder(machine.userData)) expect(file.text).not.toContain(KEY) + expect(PORTABLE_PREF_KEYS.filter((k) => /unsplash/i.test(k))).toEqual([]) + }) + + it('sends the key only to Unsplash, with a bare user agent and no language', async () => { const fetch = vi.fn<UnsplashFetch>(async () => ok({ total_pages: 0, results: [] })) - const client = createUnsplashClient({ fetch, baseUrl: () => 'https://p.test' }) + const client = createDesktopUnsplashClient(fetch, () => 'https://api.unsplash.com') + await setUnsplashAccessKeySecret(KEY) expect(await client.search('fog', 1)).toMatchObject({ ok: true }) - const init = fetch.mock.calls[0]![1] + const [url, init] = fetch.mock.calls[0]! + expect(new URL(url).origin).toBe('https://api.unsplash.com') expect(init.credentials).toBe('omit') - expect(init.headers).toMatchObject({ 'user-agent': 'ZenNotes', 'accept-language': '*' }) + expect(init.headers).toMatchObject({ + authorization: `Client-ID ${KEY}`, + 'user-agent': 'ZenNotes', + 'accept-language': '*' + }) }) - it('refuses arguments that are not a search', async () => { + it('says it cannot store a key where the OS has no encryption', async () => { + machine.encryption = false const fetch = vi.fn<UnsplashFetch>() - const client = createUnsplashClient({ fetch, baseUrl: () => 'https://p.test' }) - expect(await client.search({ query: 'fog' }, 1)).toEqual({ ok: false, reason: 'unavailable', retryAt: null }) - await client.trackDownload(42) - await client.trackDownload('https://example.com/photos/a/download') + const client = createDesktopUnsplashClient(fetch, () => 'https://api.unsplash.com') + expect(await client.status()).toEqual({ set: false, store: 'system', canStore: false }) + expect(await client.save(KEY)).toEqual({ saved: false, reason: 'cannot-store' }) expect(fetch).not.toHaveBeenCalled() }) - - it('caps one run at a minute’s worth of requests, whatever the renderer asks', async () => { - let now = 0 - const fetch = vi.fn<UnsplashFetch>(async () => ok({ total_pages: 1, results: [] })) - const client = createUnsplashClient({ fetch, baseUrl: () => 'https://p.test', now: () => now }) - for (let i = 0; i < UNSPLASH_REQUESTS_PER_MINUTE; i++) await client.search(`fog ${i}`, 1) - expect(await client.search('one more', 1)).toEqual({ ok: false, reason: 'rate-limited', retryAt: 60_000 }) - expect(fetch).toHaveBeenCalledTimes(UNSPLASH_REQUESTS_PER_MINUTE) - now = 60_000 - expect(await client.search('later', 1)).toMatchObject({ ok: true }) - }) }) diff --git a/apps/desktop/src/main/unsplash.ts b/apps/desktop/src/main/unsplash.ts index d5dbb249e..cbe155a8f 100644 --- a/apps/desktop/src/main/unsplash.ts +++ b/apps/desktop/src/main/unsplash.ts @@ -3,80 +3,47 @@ * and Cloud are: the renderer's CSP keeps connect-src to the app's own * schemes, and this side decides what leaves (a search, a page number, a * download location it has checked) and how (no cookies, a bare user agent, - * no language). The Unsplash key lives on the zennotes.org proxy, never in - * the app (shared-domain unsplash.ts has the rules). - * - * The proxy keeps one hourly budget for every ZenNotes user, so a renderer bug - * that searched in a loop would spend everyone's. This side caps what one run - * of the app may ask, well above what a person typing can reach. + * no language). Requests go straight to api.unsplash.com with the person's + * own Access Key, which lives in the system's secure storage (secret-store) + * and never reaches the renderer: it hears only whether a key is set. + * shared-domain unsplash.ts has the rules and the client itself. */ import { app, net } from 'electron' -import type { UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' +import { UNSPLASH_API_BASE_URL, createUnsplashClient, type UnsplashFetch } from '@shared/unsplash' import { - UNSPLASH_PROXY_BASE_URL, - searchUnsplashThroughProxy, - trackUnsplashDownloadThroughProxy, - type UnsplashFetch, - type UnsplashProxyOptions -} from '@shared/unsplash' + canStoreSecretsSecurely, + deleteUnsplashAccessKeySecret, + getUnsplashAccessKeySecret, + setUnsplashAccessKeySecret +} from './secret-store' -/** Searches and pings one run may send in a minute. */ -export const UNSPLASH_REQUESTS_PER_MINUTE = 40 - -/** The proxy, or the one ZENNOTES_UNSPLASH_PROXY_URL names for a test run. As - * with the other overrides, a packaged app honors it only with ZEN_PERF=1. */ -export function unsplashProxyBaseUrl(env: NodeJS.ProcessEnv, isPackaged: boolean): string { - const override = env.ZENNOTES_UNSPLASH_PROXY_URL?.trim() +/** api.unsplash.com, or the stand-in ZENNOTES_UNSPLASH_API_URL names for a + * test run. As with the other overrides, a packaged app honors it only with + * ZEN_PERF=1. */ +export function unsplashApiBaseUrl(env: NodeJS.ProcessEnv, isPackaged: boolean): string { + const override = env.ZENNOTES_UNSPLASH_API_URL?.trim() if (override && (!isPackaged || env.ZEN_PERF === '1')) return override.replace(/\/+$/, '') - return UNSPLASH_PROXY_BASE_URL + return UNSPLASH_API_BASE_URL } -export function createUnsplashClient(deps: { - fetch: UnsplashFetch - baseUrl: () => string - now?: () => number -}): { - search: (query: unknown, page: unknown) => Promise<UnsplashSearchResult> - trackDownload: (downloadLocation: unknown) => Promise<void> -} { - const now = deps.now ?? Date.now - const sent: number[] = [] - /** Room for one more request, or the time the oldest one leaves the window. */ - const take = (): number | null => { - const at = now() - while (sent.length && at - sent[0]! >= 60_000) sent.shift() - if (sent.length >= UNSPLASH_REQUESTS_PER_MINUTE) return sent[0]! + 60_000 - sent.push(at) - return null - } - const options = (): UnsplashProxyOptions => ({ - baseUrl: deps.baseUrl(), - headers: { 'user-agent': 'ZenNotes', 'accept-language': '*' }, - now - }) - return { - search: async (query, page) => { - if (typeof query !== 'string') return { ok: false, reason: 'unavailable', retryAt: null } - const wait = take() - if (wait !== null) return { ok: false, reason: 'rate-limited', retryAt: wait } - return await searchUnsplashThroughProxy( - deps.fetch, - { query, page: typeof page === 'number' && Number.isFinite(page) ? page : 1 }, - options() - ) +export function createDesktopUnsplashClient(fetch: UnsplashFetch, baseUrl: () => string) { + return createUnsplashClient({ + fetch, + store: 'system', + keys: { + get: getUnsplashAccessKeySecret, + set: setUnsplashAccessKeySecret, + delete: deleteUnsplashAccessKeySecret, + canStore: canStoreSecretsSecurely }, - trackDownload: async (downloadLocation) => { - if (typeof downloadLocation !== 'string') return - if (take() !== null) return - await trackUnsplashDownloadThroughProxy(deps.fetch, downloadLocation, options()) - } - } + options: () => ({ + baseUrl: baseUrl(), + headers: { 'user-agent': 'ZenNotes', 'accept-language': '*' } + }) + }) } -const client = createUnsplashClient({ - fetch: (url, init) => net.fetch(url, init), - baseUrl: () => unsplashProxyBaseUrl(process.env, app.isPackaged) -}) - -export const searchUnsplash = client.search -export const trackUnsplashDownload = client.trackDownload +export const unsplash = createDesktopUnsplashClient( + (url, init) => net.fetch(url, init), + () => unsplashApiBaseUrl(process.env, app.isPackaged) +) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 831e6a530..a91ed2582 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -10,7 +10,12 @@ import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' -import type { UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' +import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashSearchResult +} from '@zennotes/bridge-contract/unsplash' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -269,6 +274,10 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.UNSPLASH_SEARCH, query, page), trackUnsplashDownload: (downloadLocation: string): Promise<void> => ipcRenderer.invoke(IPC.UNSPLASH_TRACK_DOWNLOAD, downloadLocation), + getUnsplashKeyStatus: (): Promise<UnsplashKeyStatus> => ipcRenderer.invoke(IPC.UNSPLASH_KEY_STATUS), + saveUnsplashKey: (key: string): Promise<UnsplashKeySaveResult> => ipcRenderer.invoke(IPC.UNSPLASH_KEY_SAVE, key), + testUnsplashKey: (): Promise<UnsplashKeyCheck> => ipcRenderer.invoke(IPC.UNSPLASH_KEY_TEST), + clearUnsplashKey: (): Promise<void> => ipcRenderer.invoke(IPC.UNSPLASH_KEY_CLEAR), checkForAppUpdatesWithUi: (): Promise<void> => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK_WITH_UI), downloadAppUpdate: (): Promise<AppUpdateState> => ipcRenderer.invoke(IPC.APP_UPDATER_DOWNLOAD), diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 65755763d..2dbe18db7 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -75,12 +75,13 @@ import { createDatabaseOps, type DatabaseVaultLayout } from '@shared/database-op import { isUnknownRouteResponse, parseServerErrorBody } from '@shared/server-error-shape' import { pastedImageFilename } from '@shared/pasted-image' import { createAbsenceAwareReader } from '@shared/remote-absence' -import { - searchUnsplashThroughProxy, - trackUnsplashDownloadThroughProxy, - type UnsplashFetch -} from '@shared/unsplash' -import type { UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' +import { createUnsplashClient } from '@shared/unsplash' +import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashSearchResult +} from '@zennotes/bridge-contract/unsplash' import type { McpClientId, McpClientStatus, @@ -616,18 +617,47 @@ async function fetchLinkMetadata(url: string): Promise<LinkMetadata> { return { url, ok: false } } -// The cover picker's Unsplash search goes straight from the browser to the -// proxy on zennotes.org, which answers any origin and gets no cookies; the -// server this client is served from takes no part (and holds no key). -const unsplashFetch: UnsplashFetch = (url, init) => fetch(url, init) +// The cover picker's Unsplash search goes straight from this browser to +// api.unsplash.com (which answers any origin) with the person's own access +// key. A browser has no system keychain to offer, so the key stays in this +// browser's storage under its own entry, read only here; the app's prefs and +// the server this client is served from never see it. +const UNSPLASH_KEY_STORAGE = 'zennotes:unsplash-access-key' -async function searchUnsplash(query: string, page: number): Promise<UnsplashSearchResult> { - return await searchUnsplashThroughProxy(unsplashFetch, { query, page }) +function browserStorage(): Storage | null { + try { + return window.localStorage + } catch { + return null + } } -async function trackUnsplashDownload(downloadLocation: string): Promise<void> { - await trackUnsplashDownloadThroughProxy(unsplashFetch, downloadLocation) -} +const unsplash = createUnsplashClient({ + fetch: (url, init) => fetch(url, init), + store: 'browser', + keys: { + get: async () => browserStorage()?.getItem(UNSPLASH_KEY_STORAGE) ?? null, + set: async (key) => { + const storage = browserStorage() + if (!storage) return false + try { + storage.setItem(UNSPLASH_KEY_STORAGE, key) + return true + } catch { + return false + } + }, + delete: async () => browserStorage()?.removeItem(UNSPLASH_KEY_STORAGE), + canStore: () => browserStorage() !== null + } +}) + +const searchUnsplash = (query: string, page: number): Promise<UnsplashSearchResult> => unsplash.search(query, page) +const trackUnsplashDownload = (downloadLocation: string): Promise<void> => unsplash.trackDownload(downloadLocation) +const getUnsplashKeyStatus = (): Promise<UnsplashKeyStatus> => unsplash.status() +const saveUnsplashKey = (key: string): Promise<UnsplashKeySaveResult> => unsplash.save(key) +const testUnsplashKey = (): Promise<UnsplashKeyCheck> => unsplash.test() +const clearUnsplashKey = (): Promise<void> => unsplash.clear() async function revealFolder(_folder: NoteFolder, _subpath: string): Promise<void> { // No OS file manager on the web. @@ -1609,6 +1639,10 @@ export const httpBridge: ZenBridge = { fetchLinkMetadata, searchUnsplash, trackUnsplashDownload, + getUnsplashKeyStatus, + saveUnsplashKey, + testUnsplashKey, + clearUnsplashKey, revealNoteTarget, revealFilePath, moveNote, diff --git a/packages/app-core/src/components/NoteCoverPicker-unsplash-key.test.ts b/packages/app-core/src/components/NoteCoverPicker-unsplash-key.test.ts new file mode 100644 index 000000000..c05cff93c --- /dev/null +++ b/packages/app-core/src/components/NoteCoverPicker-unsplash-key.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UnsplashKeyStatus, UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' +import { useStore } from '../store' +import { consumeSettingsTarget } from '../lib/settings-navigation' +import { setUnsplashKeyStatus } from '../lib/unsplash-key' +import { NoteCoverPicker, unsplashMessage } from './NoteCoverPicker' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const NOTE = 'inbox/Trip.md' +const RAW = 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?ixid=abc' + +describe('the cover picker’s Unsplash tab and the access key', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType<typeof useStore.getState> + let originalScrollIntoView: PropertyDescriptor | undefined + let search: ReturnType<typeof vi.fn<(query: string, page: number) => Promise<UnsplashSearchResult>>> + + const stubZen = (status: UnsplashKeyStatus): void => { + ;(window as unknown as { zen: unknown }).zen = { + searchUnsplash: search, + getUnsplashKeyStatus: vi.fn(async () => status), + resolveVaultAssetUrl: () => '' + } + } + + const open = async (): Promise<HTMLInputElement> => { + useStore.setState({ noteCoverPickerPath: NOTE, noteCoverPickerStart: { tab: 'unsplash', query: 'fog' } }) + await act(async () => root.render(createElement(NoteCoverPicker, { notePath: NOTE }))) + // Past the search's 400 ms pause, so a search would have gone out. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)) + }) + return document.querySelector<HTMLInputElement>('[data-note-cover-picker] input')! + } + + const press = async (input: HTMLInputElement, key: string): Promise<void> => { + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) + } + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + search = vi.fn(async () => ({ ok: false, reason: 'no-key', retryAt: null, limit: null }) as UnsplashSearchResult) + setUnsplashKeyStatus(null) + consumeSettingsTarget() + useStore.setState({ assetFiles: [], noteContents: {}, settingsOpen: false }) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + if (originalScrollIntoView) Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + else delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView + delete (window as unknown as { zen?: unknown }).zen + vi.restoreAllMocks() + useStore.setState(originalState, true) + }) + + it('without a key, says what the key is for and how to get one, and sends no search', async () => { + stubZen({ set: false, store: 'system', canStore: true }) + await open() + const panel = document.querySelector('[data-cover-picker-setup]') + expect(panel).not.toBeNull() + expect(panel!.textContent).toContain('your searches go from this computer straight to Unsplash') + expect(panel!.textContent).toContain('create a New Application') + expect(panel!.textContent).toContain('Copy its Access Key') + expect(panel!.textContent).toContain('Paste it into Settings') + expect([...document.querySelectorAll('[data-cover-picker-action]')].map((b) => b.textContent)).toEqual([ + 'Open Settings', + 'unsplash.com/developers' + ]) + expect(document.querySelectorAll('[data-cover-choice-idx]')).toHaveLength(0) + expect(search).not.toHaveBeenCalled() + }) + + it('Enter opens Settings on the key field and closes the picker', async () => { + stubZen({ set: false, store: 'system', canStore: true }) + const input = await open() + await press(input, 'Enter') + expect(useStore.getState().settingsOpen).toBe(true) + expect(useStore.getState().noteCoverPickerPath).toBeNull() + expect(consumeSettingsTarget()).toBe('unsplash') + }) + + it('↓ then Enter opens unsplash.com/developers, from the keyboard alone', async () => { + stubZen({ set: false, store: 'browser', canStore: true }) + const openPage = vi.spyOn(window, 'open').mockReturnValue(null) + const input = await open() + expect(document.querySelector('[data-cover-picker-setup]')!.textContent).toContain('from this browser') + await press(input, 'ArrowDown') + expect(document.querySelector('[data-cover-picker-action="developers"]')!.getAttribute('aria-current')).toBe('true') + expect(document.body.textContent).toContain('open unsplash.com') + await press(input, 'Enter') + expect(openPage).toHaveBeenCalledWith('https://unsplash.com/developers', '_blank', 'noopener,noreferrer') + expect(useStore.getState().settingsOpen).toBe(false) + }) + + it('a refused key says so and offers Change key', async () => { + stubZen({ set: true, store: 'system', canStore: true }) + search.mockResolvedValue({ ok: false, reason: 'invalid-key', retryAt: null, limit: null }) + const input = await open() + expect(search).toHaveBeenCalledWith('fog', 1) + expect(document.querySelector('[data-cover-picker-message]')!.textContent).toContain( + 'Unsplash did not accept your access key' + ) + expect([...document.querySelectorAll('[data-cover-picker-action]')].map((b) => b.textContent)).toEqual(['Change key']) + await press(input, 'Enter') + expect(consumeSettingsTarget()).toBe('unsplash') + }) + + it('with a key, searches and shows the photos as before', async () => { + stubZen({ set: true, store: 'system', canStore: true }) + search.mockResolvedValue({ + ok: true, + page: { + query: 'fog', + page: 1, + totalPages: 1, + photos: [ + { + id: 'a1', + width: 6000, + height: 4000, + color: null, + alt: null, + rawUrl: RAW, + thumbUrl: `${RAW}&w=400`, + photographer: { name: 'Jane Doe', profileUrl: 'https://unsplash.com/@janedoe' }, + downloadLocation: 'https://api.unsplash.com/photos/a1/download' + } + ] + } + }) + await open() + expect(document.querySelector('[data-cover-picker-setup]')).toBeNull() + expect(document.querySelectorAll('[data-cover-choice-idx]')).toHaveLength(1) + expect(document.querySelector('[data-cover-picker-credit]')!.textContent).toBe('Photo by Jane Doe on Unsplash') + }) +}) + +describe('unsplashMessage', () => { + const failedWith = (reason: 'rate-limited' | 'offline', limit: number | null = null, retryAt: number | null = null) => + unsplashMessage({ status: 'failed', query: 'fog', reason, retryAt, limit }, 0) + + it('explains a spent hour with the new-app allowance, or the allowance Unsplash reported', () => { + expect(failedWith('rate-limited')).toBe( + 'Your Unsplash app has used this hour’s requests: a new Unsplash app allows 50 an hour until Unsplash approves it for production. The Covers tab still works.' + ) + expect(failedWith('rate-limited', 50)).toContain('a new Unsplash app allows 50 an hour') + expect(failedWith('rate-limited', 5000)).toBe('Your Unsplash app has used its 5,000 requests for this hour. The Covers tab still works.') + }) + + it('names the network plainly', () => { + expect(failedWith('offline')).toBe('Unsplash could not be reached. Check your connection; the Covers tab works offline.') + }) +}) diff --git a/packages/app-core/src/components/NoteCoverPicker.tsx b/packages/app-core/src/components/NoteCoverPicker.tsx index 02c2a0a63..1f5204ef9 100644 --- a/packages/app-core/src/components/NoteCoverPicker.tsx +++ b/packages/app-core/src/components/NoteCoverPicker.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { KeyboardEvent as ReactKeyboardEvent } from 'react' import { readNoteCover } from '@shared/note-cover' -import { UNSPLASH_HOME_URL, unsplashReferralUrl } from '@shared/unsplash' -import type { UnsplashPhoto } from '@zennotes/bridge-contract/unsplash' +import { UNSPLASH_DEVELOPERS_URL, UNSPLASH_HOME_URL, unsplashReferralUrl } from '@shared/unsplash' +import type { UnsplashKeyStore, UnsplashPhoto } from '@zennotes/bridge-contract/unsplash' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' @@ -20,6 +20,13 @@ import { setCoverTarget } from '../lib/note-cover-actions' import { unsplashSearchAvailable, useUnsplashSearch, type UnsplashSearchState } from '../lib/unsplash-search' +import { + openUnsplashKeySettings, + refreshUnsplashKeyStatus, + unsplashKeySupported, + unsplashRateLimitSentence, + useUnsplashKeyStatus +} from '../lib/unsplash-key' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -44,28 +51,51 @@ type PickerTab = 'covers' | 'unsplash' const UNSPLASH_ROW = 4 /** What the Unsplash tab says instead of tiles, or null when it has tiles. */ -function unsplashMessage(state: UnsplashSearchState, count: number): string | null { +export function unsplashMessage(state: UnsplashSearchState, count: number): string | null { switch (state.status) { case 'idle': - return 'Search free photos from Unsplash. Searches go through zennotes.org.' + return 'Search free photos from Unsplash, with your own access key.' case 'loading': return 'Searching Unsplash…' case 'failed': - if (state.reason === 'offline') { - return 'Unsplash could not be reached. Check your connection; the Covers tab works offline.' - } - if (state.reason === 'rate-limited') { - const until = state.retryAt - ? ` until ${new Date(state.retryAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` - : ' for now' - return `Unsplash search is resting${until}: its hourly limit is shared by everyone using ZenNotes. The Covers tab still works.` + switch (state.reason) { + case 'offline': + return 'Unsplash could not be reached. Check your connection; the Covers tab works offline.' + case 'invalid-key': + return 'Unsplash did not accept your access key. It may have been revoked, or part of it was lost when it was pasted.' + case 'rate-limited': + if (state.retryAt) { + const at = new Date(state.retryAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + return `Unsplash search is pausing until ${at}: ZenNotes sends at most 40 requests a minute. The Covers tab still works.` + } + return `${unsplashRateLimitSentence(state.limit)} The Covers tab still works.` + case 'no-key': + return 'Unsplash search needs an access key of your own.' + default: + return 'Unsplash search is not available right now. The Covers tab still works.' } - return 'Unsplash search is not available right now. The Covers tab still works.' case 'ready': return count === 0 ? `No Unsplash photos match “${state.query}”.` : null } } +/** Something the Unsplash tab offers in place of photos (Open Settings, the + * developers page, Change key). The field keeps focus: ↑/↓ choose, Enter + * runs it. */ +interface PanelAction { + id: string + label: string + /** The footer's word for Enter while this action is chosen. */ + hint: string + run: () => void +} + +function openWebPage(url: string): void { + // Desktop main hands a window.open to the default browser; the web client + // opens a tab. + window.open(url, '_blank', 'noopener,noreferrer') +} + /** * The cover picker (`Space l c`, `:cover`, "Change cover"). One search field * over everything a cover can come from: an https link typed into it, the @@ -98,7 +128,14 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element ) const [query, setQuery] = useState(() => start?.query ?? '') const [active, setActive] = useState(0) - const { state: unsplash, loadMore } = useUnsplashSearch(query, tab === 'unsplash') + // The tab searches with the person's own key. Until one is set it shows how + // to get one, and sends nothing; the status is known before anything is. + const keyStatus = useUnsplashKeyStatus() + const keySupported = unsplashKeySupported() + const needsKey = keySupported && keyStatus?.set === false + const keyReady = !keySupported || keyStatus?.set === true + const { state: unsplash, loadMore } = useUnsplashSearch(query, tab === 'unsplash' && keyReady) + const [panelActive, setPanelActive] = useState(0) const [busy, setBusy] = useState(false) const [dropActive, setDropActive] = useState(false) const inputRef = useRef<HTMLInputElement | null>(null) @@ -170,6 +207,37 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element const choices = useMemo(() => sections.flatMap((s) => s.choices), [sections]) const unsplashQuery = unsplash.status === 'idle' ? '' : unsplash.query + const failure = unsplash.status === 'failed' ? unsplash.reason : null + + // A search that finds no key (cleared in another window) or a refused one + // asks the host again, so the tab shows the setup panel when it should. + useEffect(() => { + if (failure === 'no-key' || failure === 'invalid-key') void refreshUnsplashKeyStatus() + }, [failure]) + + const openKeySettings = (): void => { + // Settings takes the keyboard; the editor does not get it back first. + setPickerPath(null) + openUnsplashKeySettings() + } + const panelActions: PanelAction[] = + tab !== 'unsplash' || choices.length > 0 + ? [] + : needsKey || failure === 'no-key' + ? [ + { id: 'settings', label: 'Open Settings', hint: 'open settings', run: openKeySettings }, + { + id: 'developers', + label: 'unsplash.com/developers', + hint: 'open unsplash.com', + run: () => openWebPage(UNSPLASH_DEVELOPERS_URL) + } + ] + : failure === 'invalid-key' + ? [{ id: 'settings', label: 'Change key', hint: 'change key', run: openKeySettings }] + : [] + const panelActionsKey = panelActions.map((a) => a.id).join(' ') + useEffect(() => setPanelActive(0), [panelActionsKey]) useEffect(() => { inputRef.current?.focus() @@ -248,6 +316,14 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element e.preventDefault() e.stopPropagation() switchTab() + } else if (panelActions.length > 0 && (isPaletteNextKey(e) || isPalettePreviousKey(e))) { + e.preventDefault() + e.stopPropagation() + const step = isPaletteNextKey(e) ? 1 : -1 + setPanelActive((a) => Math.min(panelActions.length - 1, Math.max(0, a + step))) + } else if (panelActions.length > 0 && e.key === 'Enter') { + e.preventDefault() + panelActions[Math.min(panelActive, panelActions.length - 1)]?.run() } else if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() @@ -385,9 +461,23 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element if (tab === 'unsplash' && el.scrollTop + el.clientHeight >= el.scrollHeight - 80) loadMore() }} > - {sections.length === 0 ? ( + {sections.length === 0 && tab === 'unsplash' && (needsKey || failure === 'no-key') ? ( + <UnsplashKeySetupPanel + store={keyStatus?.store} + actions={panelActions} + active={panelActive} + onHover={setPanelActive} + /> + ) : sections.length === 0 ? ( <div data-cover-picker-message="" className="px-6 py-8 text-center text-sm text-ink-400"> - {tab === 'unsplash' ? unsplashMessage(unsplash, choices.length) : 'No covers match.'} + {tab === 'unsplash' + ? keyStatus == null && keySupported + ? null + : unsplashMessage(unsplash, choices.length) + : 'No covers match.'} + {panelActions.length > 0 && ( + <PanelActionButtons actions={panelActions} active={panelActive} onHover={setPanelActive} /> + )} </div> ) : ( sections.map((section) => ( @@ -441,7 +531,10 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element <kbd className="rounded bg-paper-200 px-1">↑↓←→</kbd> move </span> <span> - <kbd className="rounded bg-paper-200 px-1">↵</kbd> set cover + <kbd className="rounded bg-paper-200 px-1">↵</kbd>{' '} + {panelActions.length > 0 + ? panelActions[Math.min(panelActive, panelActions.length - 1)]?.hint + : 'set cover'} </span> <span> <kbd className="rounded bg-paper-200 px-1">esc</kbd> close @@ -452,6 +545,70 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element ) } +/** The Unsplash tab without a key: what the key is for and how to get one, + * with Open Settings (the field) and the page where an app is made. */ +function UnsplashKeySetupPanel({ + store, + actions, + active, + onHover +}: { + store: UnsplashKeyStore | undefined + actions: PanelAction[] + active: number + onHover: (index: number) => void +}): JSX.Element { + return ( + <div data-cover-picker-setup="" className="mx-auto max-w-xl px-6 py-7 text-sm leading-6 text-ink-600"> + <p className="font-medium text-ink-900">Unsplash search needs an access key of your own</p> + <p className="mt-1 text-ink-500"> + ZenNotes searches Unsplash with a free key you create, so your searches go from{' '} + {store === 'browser' ? 'this browser' : 'this computer'} straight to Unsplash. It takes a minute: + </p> + <ol className="mt-3 list-decimal space-y-1 pl-5"> + <li>At unsplash.com/developers, open Your apps and create a New Application (a free Unsplash account is all it needs).</li> + <li>Copy its Access Key. The Secret Key is not needed.</li> + <li>Paste it into Settings → Typography → Covers → Unsplash access key.</li> + </ol> + <PanelActionButtons actions={actions} active={active} onHover={onHover} /> + <p className="mt-3 text-xs text-ink-400"> + A new Unsplash app allows 50 requests an hour, plenty for picking covers. The Covers tab needs no key. + </p> + </div> + ) +} + +function PanelActionButtons({ + actions, + active, + onHover +}: { + actions: PanelAction[] + active: number + onHover: (index: number) => void +}): JSX.Element { + return ( + <div className="mt-4 flex flex-wrap justify-center gap-2"> + {actions.map((action, i) => ( + <Button + key={action.id} + data-cover-picker-action={action.id} + aria-current={i === active ? 'true' : undefined} + // The field keeps focus, as with the tiles: ↑/↓ choose, Enter runs. + tabIndex={-1} + variant={i === 0 ? 'primary' : 'secondary'} + className={i === active ? 'ring-2 ring-accent' : ''} + onMouseDown={(e) => e.preventDefault()} + onMouseMove={() => onHover(i)} + onClick={action.run} + > + {action.label} + </Button> + ))} + </div> + ) +} + /** The footer's credit for the selected photo, both links the way Unsplash * asks: the photographer's profile and Unsplash, with the referral tags. */ function UnsplashCreditLine({ choice }: { choice: Choice | undefined }): JSX.Element { diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 96f0f8fbf..4821381e1 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -176,6 +176,8 @@ import { TextReplacementsSettings } from "./TextReplacementsSettings"; import { CloudSettings } from "./CloudSettings"; import { ErrorDetails } from "./ErrorDetails"; import { consumeSettingsTarget } from "../lib/settings-navigation"; +import { unsplashKeySupported } from "../lib/unsplash-key"; +import { UnsplashKeySetting } from "./UnsplashKeySetting"; import { normalizeApplicationSchemes } from "@shared/application-links"; type SettingsCategoryId = @@ -1431,6 +1433,11 @@ export function SettingsModal(): JSX.Element { const opensOnCloudVault = supportsCloudSync && initialSettingsTarget === "cloud"; const [cloudVaultReveal, setCloudVaultReveal] = useState(opensOnCloudVault); + // The cover picker's Unsplash tab sends the person here to paste a key, so + // the keyboard lands in the field itself rather than the settings search. + const opensOnUnsplashKey = + initialSettingsTarget === "unsplash" && unsplashKeySupported(); + const unsplashKeyInputRef = useRef<HTMLInputElement>(null); const settleCloudVaultReveal = useCallback(() => setCloudVaultReveal(false), []); // Settings draws its own backdrop and panel, so it never got the focus // handling the shared Modal shell gives every other dialog: the keyboard @@ -1441,7 +1448,11 @@ export function SettingsModal(): JSX.Element { // search is not what was asked for, so the panel holds focus instead. useDialogFocus( ref, - isTouchPrimaryDevice() || opensOnCloudVault ? ref : navSearchRef, + isTouchPrimaryDevice() || opensOnCloudVault + ? ref + : opensOnUnsplashKey + ? unsplashKeyInputRef + : navSearchRef, ); const settingsSearchHighlightTimerRef = useRef<number | null>(null); const [activeCategory, setActiveCategory] = useState<SettingsCategoryId>( @@ -1450,7 +1461,9 @@ export function SettingsModal(): JSX.Element { ? "editor" : initialSettingsTarget === "periodic-notes" ? "vault" - : initialSettingsTarget ?? "appearance", + : initialSettingsTarget === "unsplash" + ? "typography" + : initialSettingsTarget ?? "appearance", ); // Per-category active sub-tab (dense categories split their content into sub-tabs). const [activeSubTabByCategory, setActiveSubTabByCategory] = useState< @@ -1559,6 +1572,12 @@ export function SettingsModal(): JSX.Element { }); }, []); + useEffect(() => { + if (opensOnUnsplashKey) jumpToSettingsSearchTarget("unsplash-access-key"); + // Once, for the page another surface asked for. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const leaderKeyHintsTargetId = vimMode ? "leader-key-hints" : "vim-mode"; const wrappedLineMotionsTargetId = vimMode ? "wrapped-line-motions" @@ -3769,7 +3788,7 @@ export function SettingsModal(): JSX.Element { id: "typography", title: "Typography", description: - "Fonts, line height, reading width, alignment, and line numbers.", + "Fonts, line height, reading width, alignment, line numbers, and note covers.", keywords: [ "font", "size", @@ -3777,6 +3796,7 @@ export function SettingsModal(): JSX.Element { "width", "alignment", "numbers", + "cover", ], searchItems: [ { @@ -3836,6 +3856,21 @@ export function SettingsModal(): JSX.Element { "Where a note's cover image shows: scrolling with the text, pinned above it, or hidden.", keywords: ["cover", "banner", "header", "image", "pinned", "hidden"], }, + { + id: "unsplash-access-key", + title: "Unsplash access key", + description: + "Your own Unsplash key, which the cover picker's Unsplash tab searches with.", + keywords: [ + "unsplash", + "cover", + "api key", + "access key", + "photo", + "banner", + ], + available: unsplashKeySupported(), + }, { id: "line-numbers", title: "Line numbers", @@ -3941,18 +3976,6 @@ export function SettingsModal(): JSX.Element { ]} onChange={(next) => setContentAlign(next)} /> - <SegmentedRow - label="Note covers" - description="Where a note's cover image (its banner) shows: on top of the text, scrolling away with it; pinned above the editor and Read view; or hidden. Hiding it leaves the note's frontmatter alone." - value={noteCoverPlacement} - settingId="note-covers" - options={[ - { value: "scroll", label: "Scroll with note" }, - { value: "pinned", label: "Pinned" }, - { value: "hidden", label: "Hidden" }, - ]} - onChange={(next) => setNoteCoverPlacement(next)} - /> <SegmentedRow label="Line numbers" description="Show editor gutter numbers. Relative uses Vim-style numbering with the current line shown normally." @@ -3979,6 +4002,27 @@ export function SettingsModal(): JSX.Element { /> )} </Section> + + <Section + title="Covers" + description="How a note's cover shows, and the Unsplash key the cover picker searches with." + > + <SegmentedRow + label="Note covers" + description="Where a note's cover image (its banner) shows: on top of the text, scrolling away with it; pinned above the editor and Read view; or hidden. Hiding it leaves the note's frontmatter alone." + value={noteCoverPlacement} + settingId="note-covers" + options={[ + { value: "scroll", label: "Scroll with note" }, + { value: "pinned", label: "Pinned" }, + { value: "hidden", label: "Hidden" }, + ]} + onChange={(next) => setNoteCoverPlacement(next)} + /> + {unsplashKeySupported() && ( + <UnsplashKeySetting inputRef={unsplashKeyInputRef} /> + )} + </Section> </div> ), }, diff --git a/packages/app-core/src/components/UnsplashKeySetting.tsx b/packages/app-core/src/components/UnsplashKeySetting.tsx new file mode 100644 index 000000000..3c34029e0 --- /dev/null +++ b/packages/app-core/src/components/UnsplashKeySetting.tsx @@ -0,0 +1,149 @@ +import { useRef, useState } from 'react' +import type { RefObject } from 'react' +import { UNSPLASH_DEVELOPERS_URL } from '@shared/unsplash' +import { isImeComposing } from '../lib/ime' +import { + refreshUnsplashKeyStatus, + unsplashKeyCheckMessage, + unsplashKeyPlace, + unsplashKeyRefusalMessage, + useUnsplashKeyStatus, + type UnsplashKeyMessage +} from '../lib/unsplash-key' +import { Button } from './ui/Button' + +const TONE_CLASS: Record<UnsplashKeyMessage['tone'], string> = { + ok: 'text-ink-700', + warn: 'text-ink-700', + error: 'text-[color:rgb(var(--z-red))]' +} + +/** + * Settings → Typography → Covers → Unsplash access key. The cover picker's + * Unsplash tab searches with a key of the person's own; this is where it + * goes in (Save checks it with one request first), gets tested, and comes + * out again. The field is write-only: the host keeps the key and this page + * hears only whether there is one, so a saved key is never drawn back. + */ +export function UnsplashKeySetting({ inputRef }: { inputRef?: RefObject<HTMLInputElement> }): JSX.Element { + const status = useUnsplashKeyStatus() + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState<string | null>(null) + const [message, setMessage] = useState<UnsplashKeyMessage | null>(null) + const ownRef = useRef<HTMLInputElement>(null) + const fieldRef = inputRef ?? ownRef + const place = unsplashKeyPlace(status?.store) + const linux = /linux/i.test(navigator.userAgent) + + // The buttons stay enabled while a request is out (a press then does + // nothing): a focused button that disabled itself would drop the keyboard + // to the page. + const run = async (label: string, task: () => Promise<UnsplashKeyMessage>): Promise<void> => { + if (busy) return + setBusy(label) + try { + setMessage(await task()) + } catch (err) { + setMessage({ tone: 'error', text: `Something went wrong: ${(err as Error).message}` }) + } finally { + await refreshUnsplashKeyStatus() + setBusy(null) + // Clear disables Test and Clear, and a save empties the field so Save + // goes too: when the button that had the keyboard is gone, the field + // takes it. + requestAnimationFrame(() => { + const active = document.activeElement + if (!active || active === document.body || (active as HTMLButtonElement).disabled) fieldRef.current?.focus() + }) + } + } + + const save = (): void => { + const pasted = draft.trim() + const saveKey = window.zen.saveUnsplashKey + if (!pasted || !saveKey) return + void run('Asking Unsplash…', async () => { + const outcome = await saveKey(pasted) + if (!outcome.saved) return unsplashKeyRefusalMessage(outcome.reason, status?.store, linux) + setDraft('') + return unsplashKeyCheckMessage(outcome.check, 'save') + }) + } + + const test = (): void => { + const testKey = window.zen.testUnsplashKey + if (testKey) void run('Asking Unsplash…', async () => unsplashKeyCheckMessage(await testKey(), 'test')) + } + + const clear = (): void => { + const clearKey = window.zen.clearUnsplashKey + if (!clearKey) return + void run('Clearing…', async () => { + await clearKey() + return { tone: 'ok', text: 'Cleared. The Unsplash tab asks for a key again.' } + }) + } + + const statusText: UnsplashKeyMessage = + status == null + ? { tone: 'ok', text: 'Checking…' } + : status.set + ? { tone: 'ok', text: `A key is saved in ${place}.` } + : status.canStore + ? { tone: 'ok', text: 'No key yet.' } + : unsplashKeyRefusalMessage('cannot-store', status.store, linux) + const shown = message ?? statusText + + return ( + <div data-settings-search-id="unsplash-access-key" aria-busy={busy !== null} className="space-y-3 px-5 py-4"> + <div> + <div className="text-sm font-medium text-ink-900">Unsplash access key</div> + <div className="mt-1 text-xs leading-5 text-ink-500"> + The cover picker’s Unsplash tab searches with a free key of your own. At{' '} + <a href={UNSPLASH_DEVELOPERS_URL} target="_blank" rel="noreferrer" className="text-accent hover:underline"> + unsplash.com/developers + </a> + , open Your apps and create a New Application, then copy its Access Key and paste it here. ZenNotes keeps + it in {place} and sends it only to api.unsplash.com. A new Unsplash app allows 50 requests an hour. + </div> + </div> + <div className="flex flex-wrap items-center gap-2"> + <input + ref={fieldRef} + type="password" + value={draft} + aria-label="Unsplash access key" + placeholder={status?.set ? 'Saved. Paste a new key to replace it' : 'Paste your Access Key'} + autoComplete="off" + spellCheck={false} + data-1p-ignore="" + data-lpignore="true" + onChange={(e) => setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key !== 'Enter' || isImeComposing(e)) return + e.preventDefault() + save() + }} + className="min-w-0 flex-1 basis-64 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45" + /> + <Button variant="primary" disabled={!draft.trim()} onClick={save}> + Save + </Button> + <Button disabled={!status?.set} onClick={test}> + Test + </Button> + <Button variant="ghost" disabled={!status?.set} onClick={clear}> + Clear + </Button> + </div> + <div + data-unsplash-key-message="" + role="status" + aria-live="polite" + className={`text-xs leading-5 ${TONE_CLASS[shown.tone]}`} + > + {busy ?? shown.text} + </div> + </div> + ) +} diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 3a96e81af..28eee8407 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -53,6 +53,7 @@ import { startCoverReposition } from './note-cover-actions' import { unsplashSearchAvailable } from './unsplash-search' +import { openUnsplashKeySettings, unsplashKeySupported } from './unsplash-key' import { csvPathFromDatabaseTab } from '@shared/databases' import { canRenameVault, renameVaultWithPrompt } from './rename-vault' import { promptImageWidth } from './image-resize' @@ -477,6 +478,18 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma openUnsplashCoverPicker() } }, + { + // Settings, in the field: the cover picker's Unsplash tab searches with + // the person's own key. + id: 'note.cover.unsplashKey', + title: 'Set Unsplash Access Key…', + category: 'Note', + keywords: 'unsplash api key access cover banner photo settings', + when: () => unsplashKeySupported(), + run: () => { + openUnsplashKeySettings() + } + }, { id: 'note.cover.paste', unavailableReason: () => 'A cover needs an open note', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 2ba413e15..cf5b801a9 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -149,7 +149,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Use an Unsplash photo as a cover', body: - 'The cover picker has a second tab, Unsplash, that searches Unsplash’s free photos. Press Tab in the picker to switch between Covers and Unsplash (what you typed comes along), press `Space l u` in Vim mode, run “Add Cover from Unsplash…” from the command palette, or type `:cover unsplash mountains` to open the tab on a search. Photos appear as you type; move with the arrow keys (or Ctrl+N/P, Ctrl+J/K) and press Enter to make the selected one the cover. The line under the grid credits the selected photo’s photographer, with links to their profile and to Unsplash. Unsplash’s rules keep its photos on Unsplash, so unlike a gradient or a gallery photo the picture is not copied into `assets/`: the note links to it, `banner: "https://images.unsplash.com/…"`, with the credit on the two lines under it, `banner_credit` (the photographer) and `banner_credit_url` (their Unsplash profile). Hover the cover to see “Photo by … on Unsplash” with both links; a published note’s page always shows it. The picture needs a connection to load. One you have seen before usually still shows offline; otherwise the cover says it could not load the picture from images.unsplash.com. To keep a photo for offline use, download it from unsplash.com and drop it on the picker. Searches go through zennotes.org, which holds ZenNotes’ Unsplash key and shares one hourly allowance among everyone, so when that runs out the tab says when to try again; the Covers tab never goes online. Choosing another cover or removing this one takes the credit away with it.' + 'The cover picker has a second tab, Unsplash, that searches Unsplash’s free photos with an access key of your own. The first time, the tab says how to get one: at unsplash.com/developers, open Your apps, create a New Application (a free Unsplash account is enough) and copy its Access Key; then press Enter on Open Settings and paste the key into Settings → Typography → Covers → Unsplash access key (“Set Unsplash Access Key…” in the command palette goes there too). Save checks the key with one request to Unsplash and keeps it unless Unsplash refuses it, Test asks again and says how many of the hour’s requests are left, and Clear removes it. The desktop app keeps the key in your system’s secure storage and the web client in your browser, never in `config.toml` or the vault, and sends it only to api.unsplash.com: searches go from your machine straight to Unsplash, and zennotes.org takes no part. A new Unsplash app allows 50 requests an hour until Unsplash approves it for production; when they run out the tab says so, and if Unsplash stops accepting the key it offers Change key. Press Tab in the picker to switch between Covers and Unsplash (what you typed comes along), press `Space l u` in Vim mode, run “Add Cover from Unsplash…” from the command palette, or type `:cover unsplash mountains` to open the tab on a search. Photos appear as you type; move with the arrow keys (or Ctrl+N/P, Ctrl+J/K) and press Enter to make the selected one the cover. The line under the grid credits the selected photo’s photographer, with links to their profile and to Unsplash. Unsplash’s rules keep its photos on Unsplash, so unlike a gradient or a gallery photo the picture is not copied into `assets/`: the note links to it, `banner: "https://images.unsplash.com/…"`, with the credit on the two lines under it, `banner_credit` (the photographer) and `banner_credit_url` (their Unsplash profile). Hover the cover to see “Photo by … on Unsplash” with both links; a published note’s page always shows it. The picture needs a connection to load. One you have seen before usually still shows offline; otherwise the cover says it could not load the picture from images.unsplash.com. To keep a photo for offline use, download it from unsplash.com and drop it on the picker. The Covers tab never goes online. Choosing another cover or removing this one takes the credit away with it.' }, { title: 'Give a vault a name of its own', diff --git a/packages/app-core/src/lib/settings-navigation.ts b/packages/app-core/src/lib/settings-navigation.ts index 0012fc67a..14d46f0c5 100644 --- a/packages/app-core/src/lib/settings-navigation.ts +++ b/packages/app-core/src/lib/settings-navigation.ts @@ -1,15 +1,18 @@ /** Settings pages other surfaces can open directly: the Cloud page from the * status bar, Space r and the palette (it lands on This vault, where what * they reported waits), the Keymaps page from `:unbind` when the action id - * is missing or unknown, the CLI page from Open CLI Settings, and Periodic - * notes from the phones' Calendar entry while daily notes are off. */ + * is missing or unknown, the CLI page from Open CLI Settings, Periodic + * notes from the phones' Calendar entry while daily notes are off, and the + * Unsplash access key field from the cover picker's Unsplash tab (the + * keyboard lands in the field). */ export type SettingsNavigationTarget = | "cloud" | "keymaps" | "external-links" | "about" | "cli" - | "periodic-notes"; + | "periodic-notes" + | "unsplash"; let pendingSettingsTarget: SettingsNavigationTarget | null = null; diff --git a/packages/app-core/src/lib/unsplash-key.test.ts b/packages/app-core/src/lib/unsplash-key.test.ts new file mode 100644 index 000000000..65598f170 --- /dev/null +++ b/packages/app-core/src/lib/unsplash-key.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { unsplashKeyCheckMessage, unsplashKeyPlace, unsplashKeyRefusalMessage } from './unsplash-key' + +describe('what Settings says about the Unsplash key', () => { + it('reports a working key with what is left of the hour', () => { + expect(unsplashKeyCheckMessage({ ok: true, remaining: 49, limit: 50 }, 'save')).toEqual({ + tone: 'ok', + text: 'Saved. Unsplash accepted the key: 49 of 50 requests left this hour.' + }) + expect(unsplashKeyCheckMessage({ ok: true, remaining: null, limit: null }, 'test')).toEqual({ + tone: 'ok', + text: 'The key works.' + }) + }) + + it('tells a refused key, a spent hour and no connection apart', () => { + expect(unsplashKeyCheckMessage({ ok: false, reason: 'invalid-key', limit: null }, 'test')).toMatchObject({ + tone: 'error', + text: expect.stringContaining('Unsplash did not accept this key') + }) + expect(unsplashKeyCheckMessage({ ok: false, reason: 'rate-limited', limit: 50 }, 'test').text).toBe( + 'Your Unsplash app has used this hour’s requests: a new Unsplash app allows 50 an hour until Unsplash approves it for production.' + ) + expect(unsplashKeyCheckMessage({ ok: false, reason: 'rate-limited', limit: null }, 'save').text).toMatch(/^Saved\. /) + expect(unsplashKeyCheckMessage({ ok: false, reason: 'offline', limit: null }, 'save').text).toBe( + 'Saved, but Unsplash could not be reached to check it. The Unsplash tab says so if Unsplash refuses it.' + ) + }) + + it('says why a pasted key was not saved', () => { + expect(unsplashKeyRefusalMessage('invalid-key', 'system').text).toContain('not the Secret Key') + expect(unsplashKeyRefusalMessage('malformed', 'system').text).toContain('does not look like an Unsplash Access Key') + expect(unsplashKeyRefusalMessage('cannot-store', 'system', true).text).toContain('--password-store=gnome-libsecret') + expect(unsplashKeyRefusalMessage('cannot-store', 'system', false).text).not.toContain('--password-store') + expect(unsplashKeyRefusalMessage('cannot-store', 'browser').text).toContain('This browser would not keep the key') + }) + + it('names where the key is kept', () => { + expect(unsplashKeyPlace('system')).toBe('your system’s secure storage') + expect(unsplashKeyPlace('browser')).toBe('this browser') + }) +}) diff --git a/packages/app-core/src/lib/unsplash-key.ts b/packages/app-core/src/lib/unsplash-key.ts new file mode 100644 index 000000000..82fac445d --- /dev/null +++ b/packages/app-core/src/lib/unsplash-key.ts @@ -0,0 +1,152 @@ +/** + * Whether an Unsplash access key is set, shared by the cover picker and + * Settings. The renderer never holds the key: the host keeps it (desktop + * main in the system's secure storage, the web client in its browser) and + * answers only whether there is one. This keeps that answer, in memory and + * nowhere else, so a picker opened again knows at once. + */ +import { useEffect, useSyncExternalStore } from 'react' +import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashKeyStore +} from '@zennotes/bridge-contract/unsplash' +import { UNSPLASH_DEMO_HOURLY_LIMIT } from '@shared/unsplash' +import { useStore } from '../store' +import { requestSettingsTarget } from './settings-navigation' + +let status: UnsplashKeyStatus | null = null +let asking: Promise<void> | null = null +const listeners = new Set<() => void>() + +/** Whether this host takes an Unsplash key (the desktop app and the web + * client; the phone shells have no Unsplash tab). */ +export function unsplashKeySupported(): boolean { + return typeof window.zen?.getUnsplashKeyStatus === 'function' +} + +export function setUnsplashKeyStatus(next: UnsplashKeyStatus | null): void { + status = next + for (const listener of listeners) listener() +} + +/** Ask the host again: on opening, and after a search says there is no key. */ +export function refreshUnsplashKeyStatus(): Promise<void> { + const get = window.zen?.getUnsplashKeyStatus + if (!get) return Promise.resolve() + asking ??= get() + .then(setUnsplashKeyStatus, () => {}) + .finally(() => { + asking = null + }) + return asking +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +const snapshot = (): UnsplashKeyStatus | null => status + +/** The key's status, null until the host first answers. */ +export function useUnsplashKeyStatus(): UnsplashKeyStatus | null { + const current = useSyncExternalStore(subscribe, snapshot, snapshot) + useEffect(() => { + if (unsplashKeySupported()) void refreshUnsplashKeyStatus() + }, []) + return current +} + +/** Settings, landing on the Unsplash access key field (the picker's setup + * panel and Change key, the palette). */ +export function openUnsplashKeySettings(): void { + requestSettingsTarget('unsplash') + useStore.getState().setSettingsOpen(true) +} + +export function unsplashKeyPlace(store: UnsplashKeyStore | undefined): string { + return store === 'browser' ? 'this browser' : 'your system’s secure storage' +} + +/** What a spent allowance means: a new Unsplash app gets 50 requests an + * hour until Unsplash approves it for production. */ +export function unsplashRateLimitSentence(limit: number | null): string { + return limit && limit > UNSPLASH_DEMO_HOURLY_LIMIT + ? `Your Unsplash app has used its ${limit.toLocaleString()} requests for this hour.` + : `Your Unsplash app has used this hour’s requests: a new Unsplash app allows ${UNSPLASH_DEMO_HOURLY_LIMIT} an hour until Unsplash approves it for production.` +} + +export interface UnsplashKeyMessage { + tone: 'ok' | 'warn' | 'error' + text: string +} + +function allowanceLeft(check: Extract<UnsplashKeyCheck, { ok: true }>): string { + return check.remaining != null && check.limit != null + ? `: ${check.remaining.toLocaleString()} of ${check.limit.toLocaleString()} requests left this hour` + : '' +} + +/** What Settings says after Test, or after a save that kept the key. */ +export function unsplashKeyCheckMessage(check: UnsplashKeyCheck, after: 'save' | 'test'): UnsplashKeyMessage { + const saved = after === 'save' + if (check.ok) { + return { tone: 'ok', text: saved ? `Saved. Unsplash accepted the key${allowanceLeft(check)}.` : `The key works${allowanceLeft(check)}.` } + } + switch (check.reason) { + case 'rate-limited': + return { tone: 'warn', text: `${saved ? 'Saved. ' : ''}${unsplashRateLimitSentence(check.limit)}` } + case 'offline': + return { + tone: 'warn', + text: saved + ? 'Saved, but Unsplash could not be reached to check it. The Unsplash tab says so if Unsplash refuses it.' + : 'Unsplash could not be reached. Check your connection and try again.' + } + case 'invalid-key': + return { tone: 'error', text: 'Unsplash did not accept this key. Paste the Access Key again from your app at unsplash.com/oauth/applications.' } + case 'no-key': + return { tone: 'warn', text: 'No key is saved yet.' } + default: + return { + tone: 'warn', + text: saved + ? 'Saved, but Unsplash did not answer as expected, so the key is not checked yet.' + : 'Unsplash did not answer as expected. Try again in a moment.' + } + } +} + +/** What Settings says when a pasted key was not saved. */ +export function unsplashKeyRefusalMessage( + reason: Extract<UnsplashKeySaveResult, { saved: false }>['reason'], + store: UnsplashKeyStore | undefined, + linux = false +): UnsplashKeyMessage { + switch (reason) { + case 'malformed': + return { + tone: 'error', + text: 'That does not look like an Unsplash Access Key, so it was not saved. The key is one word of letters, digits, - and _.' + } + case 'invalid-key': + return { + tone: 'error', + text: 'Unsplash did not accept that key, so it was not saved. Check that you copied the Access Key, not the Secret Key.' + } + case 'cannot-store': + return { + tone: 'error', + text: + store === 'browser' + ? 'This browser would not keep the key: its site storage may be turned off.' + : `ZenNotes could not store the key securely: this system has no secure storage it can use.${ + linux ? ' If a Secret Service keyring is running, launch ZenNotes with --password-store=gnome-libsecret.' : '' + }` + } + } +} diff --git a/packages/app-core/src/lib/unsplash-search.test.ts b/packages/app-core/src/lib/unsplash-search.test.ts index e31696479..bf763ae61 100644 --- a/packages/app-core/src/lib/unsplash-search.test.ts +++ b/packages/app-core/src/lib/unsplash-search.test.ts @@ -112,12 +112,12 @@ describe('createUnsplashSearch', () => { it('reports a failure, and asks again when the same search comes back', async () => { const search = vi .fn<UnsplashSearchFn>() - .mockResolvedValueOnce({ ok: false, reason: 'rate-limited', retryAt: 5_000 }) + .mockResolvedValueOnce({ ok: false, reason: 'rate-limited', retryAt: null, limit: 50 }) .mockImplementation(async (q, page) => answer(q, page, ['a'])) const { controller, last } = setup(search) controller.setQuery('fog') await vi.advanceTimersByTimeAsync(400) - expect(last()).toEqual({ status: 'failed', query: 'fog', reason: 'rate-limited', retryAt: 5_000 }) + expect(last()).toEqual({ status: 'failed', query: 'fog', reason: 'rate-limited', retryAt: null, limit: 50 }) controller.setQuery('fog') await vi.advanceTimersByTimeAsync(400) expect(last()).toMatchObject({ status: 'ready', query: 'fog' }) diff --git a/packages/app-core/src/lib/unsplash-search.ts b/packages/app-core/src/lib/unsplash-search.ts index 6ac46bde4..3390b0fd1 100644 --- a/packages/app-core/src/lib/unsplash-search.ts +++ b/packages/app-core/src/lib/unsplash-search.ts @@ -1,6 +1,6 @@ /** - * The cover picker's Unsplash tab: search as you type, gentle on the one - * hourly budget every ZenNotes user shares through the zennotes.org proxy. + * The cover picker's Unsplash tab: search as you type, gentle on the hourly + * allowance of the person's own Unsplash key (50 requests for a new app). * A query waits for a pause in typing and needs two characters; an answer * that arrives after a newer query is dropped; the last answers are kept for * the session, so switching tabs or reopening the picker asks nothing again. @@ -31,7 +31,14 @@ export type UnsplashSearchState = totalPages: number loadingMore: boolean } - | { status: 'failed'; query: string; reason: UnsplashSearchFailure; retryAt: number | null } + | { + status: 'failed' + query: string + reason: UnsplashSearchFailure + retryAt: number | null + /** The key's hourly allowance, when Unsplash said. */ + limit: number | null + } export type UnsplashSearchFn = (query: string, page: number) => Promise<UnsplashSearchResult> @@ -106,7 +113,7 @@ export function createUnsplashSearch(deps: { try { result = await deps.search(query, page) } catch { - result = { ok: false, reason: 'unavailable', retryAt: null } + result = { ok: false, reason: 'unavailable', retryAt: null, limit: null } } if (result.ok) cache.set(result.page) return result @@ -116,7 +123,7 @@ export function createUnsplashSearch(deps: { const result = await ask(query, 1) if (mine !== generation || disposed) return if (!result.ok) { - emit({ status: 'failed', query, reason: result.reason, retryAt: result.retryAt }) + emit({ status: 'failed', query, reason: result.reason, retryAt: result.retryAt, limit: result.limit }) return } emit({ diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 4543a1824..312ae52cd 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -43,7 +43,12 @@ import type { VaultTextSearchToolPaths } from './ipc.js' import type { CustomTemplateFile, WriteTemplateInput } from './templates.js' -import type { UnsplashSearchResult } from './unsplash.js' +import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashSearchResult +} from './unsplash.js' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -180,12 +185,21 @@ export interface ZenBridge { * no identifier. The caller checks the setting; the host refuses * development builds and test runs, and sends each event once per run. */ sendUsageCount?(event: 'install' | 'update'): Promise<void> - /** Unsplash photo search for the cover picker, through the proxy on - * zennotes.org (no host holds an Unsplash key). A host without it shows no - * Unsplash tab. Never rejects: a failure comes back as its reason. */ + /** Unsplash photo search for the cover picker, sent straight to + * api.unsplash.com with the person's own access key. A host without it + * shows no Unsplash tab. Never rejects: a failure comes back as its reason. */ searchUnsplash?(query: string, page: number): Promise<UnsplashSearchResult> /** Unsplash's download tracking: a photo became a cover. Fire and forget. */ trackUnsplashDownload?(downloadLocation: string): Promise<void> + /** Whether an Unsplash access key is set. The key itself never crosses + * the bridge: the host keeps it and answers only this. */ + getUnsplashKeyStatus?(): Promise<UnsplashKeyStatus> + /** Check a pasted access key with one request, then keep it unless + * Unsplash refused it. */ + saveUnsplashKey?(key: string): Promise<UnsplashKeySaveResult> + /** One request with the saved key. */ + testUnsplashKey?(): Promise<UnsplashKeyCheck> + clearUnsplashKey?(): Promise<void> checkForAppUpdatesWithUi(): Promise<void> downloadAppUpdate(): Promise<AppUpdateState> installAppUpdate(): Promise<void> diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 62a4c6e83..a06bcd3a2 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -114,6 +114,10 @@ export const IPC = { APP_USAGE_COUNT: 'app:usage-count', UNSPLASH_SEARCH: 'unsplash:search', UNSPLASH_TRACK_DOWNLOAD: 'unsplash:track-download', + UNSPLASH_KEY_STATUS: 'unsplash:key-status', + UNSPLASH_KEY_SAVE: 'unsplash:key-save', + UNSPLASH_KEY_TEST: 'unsplash:key-test', + UNSPLASH_KEY_CLEAR: 'unsplash:key-clear', APP_UPDATER_CHECK_WITH_UI: 'app-updater:check-with-ui', APP_UPDATER_DOWNLOAD: 'app-updater:download', APP_UPDATER_INSTALL: 'app-updater:install', diff --git a/packages/bridge-contract/src/unsplash.ts b/packages/bridge-contract/src/unsplash.ts index 62e1f8fbc..267dd2cec 100644 --- a/packages/bridge-contract/src/unsplash.ts +++ b/packages/bridge-contract/src/unsplash.ts @@ -1,8 +1,8 @@ /** - * Unsplash photo search for the cover picker. The app never holds an - * Unsplash key: every host asks the proxy on zennotes.org, which signs the - * request, trims the answer to these fields and keeps the shared hourly - * budget (see shared-domain unsplash.ts for the rules this follows). + * Unsplash photo search for the cover picker, with the person's own Unsplash + * access key: every host asks api.unsplash.com directly and keeps the key + * itself (see shared-domain unsplash.ts for the rules this follows). The key + * never comes back across the bridge; a host says only whether one is set. */ /** One photo, as the picker needs it. */ @@ -30,11 +30,47 @@ export interface UnsplashSearchPage { photos: UnsplashPhoto[] } -/** Why a search came back empty-handed: no connection, the shared hourly - * budget is spent (until `retryAt`, epoch ms, when the proxy says), or the - * service could not answer. */ -export type UnsplashSearchFailure = 'offline' | 'rate-limited' | 'unavailable' +/** Why a request to Unsplash came back empty-handed: + * - `no-key`: no access key is set yet; + * - `invalid-key`: Unsplash refused the key (401); + * - `rate-limited`: the key's hourly allowance is spent (403 with + * X-Ratelimit-Remaining at 0), or the app's own per-minute cap is (until + * `retryAt`, epoch ms); + * - `offline`: Unsplash could not be reached; + * - `unavailable`: any other answer. */ +export type UnsplashSearchFailure = 'no-key' | 'invalid-key' | 'rate-limited' | 'offline' | 'unavailable' export type UnsplashSearchResult = | { ok: true; page: UnsplashSearchPage } - | { ok: false; reason: UnsplashSearchFailure; retryAt: number | null } + | { + ok: false + reason: UnsplashSearchFailure + retryAt: number | null + /** The key's hourly allowance, when Unsplash said (X-Ratelimit-Limit). */ + limit: number | null + } + +/** Where a host keeps the key: the system's secure storage (desktop), or + * this browser's storage (the web client). */ +export type UnsplashKeyStore = 'system' | 'browser' + +export interface UnsplashKeyStatus { + set: boolean + store: UnsplashKeyStore + /** False when the host has nowhere safe to keep a key (a Linux desktop + * without a keyring, a browser with storage turned off). */ + canStore: boolean +} + +/** One real request with a key: it works, with what is left of the hour's + * allowance when Unsplash says, or why not. */ +export type UnsplashKeyCheck = + | { ok: true; remaining: number | null; limit: number | null } + | { ok: false; reason: UnsplashSearchFailure; limit: number | null } + +/** Saving a pasted key. It is checked with one request first and kept + * unless Unsplash refuses it; a key that could not be checked (offline, + * rate-limited) is kept, with the check saying why. */ +export type UnsplashKeySaveResult = + | { saved: true; check: UnsplashKeyCheck } + | { saved: false; reason: 'malformed' | 'invalid-key' | 'cannot-store' } diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index 7152e6f81..ab7ede7ab 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -53,6 +53,7 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { id: "note.favorite", title: "Toggle Note Favorite", category: "Note", keywords: "favourite star bookmark home sidebar" }, { id: "note.cover", title: "Add or Change Cover…", category: "Note", keywords: "banner header image picture gallery gradient" }, { id: "note.cover.unsplash", title: "Add Cover from Unsplash…", category: "Note", keywords: "banner header image picture photo stock unsplash" }, + { id: "note.cover.unsplashKey", title: "Set Unsplash Access Key…", category: "Note", keywords: "unsplash api key access cover banner photo settings" }, { id: "note.cover.paste", title: "Paste Cover from Clipboard", category: "Note", keywords: "banner header image screenshot" }, { id: "note.cover.reposition", title: "Reposition Cover", category: "Note", keywords: "banner header image move focal" }, { id: "note.cover.remove", title: "Remove Cover", category: "Note", keywords: "banner header image delete" }, diff --git a/packages/shared-domain/src/unsplash.test.ts b/packages/shared-domain/src/unsplash.test.ts index 03803309c..48c3fb396 100644 --- a/packages/shared-domain/src/unsplash.test.ts +++ b/packages/shared-domain/src/unsplash.test.ts @@ -1,31 +1,40 @@ import { describe, expect, it, vi } from 'vitest' import { + UNSPLASH_REQUESTS_PER_MINUTE, + checkUnsplashAccessKey, + createUnsplashClient, isUnsplashDownloadLocation, isUnsplashImageUrl, noteCoverCreditLine, + normalizeUnsplashAccessKey, normalizeUnsplashQuery, parseUnsplashSearchResponse, - searchUnsplashThroughProxy, - trackUnsplashDownloadThroughProxy, + searchUnsplashPhotos, + trackUnsplashPhotoDownload, unsplashCoverUrl, unsplashReferralUrl, - unsplashRetryAt, type UnsplashFetch, - type UnsplashFetchResponse + type UnsplashFetchResponse, + type UnsplashKeyVault } from './unsplash' const RAW = 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?ixid=M3w1fDB8MXxzZWFyY2h8MXx8Zm9n&ixlib=rb-4.0.3' +const KEY = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcde' +/** A photo the way api.unsplash.com sends it, cut to the fields read. */ function result(overrides: Record<string, unknown> = {}): Record<string, unknown> { return { id: 'Bkci_8qcdvQ', width: 6000, height: 4000, color: '#c0d9d9', - alt: 'fog over a valley', + description: null, + alt_description: 'fog over a valley', urls: { raw: RAW, small: `${RAW}&w=400` }, - user: { name: 'Jane Doe', profile: 'https://unsplash.com/@janedoe' }, - download_location: 'https://api.unsplash.com/photos/Bkci_8qcdvQ/download?ixid=M3w1fDB8', + links: { download_location: 'https://api.unsplash.com/photos/Bkci_8qcdvQ/download?ixid=M3w1fDB8' }, + user: { name: 'Jane Doe', links: { html: 'https://unsplash.com/@janedoe?utm_source=x' } }, + premium: false, + plus: false, ...overrides } } @@ -80,9 +89,19 @@ describe('Unsplash URLs', () => { }) }) +describe('normalizeUnsplashAccessKey', () => { + it('takes a pasted key, trimmed, and nothing that could break a header', () => { + expect(normalizeUnsplashAccessKey(` ${KEY}\n`)).toBe(KEY) + expect(normalizeUnsplashAccessKey(`${KEY}\r\nX-Other: 1`)).toBeNull() + expect(normalizeUnsplashAccessKey('Access Key: abc')).toBeNull() + expect(normalizeUnsplashAccessKey('short')).toBeNull() + expect(normalizeUnsplashAccessKey(42)).toBeNull() + }) +}) + describe('parseUnsplashSearchResponse', () => { - it('reads the proxy answer into photos', () => { - const page = parseUnsplashSearchResponse({ total_pages: 7, results: [result()] }, 'fog', 1) + it('reads Unsplash’s answer into photos, keeping the profile without its query', () => { + const page = parseUnsplashSearchResponse({ total: 140, total_pages: 7, results: [result()] }, 'fog', 1) expect(page).toEqual({ query: 'fog', page: 1, @@ -103,13 +122,15 @@ describe('parseUnsplashSearchResponse', () => { }) }) - it('leaves out a photo with an image, link or ping it should not follow', () => { + it('leaves out an Unsplash+ photo, and one with an image, link or ping it should not follow', () => { const bad = [ + result({ premium: true }), + result({ plus: true }), result({ urls: { raw: 'https://example.com/a.jpg', small: RAW } }), result({ urls: { raw: RAW, small: 'javascript:alert(1)' } }), - result({ user: { name: 'X', profile: 'https://example.com/@x' } }), - result({ user: { name: ' ', profile: 'https://unsplash.com/@x' } }), - result({ download_location: 'https://example.com/photos/a/download' }), + result({ user: { name: 'X', links: { html: 'https://example.com/@x' } } }), + result({ user: { name: ' ', links: { html: 'https://unsplash.com/@x' } } }), + result({ links: { download_location: 'https://example.com/photos/a/download' } }), result({ id: '../../me' }), null, 'photo' @@ -117,8 +138,12 @@ describe('parseUnsplashSearchResponse', () => { expect(parseUnsplashSearchResponse({ total_pages: 1, results: [...bad, result()] }, 'fog', 1)?.photos).toHaveLength(1) }) - it('drops a colour or caption it cannot use, and is null for an answer that is not one', () => { - const photo = parseUnsplashSearchResponse({ results: [result({ color: 'red;', alt: 42 })] }, 'q', 1)?.photos[0] + it('falls back to the description for a caption, drops what it cannot use, and is null for an answer that is not one', () => { + expect( + parseUnsplashSearchResponse({ results: [result({ alt_description: null, description: 'Morning fog' })] }, 'q', 1) + ?.photos[0]?.alt + ).toBe('Morning fog') + const photo = parseUnsplashSearchResponse({ results: [result({ color: 'red;', alt_description: 42 })] }, 'q', 1)?.photos[0] expect(photo?.color).toBeNull() expect(photo?.alt).toBeNull() expect(parseUnsplashSearchResponse({ results: 'none' }, 'q', 1)).toBeNull() @@ -126,70 +151,226 @@ describe('parseUnsplashSearchResponse', () => { }) }) -describe('searchUnsplashThroughProxy', () => { - it('posts the query, with no cookies, and returns the page', async () => { - const fetchFn = vi.fn<UnsplashFetch>(async () => response(200, { total_pages: 2, results: [result()] })) - const out = await searchUnsplashThroughProxy(fetchFn, { query: ' fog ', page: 2 }, { baseUrl: 'http://127.0.0.1:9/u' }) - expect(out.ok && out.page.photos).toHaveLength(1) +describe('searchUnsplashPhotos', () => { + it('asks api.unsplash.com with the key, no cookies and no referrer, and returns the page', async () => { + const fetchFn = vi.fn<UnsplashFetch>(async () => response(200, { total_pages: 3, results: [result()] })) + const answer = await searchUnsplashPhotos(fetchFn, { key: KEY, query: ' misty fog ', page: 2 }) + expect(answer).toMatchObject({ ok: true, page: { query: 'misty fog', page: 2, totalPages: 3 } }) const [url, init] = fetchFn.mock.calls[0]! - expect(url).toBe('http://127.0.0.1:9/u/search') - expect(init.method).toBe('POST') + const sent = new URL(url) + expect(`${sent.origin}${sent.pathname}`).toBe('https://api.unsplash.com/search/photos') + expect(Object.fromEntries(sent.searchParams)).toEqual({ + query: 'misty fog', + page: '2', + per_page: '24', + orientation: 'landscape', + content_filter: 'high' + }) + expect(init.method).toBe('GET') expect(init.credentials).toBe('omit') - expect(JSON.parse(String(init.body))).toEqual({ query: 'fog', page: 2 }) + expect(init.referrerPolicy).toBe('no-referrer') + expect(init.headers).toMatchObject({ authorization: `Client-ID ${KEY}`, 'accept-version': 'v1' }) }) it('asks nothing for a query too short to search', async () => { const fetchFn = vi.fn<UnsplashFetch>() - const out = await searchUnsplashThroughProxy(fetchFn, { query: 'a' }) - expect(out).toEqual({ ok: true, page: { query: 'a', page: 1, totalPages: 0, photos: [] } }) + expect(await searchUnsplashPhotos(fetchFn, { key: KEY, query: ' a ' })).toMatchObject({ ok: true, page: { photos: [] } }) expect(fetchFn).not.toHaveBeenCalled() }) - it('says why when it fails: unreachable, over the hourly budget, or anything else', async () => { - const throwing = vi.fn<UnsplashFetch>(async () => { - throw new TypeError('fetch failed') - }) - expect(await searchUnsplashThroughProxy(throwing, { query: 'fog' })).toEqual({ + it('says why when it fails: a refused key, a spent hour, no connection, or anything else', async () => { + const answer = (r: UnsplashFetchResponse) => searchUnsplashPhotos(async () => r, { key: KEY, query: 'fog' }) + expect(await answer(response(401, { errors: ['OAuth error: The access token is invalid'] }))).toEqual({ ok: false, - reason: 'offline', - retryAt: null + reason: 'invalid-key', + retryAt: null, + limit: null }) - const limited = vi.fn<UnsplashFetch>(async () => response(429, {}, { 'retry-after': '120' })) - expect(await searchUnsplashThroughProxy(limited, { query: 'fog' }, { now: () => 1_000 })).toEqual({ - ok: false, - reason: 'rate-limited', - retryAt: 121_000 - }) - const down = vi.fn<UnsplashFetch>(async () => response(503, { error: 'unavailable' })) - expect(await searchUnsplashThroughProxy(down, { query: 'fog' })).toMatchObject({ ok: false, reason: 'unavailable' }) - const garbage = vi.fn<UnsplashFetch>(async () => response(200, '<html>')) - expect(await searchUnsplashThroughProxy(garbage, { query: 'fog' })).toMatchObject({ ok: false, reason: 'unavailable' }) - }) - - it('reads Retry-After as seconds or a date', () => { - expect(unsplashRetryAt('60', 0)).toBe(60_000) - expect(unsplashRetryAt('Wed, 28 Oct 2026 15:00:00 GMT', 0)).toBe(Date.UTC(2026, 9, 28, 15)) - expect(unsplashRetryAt('soon', 0)).toBeNull() - expect(unsplashRetryAt(null, 0)).toBeNull() + expect( + await answer(response(403, null, { 'x-ratelimit-limit': '50', 'x-ratelimit-remaining': '0' })) + ).toEqual({ ok: false, reason: 'rate-limited', retryAt: null, limit: 50 }) + // A 403 with allowance left is not the rate limit. + expect(await answer(response(403, null, { 'x-ratelimit-remaining': '12' }))).toMatchObject({ reason: 'unavailable' }) + expect(await answer(response(500, null))).toMatchObject({ reason: 'unavailable' }) + expect(await answer(response(200, { nothing: true }))).toMatchObject({ reason: 'unavailable' }) + const offline = await searchUnsplashPhotos( + async () => { + throw new TypeError('fetch failed') + }, + { key: KEY, query: 'fog' } + ) + expect(offline).toEqual({ ok: false, reason: 'offline', retryAt: null, limit: null }) }) }) -describe('trackUnsplashDownloadThroughProxy', () => { - it('posts a valid download location and swallows a failure', async () => { +describe('trackUnsplashPhotoDownload', () => { + it('GETs a valid download location with the key, at the API base, and swallows a failure', async () => { const fetchFn = vi.fn<UnsplashFetch>(async () => { throw new Error('offline') }) - const location = 'https://api.unsplash.com/photos/abc/download?ixid=x' - await expect(trackUnsplashDownloadThroughProxy(fetchFn, location, { baseUrl: 'https://p.test' })).resolves.toBeUndefined() - expect(fetchFn.mock.calls[0]![0]).toBe('https://p.test/downloads') - expect(JSON.parse(String(fetchFn.mock.calls[0]![1].body))).toEqual({ download_location: location }) + const downloadLocation = 'https://api.unsplash.com/photos/abc/download?ixid=xyz' + await expect(trackUnsplashPhotoDownload(fetchFn, { key: KEY, downloadLocation })).resolves.toBeUndefined() + expect(fetchFn.mock.calls[0]![0]).toBe(downloadLocation) + expect(fetchFn.mock.calls[0]![1].headers).toMatchObject({ authorization: `Client-ID ${KEY}` }) + // A test run's stand-in gets the same path and query. + await trackUnsplashPhotoDownload(fetchFn, { key: KEY, downloadLocation }, { baseUrl: 'http://127.0.0.1:7/' }) + expect(fetchFn.mock.calls[1]![0]).toBe('http://127.0.0.1:7/photos/abc/download?ixid=xyz') }) it('sends nothing for any other URL', async () => { const fetchFn = vi.fn<UnsplashFetch>() - await trackUnsplashDownloadThroughProxy(fetchFn, 'https://example.com/photos/abc/download') + await trackUnsplashPhotoDownload(fetchFn, { key: KEY, downloadLocation: 'https://example.com/photos/abc/download' }) + expect(fetchFn).not.toHaveBeenCalled() + }) +}) + +describe('checkUnsplashAccessKey', () => { + it('asks for one photo and reports what is left of the hour', async () => { + const fetchFn = vi.fn<UnsplashFetch>(async () => + response(200, [], { 'x-ratelimit-limit': '50', 'x-ratelimit-remaining': '49' }) + ) + expect(await checkUnsplashAccessKey(fetchFn, KEY)).toEqual({ ok: true, remaining: 49, limit: 50 }) + expect(fetchFn.mock.calls[0]![0]).toBe('https://api.unsplash.com/photos?per_page=1') + }) + + it('says a refused key, a spent hour or no connection apart', async () => { + expect(await checkUnsplashAccessKey(async () => response(401, {}), KEY)).toEqual({ + ok: false, + reason: 'invalid-key', + limit: null + }) + expect( + await checkUnsplashAccessKey(async () => response(403, null, { 'x-ratelimit-remaining': '0', 'x-ratelimit-limit': '50' }), KEY) + ).toEqual({ ok: false, reason: 'rate-limited', limit: 50 }) + expect( + await checkUnsplashAccessKey(async () => { + throw new TypeError('fetch failed') + }, KEY) + ).toEqual({ ok: false, reason: 'offline', limit: null }) + }) +}) + +describe('createUnsplashClient', () => { + function memoryVault(initial: string | null = null, canStore = true): UnsplashKeyVault & { value: string | null } { + const vault = { + value: initial, + get: async () => vault.value, + set: async (key: string) => { + if (!canStore) return false + vault.value = key + return true + }, + delete: async () => { + vault.value = null + }, + canStore: () => canStore + } + return vault + } + const ok = (json: unknown = { total_pages: 1, results: [result()] }) => + response(200, json, { 'x-ratelimit-limit': '50', 'x-ratelimit-remaining': '48' }) + + it('without a key, says so and sends nothing', async () => { + const fetchFn = vi.fn<UnsplashFetch>() + const client = createUnsplashClient({ fetch: fetchFn, keys: memoryVault(), store: 'system' }) + expect(await client.status()).toEqual({ set: false, store: 'system', canStore: true }) + expect(await client.search('fog', 1)).toEqual({ ok: false, reason: 'no-key', retryAt: null, limit: null }) + expect(await client.test()).toEqual({ ok: false, reason: 'no-key', limit: null }) + await client.trackDownload('https://api.unsplash.com/photos/abc/download') + expect(fetchFn).not.toHaveBeenCalled() + }) + + it('checks a pasted key before keeping it, and never answers with the key', async () => { + const vault = memoryVault() + const fetchFn = vi.fn<UnsplashFetch>(async () => ok([])) + const client = createUnsplashClient({ fetch: fetchFn, keys: vault, store: 'system' }) + const saved = await client.save(` ${KEY} `) + expect(saved).toEqual({ saved: true, check: { ok: true, remaining: 48, limit: 50 } }) + expect(vault.value).toBe(KEY) + const status = await client.status() + expect(status).toEqual({ set: true, store: 'system', canStore: true }) + for (const answer of [saved, status, await client.test()]) expect(JSON.stringify(answer)).not.toContain(KEY) + expect(fetchFn.mock.calls[0]![1].headers).toMatchObject({ authorization: `Client-ID ${KEY}` }) + }) + + it('does not keep a key Unsplash refuses, a malformed one, or one with nowhere safe to go', async () => { + const refused = memoryVault() + expect( + await createUnsplashClient({ fetch: async () => response(401, {}), keys: refused, store: 'system' }).save(KEY) + ).toEqual({ saved: false, reason: 'invalid-key' }) + expect(refused.value).toBeNull() + + const fetchFn = vi.fn<UnsplashFetch>() + expect(await createUnsplashClient({ fetch: fetchFn, keys: memoryVault(), store: 'system' }).save('my key')).toEqual({ + saved: false, + reason: 'malformed' + }) + const nowhere = createUnsplashClient({ fetch: fetchFn, keys: memoryVault(null, false), store: 'system' }) + expect(await nowhere.status()).toEqual({ set: false, store: 'system', canStore: false }) + expect(await nowhere.save(KEY)).toEqual({ saved: false, reason: 'cannot-store' }) + expect(fetchFn).not.toHaveBeenCalled() + }) + + it('keeps a key it could not check, saying why', async () => { + const vault = memoryVault() + const client = createUnsplashClient({ + fetch: async () => { + throw new TypeError('fetch failed') + }, + keys: vault, + store: 'browser' + }) + expect(await client.save(KEY)).toEqual({ saved: true, check: { ok: false, reason: 'offline', limit: null } }) + expect(vault.value).toBe(KEY) + }) + + it('searches and pings with the saved key, and clearing it stops both', async () => { + const vault = memoryVault(KEY) + const fetchFn = vi.fn<UnsplashFetch>(async () => ok()) + const client = createUnsplashClient({ fetch: fetchFn, keys: vault, store: 'system' }) + expect(await client.search('fog', 1)).toMatchObject({ ok: true, page: { photos: [{ id: 'Bkci_8qcdvQ' }] } }) + await client.trackDownload('https://api.unsplash.com/photos/Bkci_8qcdvQ/download?ixid=M3w1fDB8') + expect(fetchFn).toHaveBeenCalledTimes(2) + await client.clear() + expect(vault.value).toBeNull() + expect(await client.status()).toMatchObject({ set: false }) + expect(await client.search('fog', 1)).toMatchObject({ ok: false, reason: 'no-key' }) + expect(fetchFn).toHaveBeenCalledTimes(2) + }) + + it('refuses arguments that are not a search or a download endpoint', async () => { + const fetchFn = vi.fn<UnsplashFetch>() + const client = createUnsplashClient({ fetch: fetchFn, keys: memoryVault(KEY), store: 'system' }) + expect(await client.search({ query: 'fog' }, 1)).toMatchObject({ ok: false, reason: 'unavailable' }) + await client.trackDownload(42) + await client.trackDownload('https://example.com/photos/a/download') + expect(await client.search('a', 1)).toMatchObject({ ok: true, page: { photos: [] } }) expect(fetchFn).not.toHaveBeenCalled() }) + + it('caps one run at a minute’s worth of requests, whatever the renderer asks', async () => { + let now = 0 + const fetchFn = vi.fn<UnsplashFetch>(async () => ok({ total_pages: 1, results: [] })) + const client = createUnsplashClient({ fetch: fetchFn, keys: memoryVault(KEY), store: 'system', now: () => now }) + for (let i = 0; i < UNSPLASH_REQUESTS_PER_MINUTE; i++) await client.search(`fog ${i}`, 1) + expect(await client.search('one more', 1)).toEqual({ ok: false, reason: 'rate-limited', retryAt: 60_000, limit: null }) + expect(fetchFn).toHaveBeenCalledTimes(UNSPLASH_REQUESTS_PER_MINUTE) + now = 60_000 + expect(await client.search('later', 1)).toMatchObject({ ok: true }) + }) + + it('sends the host’s base URL and headers', async () => { + const fetchFn = vi.fn<UnsplashFetch>(async () => ok()) + const client = createUnsplashClient({ + fetch: fetchFn, + keys: memoryVault(KEY), + store: 'system', + options: () => ({ baseUrl: 'http://127.0.0.1:9/', headers: { 'user-agent': 'ZenNotes' } }) + }) + await client.search('fog', 1) + expect(fetchFn.mock.calls[0]![0]).toMatch(/^http:\/\/127\.0\.0\.1:9\/search\/photos\?/) + expect(fetchFn.mock.calls[0]![1].headers).toMatchObject({ 'user-agent': 'ZenNotes', authorization: `Client-ID ${KEY}` }) + }) }) describe('noteCoverCreditLine', () => { diff --git a/packages/shared-domain/src/unsplash.ts b/packages/shared-domain/src/unsplash.ts index 3b267abe6..4be7f9cff 100644 --- a/packages/shared-domain/src/unsplash.ts +++ b/packages/shared-domain/src/unsplash.ts @@ -12,27 +12,49 @@ * photographer's profile and Unsplash to unsplash.com, every link with * utm_source and utm_medium=referral. The note keeps the name and the * profile (note-cover.ts); the referral parameters are added when drawn. - * - The key stays secret, so search and the ping go through the proxy on - * zennotes.org, which also keeps one hourly budget for every ZenNotes user. + * - The key. Each person registers an Unsplash app of their own and pastes + * its Access Key. Requests go from their machine straight to + * api.unsplash.com with `Authorization: Client-ID <key>`; zennotes.org takes + * no part. The terms keep a key confidential, so the host keeps it to + * itself (desktop main in the system's secure storage, the web client in + * its browser) and tells the renderer only whether one is set. * - * Whatever the proxy hands back is checked here before the app shows it: + * Whatever Unsplash hands back is checked here before the app shows it: * images only from images.unsplash.com, links only to unsplash.com, a ping * only to an api.unsplash.com download endpoint. */ import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashKeyStore, UnsplashPhoto, + UnsplashSearchFailure, UnsplashSearchPage, UnsplashSearchResult } from '@zennotes/bridge-contract/unsplash' import type { NoteCover, NoteCoverCredit } from './note-cover' -export const UNSPLASH_PROXY_BASE_URL = 'https://zennotes.org/api/v1/unsplash' +export const UNSPLASH_API_BASE_URL = 'https://api.unsplash.com' export const UNSPLASH_HOME_URL = 'https://unsplash.com/' +/** Where a person registers the app whose Access Key they paste. */ +export const UNSPLASH_DEVELOPERS_URL = 'https://unsplash.com/developers' +/** A person's Unsplash apps, where a key is found again or revoked. */ +export const UNSPLASH_APPS_URL = 'https://unsplash.com/oauth/applications' /** The app name Unsplash's referral links carry. */ export const UNSPLASH_UTM_SOURCE = 'zennotes' export const UNSPLASH_MIN_QUERY_LENGTH = 2 export const UNSPLASH_MAX_QUERY_LENGTH = 100 export const UNSPLASH_MAX_PAGE = 50 +/** Photos per page: six rows of the picker's four tiles. */ +export const UNSPLASH_PER_PAGE = 24 +/** What a new Unsplash app may ask in an hour, until Unsplash approves it + * for production. */ +export const UNSPLASH_DEMO_HOURLY_LIMIT = 50 +/** Requests one client sends in a minute at most, well above what a person + * typing reaches: a renderer bug that searched in a loop would otherwise + * spend the key's hour in seconds. */ +export const UNSPLASH_REQUESTS_PER_MINUTE = 40 /** A cover spans the whole pane: 2400 px fills a wide window at 2x without * pulling the original, which is often 6000 px and more. */ @@ -52,10 +74,6 @@ export function isUnsplashImageUrl(url: unknown): boolean { return httpsUrl(url)?.hostname === 'images.unsplash.com' } -function isUnsplashPageUrl(url: unknown): boolean { - return httpsUrl(url)?.hostname === 'unsplash.com' -} - /** An api.unsplash.com download endpoint, the only place the ping may go. */ export function isUnsplashDownloadLocation(url: unknown): boolean { const parsed = httpsUrl(url) @@ -95,6 +113,16 @@ export function isUnsplashSearchableQuery(query: string): boolean { return normalizeUnsplashQuery(query).length >= UNSPLASH_MIN_QUERY_LENGTH } +/** A pasted Access Key, trimmed, or null when it cannot be one. Unsplash's + * keys are 43 URL-safe characters today; this is looser on purpose. It + * keeps a pasted line break or label out of a request header, and leaves + * the real verdict to Unsplash, which saving asks. */ +export function normalizeUnsplashAccessKey(key: unknown): string | null { + if (typeof key !== 'string') return null + const trimmed = key.trim() + return /^[A-Za-z0-9_-]{16,128}$/.test(trimmed) ? trimmed : null +} + function text(value: unknown, max: number): string | null { if (typeof value !== 'string') return null const trimmed = value.trim().replace(/\s+/g, ' ') @@ -105,31 +133,42 @@ function size(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : 0 } +function record(value: unknown): Record<string, unknown> { + return value && typeof value === 'object' ? (value as Record<string, unknown>) : {} +} + function photoFrom(entry: unknown): UnsplashPhoto | null { if (!entry || typeof entry !== 'object') return null const e = entry as Record<string, unknown> - const urls = (e.urls ?? {}) as Record<string, unknown> - const user = (e.user ?? {}) as Record<string, unknown> + // An Unsplash+ photo can be listed to a free key, but nobody may use it + // without a plan. + if (e.premium === true || e.plus === true) return null + const urls = record(e.urls) + const user = record(e.user) const id = typeof e.id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(e.id) ? e.id : null const name = text(user.name, 120) + const profile = httpsUrl(record(user.links).html) + const downloadLocation = record(e.links).download_location if (!id || !name) return null if (!isUnsplashImageUrl(urls.raw) || !isUnsplashImageUrl(urls.small)) return null - if (!isUnsplashPageUrl(user.profile) || !isUnsplashDownloadLocation(e.download_location)) return null + if (profile?.hostname !== 'unsplash.com' || !isUnsplashDownloadLocation(downloadLocation)) return null return { id, width: size(e.width), height: size(e.height), color: typeof e.color === 'string' && /^#[0-9a-f]{6}$/i.test(e.color) ? e.color : null, - alt: text(e.alt, 300), + alt: text(e.alt_description, 300) ?? text(e.description, 300), rawUrl: urls.raw as string, thumbUrl: urls.small as string, - photographer: { name, profileUrl: user.profile as string }, - downloadLocation: e.download_location as string + // The note keeps the profile itself; the referral tags are added when drawn. + photographer: { name, profileUrl: `${profile.origin}${profile.pathname}` }, + downloadLocation: downloadLocation as string } } -/** The proxy's answer, `{ total_pages, results: [...] }`, as a page of - * photos; a malformed photo is left out, a malformed answer is null. */ +/** Unsplash's search answer, `{ total_pages, results: [...] }`, as a page of + * photos; a photo the app could not use is left out, an answer that is not + * one is null. */ export function parseUnsplashSearchResponse( json: unknown, query: string, @@ -153,88 +192,229 @@ export interface UnsplashFetchResponse { } export type UnsplashFetch = (url: string, init: RequestInit) => Promise<UnsplashFetchResponse> -export interface UnsplashProxyOptions { +export interface UnsplashApiOptions { + /** api.unsplash.com, or a stand-in for a test run. */ baseUrl?: string /** Extra request headers (the desktop sends a bare user agent). */ headers?: Record<string, string> - now?: () => number } -function proxyRequest(body: unknown, headers: Record<string, string> | undefined): RequestInit { +function apiBase(options: UnsplashApiOptions): string { + return (options.baseUrl ?? UNSPLASH_API_BASE_URL).replace(/\/+$/, '') +} + +function apiRequest(key: string, headers: Record<string, string> | undefined): RequestInit { return { - method: 'POST', - // No cookies: the app's session may hold zennotes.org ones from a Cloud - // page, and a search has nothing to do with the account. + method: 'GET', + // No cookies: a search has nothing to do with an account, and the + // desktop's session may hold zennotes.org ones from a Cloud page. credentials: 'omit', cache: 'no-store', - headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, - body: JSON.stringify(body), + // Unsplash needs no Referer naming the page (a self-hoster's address). + referrerPolicy: 'no-referrer', + headers: { + accept: 'application/json', + 'accept-version': 'v1', + authorization: `Client-ID ${key}`, + ...headers + }, signal: AbortSignal.timeout(10_000) } } -/** When to try again after a 429: Retry-After in seconds, or an HTTP date. */ -export function unsplashRetryAt(retryAfter: string | null, now: number): number | null { - if (!retryAfter) return null - const seconds = Number(retryAfter) - if (Number.isFinite(seconds) && seconds >= 0) return now + seconds * 1000 - const date = Date.parse(retryAfter) - return Number.isFinite(date) ? date : null +function headerCount(response: UnsplashFetchResponse, name: string): number | null { + const value = response.headers.get(name)?.trim() + if (!value) return null + const count = Number(value) + return Number.isInteger(count) && count >= 0 ? count : null } -/** One page of results through the proxy. Never throws: a failure comes back - * as the reason the picker shows. */ -export async function searchUnsplashThroughProxy( +/** What an answer that is not a success means. Unsplash refuses a key with + * 401, and says the hour's allowance is spent with 403 "Rate Limit + * Exceeded" and X-Ratelimit-Remaining at 0. */ +export function unsplashFailureOf(response: UnsplashFetchResponse): UnsplashSearchFailure { + if (response.status === 401) return 'invalid-key' + if (response.status === 429) return 'rate-limited' + if (response.status === 403 && headerCount(response, 'x-ratelimit-remaining') === 0) return 'rate-limited' + return 'unavailable' +} + +function failed(reason: UnsplashSearchFailure, limit: number | null, retryAt: number | null = null): UnsplashSearchResult { + return { ok: false, reason, retryAt, limit } +} + +function clampPage(page: number | undefined): number { + return Math.min(UNSPLASH_MAX_PAGE, Math.max(1, Math.floor(page ?? 1))) +} + +/** One page of landscape, safe-search results. Never throws: a failure comes + * back as the reason the picker shows. */ +export async function searchUnsplashPhotos( fetchFn: UnsplashFetch, - input: { query: string; page?: number }, - options: UnsplashProxyOptions = {} + input: { key: string; query: string; page?: number }, + options: UnsplashApiOptions = {} ): Promise<UnsplashSearchResult> { const query = normalizeUnsplashQuery(input.query) - const page = Math.min(UNSPLASH_MAX_PAGE, Math.max(1, Math.floor(input.page ?? 1))) - const now = options.now ?? Date.now + const page = clampPage(input.page) if (query.length < UNSPLASH_MIN_QUERY_LENGTH) { return { ok: true, page: { query, page, totalPages: 0, photos: [] } } } + const params = new URLSearchParams({ + query, + page: String(page), + per_page: String(UNSPLASH_PER_PAGE), + orientation: 'landscape', + content_filter: 'high' + }) let response: UnsplashFetchResponse try { - response = await fetchFn( - `${options.baseUrl ?? UNSPLASH_PROXY_BASE_URL}/search`, - proxyRequest({ query, page }, options.headers) - ) + response = await fetchFn(`${apiBase(options)}/search/photos?${params}`, apiRequest(input.key, options.headers)) } catch { - return { ok: false, reason: 'offline', retryAt: null } + return failed('offline', null) } - if (response.status === 429) { - return { ok: false, reason: 'rate-limited', retryAt: unsplashRetryAt(response.headers.get('retry-after'), now()) } - } - if (!response.ok) return { ok: false, reason: 'unavailable', retryAt: null } + if (!response.ok) return failed(unsplashFailureOf(response), headerCount(response, 'x-ratelimit-limit')) let parsed: UnsplashSearchPage | null = null try { parsed = parseUnsplashSearchResponse(await response.json(), query, page) } catch { parsed = null } - return parsed ? { ok: true, page: parsed } : { ok: false, reason: 'unavailable', retryAt: null } + return parsed ? { ok: true, page: parsed } : failed('unavailable', null) } -/** Tell Unsplash a photo became a cover. Fire and forget: a ping that does +/** Tell Unsplash a photo became a cover: a GET of its download location, + * with the key. The location's path and query go to the API base, which is + * api.unsplash.com outside a test run. Fire and forget: a ping that does * not arrive must never undo or delay the pick. */ -export async function trackUnsplashDownloadThroughProxy( +export async function trackUnsplashPhotoDownload( fetchFn: UnsplashFetch, - downloadLocation: string, - options: UnsplashProxyOptions = {} + input: { key: string; downloadLocation: string }, + options: UnsplashApiOptions = {} ): Promise<void> { - if (!isUnsplashDownloadLocation(downloadLocation)) return + if (!isUnsplashDownloadLocation(input.downloadLocation)) return + const location = new URL(input.downloadLocation) try { - await fetchFn( - `${options.baseUrl ?? UNSPLASH_PROXY_BASE_URL}/downloads`, - proxyRequest({ download_location: downloadLocation }, options.headers) - ) + await fetchFn(`${apiBase(options)}${location.pathname}${location.search}`, apiRequest(input.key, options.headers)) } catch { // Dropped, as above. } } +/** One request with a key: the first photo of Unsplash's editorial feed, + * the cheapest thing it answers. It costs one of the hour's requests, and + * the answer's headers say how many are left. */ +export async function checkUnsplashAccessKey( + fetchFn: UnsplashFetch, + key: string, + options: UnsplashApiOptions = {} +): Promise<UnsplashKeyCheck> { + let response: UnsplashFetchResponse + try { + response = await fetchFn(`${apiBase(options)}/photos?per_page=1`, apiRequest(key, options.headers)) + } catch { + return { ok: false, reason: 'offline', limit: null } + } + const limit = headerCount(response, 'x-ratelimit-limit') + if (!response.ok) return { ok: false, reason: unsplashFailureOf(response), limit } + return { ok: true, remaining: headerCount(response, 'x-ratelimit-remaining'), limit } +} + +/** Where a host keeps the key. `set` answers false when it could not keep + * it safely. */ +export interface UnsplashKeyVault { + get(): Promise<string | null> + set(key: string): Promise<boolean> + delete(): Promise<void> + canStore(): boolean +} + +export interface UnsplashClient { + status(): Promise<UnsplashKeyStatus> + save(key: unknown): Promise<UnsplashKeySaveResult> + test(): Promise<UnsplashKeyCheck> + clear(): Promise<void> + search(query: unknown, page: unknown): Promise<UnsplashSearchResult> + trackDownload(downloadLocation: unknown): Promise<void> +} + +/** + * Everything a host does for the Unsplash tab, given a fetch and a place for + * the key: desktop main (net.fetch and the system's secure storage) and the + * web client (the browser's fetch and storage) are both built from this. The + * arguments are checked here, since desktop main takes them from the + * renderer, and nothing the client answers carries the key. + */ +export function createUnsplashClient(deps: { + fetch: UnsplashFetch + keys: UnsplashKeyVault + store: UnsplashKeyStore + options?: () => UnsplashApiOptions + now?: () => number +}): UnsplashClient { + const now = deps.now ?? Date.now + const options = deps.options ?? (() => ({})) + const sent: number[] = [] + /** Room for one more request, or the time the oldest one leaves the window. */ + const take = (): number | null => { + const at = now() + while (sent.length && at - sent[0]! >= 60_000) sent.shift() + if (sent.length >= UNSPLASH_REQUESTS_PER_MINUTE) return sent[0]! + 60_000 + sent.push(at) + return null + } + // Read once and kept for the run; saving or clearing replaces it. + let current: Promise<string | null> | null = null + const key = (): Promise<string | null> => + (current ??= deps.keys.get().then(normalizeUnsplashAccessKey, () => null)) + + return { + async status() { + return { set: (await key()) !== null, store: deps.store, canStore: deps.keys.canStore() } + }, + async save(raw) { + const next = normalizeUnsplashAccessKey(raw) + if (!next) return { saved: false, reason: 'malformed' } + if (!deps.keys.canStore()) return { saved: false, reason: 'cannot-store' } + const check: UnsplashKeyCheck = + take() === null + ? await checkUnsplashAccessKey(deps.fetch, next, options()) + : { ok: false, reason: 'rate-limited', limit: null } + if (!check.ok && check.reason === 'invalid-key') return { saved: false, reason: 'invalid-key' } + if (!(await deps.keys.set(next).catch(() => false))) return { saved: false, reason: 'cannot-store' } + current = Promise.resolve(next) + return { saved: true, check } + }, + async test() { + const saved = await key() + if (!saved) return { ok: false, reason: 'no-key', limit: null } + if (take() !== null) return { ok: false, reason: 'rate-limited', limit: null } + return await checkUnsplashAccessKey(deps.fetch, saved, options()) + }, + async clear() { + await deps.keys.delete() + current = Promise.resolve(null) + }, + async search(query, page) { + if (typeof query !== 'string') return failed('unavailable', null) + const at = typeof page === 'number' && Number.isFinite(page) ? clampPage(page) : 1 + if (!isUnsplashSearchableQuery(query)) { + return { ok: true, page: { query: normalizeUnsplashQuery(query), page: at, totalPages: 0, photos: [] } } + } + const saved = await key() + if (!saved) return failed('no-key', null) + const wait = take() + if (wait !== null) return failed('rate-limited', null, wait) + return await searchUnsplashPhotos(deps.fetch, { key: saved, query, page: at }, options()) + }, + async trackDownload(downloadLocation) { + if (!isUnsplashDownloadLocation(downloadLocation)) return + const saved = await key() + if (!saved || take() !== null) return + await trackUnsplashPhotoDownload(deps.fetch, { key: saved, downloadLocation: downloadLocation as string }, options()) + } + } +} + /** The credit a picked photo leaves in the note. */ export function unsplashPhotoCredit(photo: UnsplashPhoto): NoteCoverCredit { return { name: photo.photographer.name, url: photo.photographer.profileUrl } From 0df65fce8f153f402dfd22f859ef8deda2e5b342 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 16:15:41 -0500 Subject: [PATCH 29/34] Feat(editor): a cover's frontmatter folds to one line in Edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Unsplash cover leaves four lines in a note's frontmatter, and the first is a URL a few hundred characters long. Adib opened such a note in Edit mode and called the properties card too much text to look at. The cover drawn above the note already shows the picture and its credit, so in Edit and in Split's editor (Live Preview) those lines now fold into one muted line in the card, where the first of them was: cover · Unsplash photo by Kalen Emsley cover · lisbon.jpg an image in the vault cover · Dusk, cover · The Great Wave a gradient, a gallery photo cover · image from example.com any other web image cover nothing more to say The other properties keep their rows, so a note with only cover keys shows a card of one line, and tags and due dates stay as they were. Which lines fold comes from the shared cover reader, not a second key list. readNoteCoverLines (shared-domain) finds the line of each key the cover owns (banner, banner_y, banner-y, banner_credit, banner_credit_url, or the cover family when a note uses Obsidian's cover:), with the indented lines a YAML value runs on to, and noteCoverFieldKeys is now the one list that setNoteCover and removeNoteCover clear too. The line names a gradient or a gallery photo by the title the picker shows, matched by the file name the pick wrote (builtinCoverForAsset). A cover nothing draws folds nothing, and neither does a pane with covers hidden in Settings, where the raw lines are the only place the cover shows. Source mode and Preview are unchanged. The lines open while the selection touches one of them and fold again when it leaves, the rule Live Preview uses for a heading's #, and a note being read (a phone with the keyboard down) keeps them folded. j/k and the arrow keys step into the folded line one logical line at a time, the way they step into a rendered math block: k from under the card lands on the last cover line, j from above on the first, with Vim on or off, in normal and insert mode. A banner_y written later under other keys takes no row and opens the same way. A click on the line opens it with the caret at the start of the first cover line. Opening the lines from below grew the card above the caret and drew the caret two to four rows lower than the line it stepped onto. The transaction that opens or closes the fold now notes where the head's line is drawn, and the scroll that follows puts it back there, after Vim's scrolloff. A note shorter than the window gets an empty box under the text to make that room, gone again once the view fits the note. At the very top of a note nothing can scroll further up, so closing the lines downward there still pulls the text up by the folded height. It is decoration only. The document never changes, so select-all, copy, find and undo see the raw lines, and no file is written. The in-app manual says all this in the two cover guides and the Note covers setting. Verified with unit tests (the line reader, the line's text for each cover kind, opening and folding, Vim and arrow stepping, the click, the scroll back), typecheck, test:run, the three Vim smoke suites, and 53 CDP checks on the built app: Vim on and off, insert mode, Split, source mode, a short note, a mixed note and a banner_y under tags. --- .../app-core/src/components/EditorPane.tsx | 28 +- .../app-core/src/lib/cm-cover-fold.test.ts | 346 +++++++++++++++ packages/app-core/src/lib/cm-cover-fold.ts | 411 ++++++++++++++++++ packages/app-core/src/lib/cm-math-nav.ts | 9 +- packages/app-core/src/lib/cm-note-cover.ts | 15 +- .../app-core/src/lib/cm-vim-display-line.ts | 8 +- packages/app-core/src/lib/help.ts | 6 +- .../app-core/src/lib/note-cover-gallery.ts | 8 + packages/app-core/src/styles/index.css | 13 + .../shared-domain/src/note-cover-gradients.ts | 14 +- packages/shared-domain/src/note-cover.test.ts | 38 ++ packages/shared-domain/src/note-cover.ts | 80 +++- 12 files changed, 939 insertions(+), 37 deletions(-) create mode 100644 packages/app-core/src/lib/cm-cover-fold.test.ts create mode 100644 packages/app-core/src/lib/cm-cover-fold.ts diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 464ec6de6..83de982c5 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -110,7 +110,7 @@ import { import { tags as t } from '@lezer/highlight' import { autocompletion } from '@codemirror/autocomplete' import { MIN_RIGHT_PANEL_WIDTH, noteDiskRevision, useStore } from '../store' -import type { LineNumberMode } from '../store' +import type { LineNumberMode, NoteCoverPlacement } from '../store' import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' import { livePreviewPlugin } from '../lib/cm-live-preview' @@ -159,7 +159,13 @@ import { EditorSelectionToolbar } from './EditorSelectionToolbar' import { CloudConflictNoteBanner } from './CloudConflictNoteBanner' import { createPortal } from 'react-dom' import { readNoteCover } from '@shared/note-cover' -import { editorShowsNoteCover, noteCoverExtension } from '../lib/cm-note-cover' +import { + editorShowsNoteCover, + noteCoverExtension, + pinnedNoteCoverExtension, + type NoteCoverSlotSink +} from '../lib/cm-note-cover' +import { coverFoldExtension } from '../lib/cm-cover-fold' import { NoteCover } from './NoteCover' import { AgentEditNoteBanner, @@ -480,6 +486,14 @@ function markdownEditingExtensions(showHeadingLevelLabels = false): Extension[] ] } +/** The editor's part of the note cover: the slot it is drawn in when it + * scrolls with the text, and in any placement that draws it, the notice + * that lets the cover's frontmatter fold (cm-cover-fold.ts). */ +function noteCoverEditorExtension(placement: NoteCoverPlacement, sink: NoteCoverSlotSink): Extension { + if (placement === 'scroll') return noteCoverExtension(sink) + return placement === 'pinned' ? pinnedNoteCoverExtension : [] +} + function markdownSyntaxHighlightExtensions(): Extension[] { return [ syntaxHighlighting(paperHighlight), @@ -509,6 +523,8 @@ function wysiwygExtensions( // Frontmatter renders as compact properties only while Live Preview is // on; with it off the block reads as plain --- markdown. (#616) frontmatterStyle, + // A cover's frontmatter lines fold to one line in that card. + coverFoldExtension, codeBlockFlairPlugin, // Table widgets are gated on a setting — off keeps tables as plain editable // markdown for full keyboard/Vim editing (#232). @@ -2170,9 +2186,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }), commentDecorationField, agentReviewCompartment.of([]), - noteCoverCompartment.of( - s0.noteCoverPlacement === 'scroll' ? noteCoverExtension(noteCoverSlotSink) : [] - ), + noteCoverCompartment.of(noteCoverEditorExtension(s0.noteCoverPlacement, noteCoverSlotSink)), wordWrapCompartment.of(s0.wordWrap ? EditorView.lineWrapping : []), scrolloffCompartment.of(scrollOff(s0.editorScrollOff)), markdownCompartment.of( @@ -2831,9 +2845,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const comp = noteCoverCompartmentRef.current if (!view || !comp) return view.dispatch({ - effects: comp.reconfigure( - noteCoverPlacement === 'scroll' ? noteCoverExtension(noteCoverSlotSink) : [] - ) + effects: comp.reconfigure(noteCoverEditorExtension(noteCoverPlacement, noteCoverSlotSink)) }) }, [noteCoverPlacement, noteCoverSlotSink]) useEffect(() => { diff --git a/packages/app-core/src/lib/cm-cover-fold.test.ts b/packages/app-core/src/lib/cm-cover-fold.test.ts new file mode 100644 index 000000000..013f21fed --- /dev/null +++ b/packages/app-core/src/lib/cm-cover-fold.test.ts @@ -0,0 +1,346 @@ +// @vitest-environment jsdom +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorSelection, EditorState, type Extension } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { selectAll } from '@codemirror/commands' +import { Vim, getCM, vim } from '@replit/codemirror-vim' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readNoteCover } from '@shared/note-cover' +import { coverFoldExtension, coverFoldLineRanges, coverFoldText } from './cm-cover-fold' +import { mathBlockArrowKeymap } from './cm-math-nav' +import { noteCoverExtension, pinnedNoteCoverExtension } from './cm-note-cover' +import { sourceRevealExtension } from './cm-source-reveal' +import { registerDisplayLineMotion } from './cm-vim-display-line' + +registerDisplayLineMotion() + +const UNSPLASH = + 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?ixid=M3w2NjQ3NzZ8MHwxfHNlYXJjaHwxfHxtb3VudGFpbnN8ZW58MHx8fHwxNzYxNjc0NDAwfDA&w=2400&q=80&auto=format' + +// 1-based lines: 1 `---`, 2 banner, 3 banner_credit, 4 banner_credit_url, 5 `---`, 6 `# Lake`, 7 ``, 8 body. +const ONLY_COVER = `---\nbanner: "${UNSPLASH}"\nbanner_credit: Kalen Emsley\nbanner_credit_url: "https://unsplash.com/@kalenemsley"\n---\n# Lake\n\nStill water.` + +// 1-based lines: 1 `---`, 2 tags, 3 due, 4 banner, 5 banner_credit, 6 banner_credit_url, 7 `---`, 8 body. +const MIXED = `---\ntags: [travel]\ndue: 2026-11-02\nbanner: "${UNSPLASH}"\nbanner_credit: Kalen Emsley\nbanner_credit_url: "https://unsplash.com/@kalenemsley"\n---\nPacking list.` + +// 1-based lines: 1 `---`, 2 banner, 3 banner_credit, 4 tags, 5 banner_y, 6 `---`, 7 body. +const SPLIT = `---\nbanner: "${UNSPLASH}"\nbanner_credit: Kalen Emsley\ntags: [travel]\nbanner_y: 0.35\n---\nBody.` + +const views: EditorView[] = [] + +function mount( + doc: string, + { + anchor = doc.length, + cover = noteCoverExtension(() => {}), + extra = [] as Extension[] + }: { anchor?: number; cover?: Extension; extra?: Extension[] } = {} +): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor }, + extensions: [markdown({ base: markdownLanguage }), cover, coverFoldExtension, ...extra] + }) + }) + forceParsing(view, doc.length, 5000) + views.push(view) + return view +} + +const lineAt = (view: EditorView, n: number): number => view.state.doc.line(n).from +const headLine = (view: EditorView): number => view.state.doc.lineAt(view.state.selection.main.head).number +const summary = (view: EditorView): string | null => + view.contentDOM.querySelector('[data-frontmatter-cover]')?.textContent ?? null +const shownText = (view: EditorView): string => view.contentDOM.textContent ?? '' + +beforeEach(() => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) +}) +afterEach(() => { + while (views.length) views.pop()!.destroy() + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +describe('what the folded line says', () => { + const text = (frontmatter: string): string => coverFoldText(readNoteCover(`---\n${frontmatter}\n---\n`)!) + + it('names the photographer of an Unsplash photo', () => { + expect(text(`banner: "${UNSPLASH}"\nbanner_credit: Kalen Emsley`)).toBe('cover · Unsplash photo by Kalen Emsley') + expect(text(`banner: "${UNSPLASH}"`)).toBe('cover · Unsplash photo') + }) + + it('names a vault image by its file name', () => { + expect(text('banner: "![[assets/lisbon.jpg]]"')).toBe('cover · lisbon.jpg') + expect(text('banner: "![](pics/my%20trip.png)"')).toBe('cover · my trip.png') + }) + + it('names a gradient or a gallery photo by its title', () => { + expect(text('banner: "![[assets/cover-gradient-dusk.svg]]"')).toBe('cover · Dusk') + expect(text('banner: "![[attachements/cover-great-wave.webp]]"')).toBe('cover · The Great Wave') + }) + + it('names the host of any other web image', () => { + expect(text('banner: "https://example.com/photos/a.jpg?w=1200"')).toBe('cover · image from example.com') + }) + + it('says just cover when there is nothing more to say', () => { + expect(text('banner: "![[assets/]]"')).toBe('cover') + }) +}) + +describe('folding a cover’s frontmatter', () => { + it('folds a cover-only frontmatter into one line in the card', () => { + const view = mount(ONLY_COVER) + expect(coverFoldLineRanges(view.state)).toEqual([{ fromLine: 2, toLine: 4 }]) + expect(summary(view)).toBe('cover · Unsplash photo by Kalen Emsley') + // The card's two fence rows and the one folded line. + const lines = [...view.contentDOM.querySelectorAll('.cm-line')].slice(0, 3) + expect(lines.map((line) => line.textContent)).toEqual(['---', 'cover · Unsplash photo by Kalen Emsley', '---']) + expect(shownText(view)).not.toContain('images.unsplash.com') + expect(shownText(view)).not.toContain('banner_credit') + }) + + it('keeps the other properties of a mixed note visible', () => { + const view = mount(MIXED) + expect(coverFoldLineRanges(view.state)).toEqual([{ fromLine: 4, toLine: 6 }]) + const lines = [...view.contentDOM.querySelectorAll('.cm-line')].slice(0, 5).map((line) => line.textContent) + expect(lines).toEqual(['---', 'tags: [travel]', 'due: 2026-11-02', 'cover · Unsplash photo by Kalen Emsley', '---']) + }) + + it('folds every cover line into the first, and a later one takes no row', () => { + const view = mount(SPLIT) + expect(coverFoldLineRanges(view.state)).toEqual([ + { fromLine: 2, toLine: 3 }, + { fromLine: 5, toLine: 5 } + ]) + const lines = [...view.contentDOM.querySelectorAll('.cm-line')].slice(0, 4).map((line) => line.textContent) + expect(lines).toEqual(['---', 'cover · Unsplash photo by Kalen Emsley', 'tags: [travel]', '---']) + }) + + it('changes nothing in the document', () => { + const view = mount(MIXED) + expect(view.state.doc.toString()).toBe(MIXED) + selectAll(view) + expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe(MIXED) + }) + + it('leaves the raw lines in an editor without it (source mode)', () => { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: ONLY_COVER, + extensions: [markdown({ base: markdownLanguage }), noteCoverExtension(() => {})] + }) + }) + views.push(view) + expect(summary(view)).toBeNull() + expect(shownText(view)).toContain('banner_credit: Kalen Emsley') + }) + + it('folds only where the pane draws the cover', () => { + expect(summary(mount(ONLY_COVER, { cover: pinnedNoteCoverExtension }))).toBe('cover · Unsplash photo by Kalen Emsley') + // Covers hidden in Settings: the raw lines are the only place it shows. + const hidden = mount(ONLY_COVER, { cover: [] }) + expect(summary(hidden)).toBeNull() + expect(coverFoldLineRanges(hidden.state)).toEqual([]) + expect(shownText(hidden)).toContain('banner_credit: Kalen Emsley') + }) + + it('folds a cover written by an edit, and opens when the cover goes', () => { + const view = mount('---\ntags: [a]\n---\nBody.') + expect(summary(view)).toBeNull() + view.dispatch({ changes: { from: lineAt(view, 3), insert: 'banner: "![[assets/lisbon.jpg]]"\n' } }) + expect(summary(view)).toBe('cover · lisbon.jpg') + view.dispatch({ changes: { from: lineAt(view, 3), to: lineAt(view, 4) } }) + expect(summary(view)).toBeNull() + }) + + it('folds once the closing fence is typed further down', () => { + const view = mount('---\nbanner: "![[assets/lisbon.jpg]]"\n\nBody.') + expect(summary(view)).toBeNull() + view.dispatch({ changes: { from: lineAt(view, 3), insert: '---' } }) + expect(summary(view)).toBe('cover · lisbon.jpg') + }) +}) + +describe('opening the folded lines', () => { + it('opens all of them while the selection touches one, and folds again when it leaves', () => { + const view = mount(SPLIT) + view.dispatch({ selection: { anchor: lineAt(view, 5) + 3 } }) + expect(summary(view)).toBeNull() + expect(coverFoldLineRanges(view.state)).toEqual([]) + expect(shownText(view)).toContain('banner_credit: Kalen Emsley') + expect(shownText(view)).toContain('banner_y: 0.35') + + view.dispatch({ selection: { anchor: lineAt(view, 4) } }) // tags, between the cover lines + expect(summary(view)).toBe('cover · Unsplash photo by Kalen Emsley') + + view.dispatch({ selection: EditorSelection.range(lineAt(view, 1), lineAt(view, 2)) }) // a range ending on one + expect(summary(view)).toBeNull() + }) + + it('stays folded while a note is read, and opens once the editor is being edited', () => { + const view = mount(ONLY_COVER, { anchor: 6, extra: [sourceRevealExtension] }) + expect(headLine(view)).toBe(2) + expect(summary(view)).toBe('cover · Unsplash photo by Kalen Emsley') + view.focus() + return Promise.resolve().then(() => { + expect(summary(view)).toBeNull() + }) + }) + + it('a click on the folded line opens it with the caret on the first cover line', () => { + const view = mount(MIXED, { anchor: MIXED.length }) + const line = view.contentDOM.querySelector('[data-frontmatter-cover]')! + line.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })) + expect(view.state.selection.main.head).toBe(lineAt(view, 4)) + expect(summary(view)).toBeNull() + }) +}) + +describe('stepping into the folded lines from the keyboard', () => { + beforeEach(() => { + // jsdom has no text layout: every pixel motion here falls back to a + // logical step and says so once, and the measure that reading the + // caret's place runs as the fold opens logs Vim's block cursor failing + // to measure itself. + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + const press = (view: EditorView, key: string): void => { + Vim.handleKey(getCM(view)!, key, 'user') + } + const arrow = (view: EditorView, key: 'ArrowDown' | 'ArrowUp'): void => { + view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + } + + it('vim j and k step into the fold one line at a time and through the opened lines', () => { + const view = mount(MIXED, { anchor: 0, extra: [vim()] }) + view.focus() + const visited: number[] = [] + for (let i = 0; i < 7; i++) { + press(view, 'j') + visited.push(headLine(view)) + } + expect(visited).toEqual([2, 3, 4, 5, 6, 7, 8]) + for (let i = 0; i < 7; i++) { + press(view, 'k') + visited.push(headLine(view)) + } + expect(visited.slice(7)).toEqual([7, 6, 5, 4, 3, 2, 1]) + }) + + it('vim k from under the card lands on the last cover line and opens the fold', () => { + const view = mount(MIXED, { anchor: EditorState.create({ doc: MIXED }).doc.line(7).from, extra: [vim()] }) + view.focus() + expect(summary(view)).not.toBeNull() + press(view, 'k') + expect(headLine(view)).toBe(6) + expect(summary(view)).toBeNull() + }) + + it('vim j and k reach a cover line folded further down', () => { + const view = mount(SPLIT, { anchor: 0, extra: [vim()] }) + view.focus() + view.dispatch({ selection: { anchor: lineAt(view, 4) } }) // tags + press(view, 'j') + expect(headLine(view)).toBe(5) + expect(summary(view)).toBeNull() + press(view, 'j') + expect(headLine(view)).toBe(6) + press(view, 'k') + expect(headLine(view)).toBe(5) + }) + + it('the arrow keys do the same with Vim off', () => { + const view = mount(MIXED, { anchor: 0, extra: [keymap.of([...mathBlockArrowKeymap])] }) + view.focus() + view.dispatch({ selection: { anchor: lineAt(view, 3) } }) // due + arrow(view, 'ArrowDown') + expect(headLine(view)).toBe(4) + expect(summary(view)).toBeNull() + + view.dispatch({ selection: { anchor: lineAt(view, 7) } }) // closing fence + expect(summary(view)).not.toBeNull() + arrow(view, 'ArrowUp') + expect(headLine(view)).toBe(6) + expect(summary(view)).toBeNull() + }) +}) + +describe('keeping the caret in place as the fold opens and closes', () => { + // jsdom has no layout: give the head's line a height on screen that + // depends on whether the lines are folded, the way the card grows above it. + function layout(view: EditorView, { folded, open }: { folded: number; open: number }): { scrolled: () => number } { + let scrollTop = 0 + Object.defineProperty(view, 'documentTop', { configurable: true, get: () => 50 - scrollTop }) + Object.defineProperty(view.scrollDOM, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + } + }) + vi.spyOn(view.scrollDOM, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 600, 400)) + vi.spyOn(view, 'lineBlockAtHeight').mockImplementation(() => view.lineBlockAt(0)) + vi.spyOn(view, 'lineBlockAt').mockImplementation(() => { + const top = coverFoldLineRanges(view.state).length ? folded : open + return { from: 0, to: 0, top, bottom: top + 20, height: 20 } as ReturnType<EditorView['lineBlockAt']> + }) + return { scrolled: () => scrollTop } + } + + const scrollFor = (view: EditorView, head: number): boolean => + view.state.facet(EditorView.scrollHandler).some((handler) => + handler(view, EditorSelection.cursor(head), { x: 'nearest', y: 'nearest', xMargin: 5, yMargin: 5 }) + ) + + it('scrolls the line the caret opens onto back to where the folded line was drawn', () => { + const view = mount(MIXED, { anchor: EditorState.create({ doc: MIXED }).doc.line(7).from }) + const { scrolled } = layout(view, { folded: 100, open: 160 }) + const head = lineAt(view, 6) + view.dispatch({ selection: { anchor: head } }) // k from the fence onto the last cover line + expect(summary(view)).toBeNull() + // Answered once, for the scroll codemirror-vim asks for after the motion. + expect(scrollFor(view, head)).toBe(true) + expect(scrolled()).toBe(60) + expect(scrollFor(view, head)).toBe(false) + }) + + it('does the same as the lines fold again under the caret', () => { + const view = mount(MIXED, { anchor: 0 }) + view.dispatch({ selection: { anchor: lineAt(view, 6) } }) + const { scrolled } = layout(view, { folded: 100, open: 160 }) + const head = lineAt(view, 7) + view.dispatch({ selection: { anchor: head } }) // j onto the closing fence + expect(summary(view)).not.toBeNull() + expect(scrollFor(view, head)).toBe(true) + expect(scrolled()).toBe(-60) + }) + + it('leaves scrolling alone for a move that keeps the fold as it was', () => { + const view = mount(MIXED, { anchor: 0 }) + layout(view, { folded: 100, open: 160 }) + const head = lineAt(view, 8) + view.dispatch({ selection: { anchor: head } }) + expect(scrollFor(view, head)).toBe(false) + }) + + it('leaves scrolling alone when the line was not on screen', () => { + const view = mount(MIXED, { anchor: 0 }) + layout(view, { folded: 900, open: 960 }) + const head = lineAt(view, 6) + view.dispatch({ selection: { anchor: head } }) + expect(summary(view)).toBeNull() + expect(scrollFor(view, head)).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/cm-cover-fold.ts b/packages/app-core/src/lib/cm-cover-fold.ts new file mode 100644 index 000000000..09d8d66a2 --- /dev/null +++ b/packages/app-core/src/lib/cm-cover-fold.ts @@ -0,0 +1,411 @@ +/** + * A note cover's frontmatter, folded to one line in Live Preview. + * + * A cover is plain frontmatter (`banner`, `banner_y`, and for an Unsplash + * photo `banner_credit` and `banner_credit_url`), and an Unsplash URL alone + * runs to a few hundred characters. The cover drawn above the note already + * shows the picture and its credit, so in Edit and in Split's editor those + * lines fold into one muted line in the properties card, `cover · Unsplash + * photo by Jane Doe`, at the place of the first of them. The other properties + * stay as they were. + * + * Which lines are the cover's, and what the line says, come from the shared + * cover reader (`readNoteCoverLines` in shared-domain), so the fold cannot + * disagree with the cover drawn above it: no cover drawn, nothing folds. A + * pane with covers hidden in Settings keeps the raw lines, since they are then + * the only place the cover shows. + * + * The fold is decoration only. The document never changes, so select-all, + * copy, find and undo all see the raw text. The lines open for editing + * whenever the selection touches one of them (an arrow key, `j`/`k`, a click + * on the line, a search hit) and fold again once it leaves, the same rule + * Live Preview uses to show a heading's `#` (cm-source-reveal.ts): a note + * opened to be read keeps them folded until the editor takes the focus. + * + * Getting in from the keyboard: the line stands for several lines, and a + * pixel-based vertical motion would land on either end of it, or skip the + * cover lines a fold hides further down. So `j`/`k` (cm-vim-display-line.ts) + * and the arrow keys (cm-math-nav.ts) step into a folded line logically, one + * line at a time, the way they step into a rendered math block. Opening or + * closing the fold changes the card's height above the caret, which would + * shift the caret on screen, so the editor scrolls the caret's line back to + * where it was drawn (`keepCaretInPlace`). + */ +import { + EditorSelection, + EditorState, + StateField, + type Extension, + type SelectionRange, + type Text, + type Transaction +} from '@codemirror/state' +import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view' +import { isFrontmatterFence } from '@shared/markdown-lines' +import { readNoteCoverLines, type NoteCover } from '@shared/note-cover' +import { isUnsplashImageUrl } from '@shared/unsplash' +import { frontmatterEnd, noteCoverDrawn } from './cm-note-cover' +import { builtinCoverForAsset } from './note-cover-gallery' +import { revealedSelectionRanges } from './cm-source-reveal' + +/** The word the folded line starts with, styled as a property key. */ +const COVER_LABEL = 'cover' + +/** A run of consecutive cover lines, 1-based document line numbers. */ +export interface CoverFoldLineRange { + fromLine: number + toLine: number +} + +interface CoverFoldInfo { + /** In document order; the folded line stands in place of the first. */ + runs: readonly CoverFoldLineRange[] + text: string +} + +interface CoverFoldValue { + info: CoverFoldInfo | null + /** End of the frontmatter, so an edit below it skips the re-read. */ + end: number + folded: boolean + decorations: DecorationSet +} + +function webHost(url: string): string | null { + try { + return new URL(url).hostname || null + } catch { + return null + } +} + +function vaultFileName(href: string): string { + const name = href.slice(href.lastIndexOf('/') + 1) + try { + return decodeURIComponent(name) + } catch { + return name + } +} + +/** What the folded line says after `cover`, or null for nothing more. */ +function coverFoldDetail(cover: NoteCover): string | null { + const { target } = cover + if (target.kind === 'url') { + if (isUnsplashImageUrl(target.url)) { + return cover.credit ? `Unsplash photo by ${cover.credit.name}` : 'Unsplash photo' + } + const host = webHost(target.url) + return host ? `image from ${host}` : null + } + // A gradient or a gallery photo is known by the file name its pick wrote. + const builtin = builtinCoverForAsset(target.href) + if (builtin) return builtin.title + return vaultFileName(target.href) || null +} + +/** The folded line's text: `cover · Unsplash photo by Jane Doe`, `cover · + * lisbon.jpg`, `cover · Dusk`, `cover · image from example.com`, or + * `cover`. */ +export function coverFoldText(cover: NoteCover): string { + const detail = coverFoldDetail(cover) + return detail ? `${COVER_LABEL} · ${detail}` : COVER_LABEL +} + +function readFoldInfo(state: EditorState): { info: CoverFoldInfo | null; end: number } { + const end = frontmatterEnd(state) + if (end === 0 || !state.facet(noteCoverDrawn)) return { info: null, end } + const found = readNoteCoverLines(`${state.sliceDoc(0, end)}\n`) + if (!found || found.lines.length === 0) return { info: null, end } + const runs: CoverFoldLineRange[] = [] + for (const bodyLine of found.lines) { + // The reader counts the opening fence as line 0; the document, as line 1. + const line = bodyLine + 1 + const last = runs[runs.length - 1] + if (last && last.toLine === line - 1) last.toLine = line + else runs.push({ fromLine: line, toLine: line }) + } + return { info: { runs, text: coverFoldText(found.cover) }, end } +} + +function touchesCoverLines(doc: Text, info: CoverFoldInfo, ranges: readonly SelectionRange[]): boolean { + for (const run of info.runs) { + const from = doc.line(run.fromLine).from + const to = doc.line(run.toLine).to + if (ranges.some((range) => range.from <= to && range.to >= from)) return true + } + return false +} + +function foldedIn(state: EditorState, info: CoverFoldInfo | null): boolean { + return !!info && !touchesCoverLines(state.doc, info, revealedSelectionRanges(state)) +} + +class CoverFoldWidget extends WidgetType { + constructor(readonly text: string) { + super() + } + + eq(other: CoverFoldWidget): boolean { + return other.text === this.text + } + + toDOM(): HTMLElement { + const line = document.createElement('span') + line.className = 'cm-frontmatter-cover' + line.setAttribute('data-frontmatter-cover', '') + line.title = this.text + const label = document.createElement('span') + label.className = 'cm-frontmatter-key' + label.textContent = COVER_LABEL + line.append(label, this.text.slice(COVER_LABEL.length)) + return line + } + + // The editor takes the click (below), so it must see the event. + ignoreEvent(): boolean { + return false + } +} + +const HIDDEN_COVER_LINES = Decoration.replace({}) + +function foldDecorations(doc: Text, info: CoverFoldInfo): DecorationSet { + return Decoration.set( + info.runs.map((run, index) => { + const last = doc.line(run.toLine) + if (index === 0) { + return Decoration.replace({ widget: new CoverFoldWidget(info.text) }).range(doc.line(run.fromLine).from, last.to) + } + // A later run (`banner_y` written under keys added after the cover) + // goes away with the line break above it, so it takes no row. + return HIDDEN_COVER_LINES.range(doc.line(run.fromLine - 1).to, last.to) + }) + ) +} + +function foldValue(state: EditorState, info: CoverFoldInfo | null, end: number): CoverFoldValue { + const folded = foldedIn(state, info) + return { info, end, folded, decorations: folded && info ? foldDecorations(state.doc, info) : Decoration.none } +} + +/** Whether an edit can change what the frontmatter says. */ +function frontmatterMayChange(tr: Transaction, end: number): boolean { + if (end > 0) return tr.changes.touchesRange(0, Math.min(tr.startState.doc.length, end + 1)) !== false + // No closed block yet: the closing fence can be typed anywhere under an + // opening one. + return isFrontmatterFence(tr.state.doc.line(1).text) +} + +const coverFoldField = StateField.define<CoverFoldValue>({ + create(state) { + const { info, end } = readFoldInfo(state) + return foldValue(state, info, end) + }, + update(value, tr) { + let { info, end } = value + if ( + tr.startState.facet(noteCoverDrawn) !== tr.state.facet(noteCoverDrawn) || + (tr.docChanged && frontmatterMayChange(tr, value.end)) + ) { + ;({ info, end } = readFoldInfo(tr.state)) + } else if (tr.docChanged) { + end = tr.changes.mapPos(end) + } + if (info === value.info && foldedIn(tr.state, info) === value.folded) { + return tr.docChanged ? { ...value, end, decorations: value.decorations.map(tr.changes) } : value + } + return foldValue(tr.state, info, end) + }, + provide: (field) => EditorView.decorations.from(field, (value) => value.decorations) +}) + +/** The folded cover lines, for the motions that step into them a line at a + * time; empty while they are open, or when nothing folds. */ +export function coverFoldLineRanges(state: EditorState): readonly CoverFoldLineRange[] { + const value = state.field(coverFoldField, false) + return value?.folded && value.info ? value.info.runs : [] +} + +// A click on the folded line opens it with the caret at the start of the +// first cover line, where the line was drawn. Left to the editor, the click +// would land on either end of the hidden text, and the end is a line or two +// further down once the lines open. +const coverFoldClick = EditorView.domEventHandlers({ + mousedown(event, view) { + if (event.button !== 0 || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return false + const target = event.target as Element | null + if (!target?.closest?.('[data-frontmatter-cover]')) return false + const value = view.state.field(coverFoldField, false) + if (!value?.folded || !value.info) return false + view.dispatch({ + selection: EditorSelection.cursor(view.state.doc.line(value.info.runs[0].fromLine).from), + userEvent: 'select.pointer' + }) + view.focus() + return true + } +}) + +/** Between the transaction that opens or closes the fold and the measure + * that scrolls for it. The transaction extender below needs the view, to + * read where the caret's line is drawn before the layout changes. */ +interface CaretAnchor { + view: EditorView | null + /** The head the fold opened or closed around, and the screen height its + * line is to be drawn at. */ + pending: { head: number; top: number; until: number } | null +} + +const caretAnchorField = StateField.define<CaretAnchor>({ + create: () => ({ view: null, pending: null }), + update: (value) => value +}) + +/** + * Room to scroll. A note shorter than the window cannot scroll, so opening + * the fold from below would still push the caret down, and closing it after + * a scroll would let the browser clamp the scroll back and drop the text. So + * when the caret's line needs a scroll the note is too short for, an empty + * box below the text makes the room, and goes again once the view is back + * inside the note's own height. It only ever covers blank editor background. + */ +class CaretAnchorView { + private room: HTMLElement | null = null + + constructor(readonly view: EditorView) { + view.state.field(caretAnchorField).view = view + } + + update(update: ViewUpdate): void { + update.state.field(caretAnchorField).view = update.view + if (this.room) this.releaseRoom() + } + + /** Scroll to `top`, making room below the note when it is too short. */ + scrollTo(top: number): void { + const scroller = this.view.scrollDOM + if (top > scroller.scrollHeight - scroller.clientHeight) { + if (!this.room) { + this.room = document.createElement('div') + this.room.setAttribute('aria-hidden', 'true') + this.room.setAttribute('data-cover-fold-room', '') + this.room.style.cssText = 'position:absolute;left:0;width:1px;height:1px;pointer-events:none;visibility:hidden' + scroller.appendChild(this.room) + } + this.room.style.top = `${Math.ceil(top + scroller.clientHeight) - 1}px` + } + scroller.scrollTop = top + } + + /** Drop the room once the scroll position fits the note without it. */ + releaseRoom(): void { + this.view.requestMeasure({ + key: this, + read: (view) => { + if (!this.room) return false + // The note's own height, read without taking the room away: hiding + // it for the reading would let the browser clamp the scroll. + const scroller = view.scrollDOM + const content = view.contentDOM + const height = content.offsetTop + content.offsetHeight + (parseFloat(getComputedStyle(scroller).paddingBottom) || 0) + return scroller.scrollTop <= Math.max(0, height - scroller.clientHeight) + 1 + }, + write: (fits) => { + if (!fits || !this.room) return + this.room.remove() + this.room = null + } + }) + } + + destroy(): void { + this.room?.remove() + const anchor = this.view.state.field(caretAnchorField, false) + if (anchor?.view === this.view) anchor.view = null + } +} + +const caretAnchorView = ViewPlugin.fromClass(CaretAnchorView, { + eventObservers: { + scroll() { + this.releaseRoom() + } + } +}) + +/** + * Opening or closing the fold grows or shrinks the card above the caret: `k` + * from under the card onto its last cover line would draw the caret a line or + * two lower than the folded line it stepped onto, and leaving the lines + * downward would pull it up. So the transaction that opens or closes them + * notes the screen height the head's line is drawn at now (for a head on the + * folded line, that line's), and asks for a scroll, which the handler below + * answers once the new layout is measured by putting the line back there. It + * answers the first scroll request for that head, whoever asks + * (codemirror-vim asks again after every motion), and runs after Vim's + * scrolloff in the same measure, so its scroll is the one that stands. A + * note scrolled to its very top has nothing above to scroll into, so there + * closing the lines under the caret still pulls the text up. + */ +const keepCaretInPlace = EditorState.transactionExtender.of((tr) => { + const anchor = tr.startState.field(caretAnchorField, false) + const view = anchor?.view + if (!anchor || !view || !tr.selection || tr.docChanged || tr.reconfigured) return null + const head = tr.newSelection.main.head + if (anchor.pending?.head !== head) anchor.pending = null + const value = tr.startState.field(coverFoldField, false) + if (!value?.info) return null + // A note being read keeps its cover lines folded wherever the caret is. + const reading = revealedSelectionRanges(tr.startState).length === 0 + const folded = reading || !touchesCoverLines(tr.startState.doc, value.info, tr.newSelection.ranges) + if (folded === value.folded) return null + let top: number + let bottom: number + let scroller: DOMRect + try { + // Settle a measure still pending from a key pressed in the same frame + // (any layout query does), so the heights read here are the ones on + // screen. + view.lineBlockAtHeight(0) + const block = view.lineBlockAt(head) + top = view.documentTop + block.top + bottom = view.documentTop + block.bottom + scroller = view.scrollDOM.getBoundingClientRect() + } catch { + // A transaction built while the view is updating cannot read layout. + return null + } + // Off screen, the editor's own scrolling applies. + if (top < scroller.top || bottom > scroller.bottom) return null + // A cover line folded further down is drawn at the end of the line above + // it: stepping down onto it opens it under that line, stepping up, in its + // place. + const headLine = tr.startState.doc.lineAt(head).number + const laterRun = value.folded && value.info.runs.findIndex((run) => headLine >= run.fromLine && headLine <= run.toLine) > 0 + const fromAbove = tr.startState.selection.main.head < head + anchor.pending = { head, top: laterRun && fromAbove ? bottom : top, until: Date.now() + 1000 } + return { effects: EditorView.scrollIntoView(head) } +}) + +const caretAnchorScroll = EditorView.scrollHandler.of((view, range) => { + const anchor = view.state.field(caretAnchorField, false) + const pending = anchor?.pending + const plugin = view.plugin(caretAnchorView) + if (!anchor || !pending || !plugin) return false + anchor.pending = null + if (range.head !== pending.head || Date.now() > pending.until) return false + plugin.scrollTo(view.scrollDOM.scrollTop + view.documentTop + view.lineBlockAt(pending.head).top - pending.top) + return true +}) + +/** Live Preview's cover fold. Folds only in an editor that draws the note's + * cover (`noteCoverDrawn`, from the pane's cover placement). */ +export const coverFoldExtension: Extension = [ + coverFoldField, + coverFoldClick, + caretAnchorField, + caretAnchorView, + keepCaretInPlace, + caretAnchorScroll +] diff --git a/packages/app-core/src/lib/cm-math-nav.ts b/packages/app-core/src/lib/cm-math-nav.ts index d8d942146..ab1cb46b9 100644 --- a/packages/app-core/src/lib/cm-math-nav.ts +++ b/packages/app-core/src/lib/cm-math-nav.ts @@ -6,14 +6,16 @@ * (`cursorLineUp`/`cursorLineDown`) skips clean over it — only a mouse click * could reveal the source. When the next logical line sits inside a rendered * block, step the cursor there directly; cm-math-render reveals the source in - * the same transaction. Covers insert-mode and non-Vim editing; Vim's `j`/`k` - * get the equivalent treatment in cm-vim-display-line.ts. + * the same transaction. A cover's folded frontmatter lines (cm-cover-fold.ts) + * are stepped into the same way. Covers insert-mode and non-Vim editing; + * Vim's `j`/`k` get the equivalent treatment in cm-vim-display-line.ts. */ import { completionStatus } from '@codemirror/autocomplete' import type { EditorView, KeyBinding } from '@codemirror/view' import { mathBlockLineRanges } from './cm-math-render' import { embedBlockLineRanges } from './cm-embed-render' import { mermaidBlockLineRanges } from './cm-mermaid-render' +import { coverFoldLineRanges } from './cm-cover-fold' function moveIntoRenderedMathBlock(view: EditorView, dir: 1 | -1): boolean { const state = view.state @@ -27,7 +29,8 @@ function moveIntoRenderedMathBlock(view: EditorView, dir: 1 | -1): boolean { const block = [ ...mathBlockLineRanges(state), ...embedBlockLineRanges(state), - ...mermaidBlockLineRanges(state) + ...mermaidBlockLineRanges(state), + ...coverFoldLineRanges(state) ].find((r) => targetNumber >= r.fromLine && targetNumber <= r.toLine) if (!block) return false // Inside the same (already revealed) block the default motion works; only diff --git a/packages/app-core/src/lib/cm-note-cover.ts b/packages/app-core/src/lib/cm-note-cover.ts index 7f1960666..e4b5342df 100644 --- a/packages/app-core/src/lib/cm-note-cover.ts +++ b/packages/app-core/src/lib/cm-note-cover.ts @@ -29,6 +29,14 @@ import { readNoteCover } from '@shared/note-cover' /** Told about each slot element the widget creates (`alive`) or destroys. */ export type NoteCoverSlotSink = (slot: HTMLElement, alive: boolean) => void +/** True in an editor whose pane draws the note's cover, in the slot above the + * first line or pinned above the editor. The cover's frontmatter folds only + * there (cm-cover-fold.ts): with covers hidden, those lines are the only + * place the cover shows. */ +export const noteCoverDrawn = Facet.define<boolean, boolean>({ + combine: (values) => values.some(Boolean) +}) + const noteCoverSlotSink = Facet.define<NoteCoverSlotSink, NoteCoverSlotSink | null>({ combine: (sinks) => sinks[0] ?? null }) @@ -62,7 +70,7 @@ class NoteCoverSlotWidget extends WidgetType { /** Where the leading frontmatter ends, or 0 without one. Same fence rule as * the frontmatter card, so a cover never shows for a block the editor does * not treat as frontmatter. */ -function frontmatterEnd(state: EditorState): number { +export function frontmatterEnd(state: EditorState): number { const doc = state.doc if (doc.lines < 2 || !isFrontmatterFence(doc.line(1).text)) return 0 for (let i = 2; i <= doc.lines; i++) { @@ -162,5 +170,8 @@ export function editorShowsNoteCover(state: EditorState): boolean { /** The cover slot, for an editor whose pane portals the cover into it. */ export function noteCoverExtension(sink: NoteCoverSlotSink): Extension { - return [noteCoverSlotSink.of(sink), noteCoverField, coverBleed] + return [noteCoverSlotSink.of(sink), noteCoverField, coverBleed, noteCoverDrawn.of(true)] } + +/** For an editor whose pane pins the cover above it instead. */ +export const pinnedNoteCoverExtension: Extension = noteCoverDrawn.of(true) diff --git a/packages/app-core/src/lib/cm-vim-display-line.ts b/packages/app-core/src/lib/cm-vim-display-line.ts index f493f4f26..de7bb617f 100644 --- a/packages/app-core/src/lib/cm-vim-display-line.ts +++ b/packages/app-core/src/lib/cm-vim-display-line.ts @@ -6,6 +6,7 @@ import { displayRowEdge } from './cm-display-row' import { mathBlockLineRanges } from './cm-math-render' import { embedBlockLineRanges } from './cm-embed-render' import { mermaidBlockLineRanges } from './cm-mermaid-render' +import { coverFoldLineRanges } from './cm-cover-fold' // Minimal shape of the CodeMirror-Vim adapter + state the display-line motion // touches (the package's own types don't surface these helpers). @@ -173,7 +174,9 @@ function isWrapPoint(view: EditorView, offset: number): boolean { * has no cursor coordinates, so it would skip clean over the block — and the * reveal-induced height changes made consecutive blocks compound into * multi-line jumps. Inside an already-revealed block (plain source text), - * normal display-line movement applies. + * normal display-line movement applies. A cover's folded frontmatter lines + * (cm-cover-fold) are stepped into the same way: the folded line stands + * for several lines, and lines folded further down take no row at all. * `gj`/`gk` are untouched. Mirrors codemirror-vim's own `moveByDisplayLines`, * including maintaining the horizontal goal column across consecutive presses. * The whole pixel path runs inside `pixelMotionFallback` (#574): if any CM6 @@ -217,7 +220,8 @@ export function zenMoveByDisplayLine( ? [ ...mathBlockLineRanges(cm.cm6.state), ...embedBlockLineRanges(cm.cm6.state), - ...mermaidBlockLineRanges(cm.cm6.state) + ...mermaidBlockLineRanges(cm.cm6.state), + ...coverFoldLineRanges(cm.cm6.state) ] : [] const logicalTarget = Math.max( diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index cf5b801a9..0e5f8e5cb 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -144,12 +144,12 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Give a note a cover image', body: - 'A cover is a wide banner image across the top of a note. Press `Space l c` in Vim mode, run “Add Cover…” from the command palette, or type `:cover` to open the cover picker. Type to search it, move with the arrow keys and press Enter to pick a gradient, a photograph from the gallery (public-domain works from The Met and NASA), or an image already in the vault. Upload image… takes a file from disk, and an image you paste (⌘V / Ctrl+V) or drop anywhere in the picker becomes the cover; paste an https link into the search field to use a picture from the web, or press Tab for the Unsplash tab and its free photos (see the next guide). Gradients, gallery photos and uploads are copied into the vault’s `assets/` folder like a pasted image, so the cover works offline, on a self-hosted server and through Cloud sync, end-to-end encrypted vaults included. Hover the cover for Change cover, Reposition and Remove, or drop an image straight onto it. Reposition (also “Reposition Cover” in the palette and `:cover reposition`) lets you drag the picture up or down, or move it with ↑/↓ (j/k in Vim mode, Shift for small steps); Enter keeps the new position and Escape puts the old one back. The cover is plain frontmatter, `banner: "![[assets/photo.jpg]]"` with the focal point in `banner_y` (0 is the top edge, 1 the bottom), the keys Obsidian’s banner plugins read, so a shared vault shows the same banner there; a `cover:` image, or one written as a plain `[[link]]`, shows too. The Assets view counts a cover image as used, renaming or moving the image updates the note, and `u` (or Undo) takes back a cover change made in the editor. Settings → Typography → Note covers decides whether the cover scrolls away with the text, stays pinned above the editor and Read view, or is hidden. A published note’s public page shows its cover too, and so does Quick Look when you press Space on a note in Finder on macOS: both draw it read-only at the note’s focal point. Quick Look follows the Hidden setting and leaves out a cover from the web, since a preview never goes online.' + 'A cover is a wide banner image across the top of a note. Press `Space l c` in Vim mode, run “Add Cover…” from the command palette, or type `:cover` to open the cover picker. Type to search it, move with the arrow keys and press Enter to pick a gradient, a photograph from the gallery (public-domain works from The Met and NASA), or an image already in the vault. Upload image… takes a file from disk, and an image you paste (⌘V / Ctrl+V) or drop anywhere in the picker becomes the cover; paste an https link into the search field to use a picture from the web, or press Tab for the Unsplash tab and its free photos (see the next guide). Gradients, gallery photos and uploads are copied into the vault’s `assets/` folder like a pasted image, so the cover works offline, on a self-hosted server and through Cloud sync, end-to-end encrypted vaults included. Hover the cover for Change cover, Reposition and Remove, or drop an image straight onto it. Reposition (also “Reposition Cover” in the palette and `:cover reposition`) lets you drag the picture up or down, or move it with ↑/↓ (j/k in Vim mode, Shift for small steps); Enter keeps the new position and Escape puts the old one back. The cover is plain frontmatter, `banner: "![[assets/photo.jpg]]"` with the focal point in `banner_y` (0 is the top edge, 1 the bottom), the keys Obsidian’s banner plugins read, so a shared vault shows the same banner there; a `cover:` image, or one written as a plain `[[link]]`, shows too. In Edit and Split those lines fold into one muted line in the note’s properties, such as `cover · lisbon.jpg` or `cover · Unsplash photo by Jane Doe`, since the cover above already shows the picture; the other properties stay as they are. Move the cursor onto that line (the arrow keys, or `j`/`k` in Vim mode) or click it and the raw lines open for editing; they fold again when the cursor leaves. The file itself never changes, so copying, searching and undo work on the lines as written, and with Live Preview off or covers hidden in Settings they show in full. The Assets view counts a cover image as used, renaming or moving the image updates the note, and `u` (or Undo) takes back a cover change made in the editor. Settings → Typography → Note covers decides whether the cover scrolls away with the text, stays pinned above the editor and Read view, or is hidden. A published note’s public page shows its cover too, and so does Quick Look when you press Space on a note in Finder on macOS: both draw it read-only at the note’s focal point. Quick Look follows the Hidden setting and leaves out a cover from the web, since a preview never goes online.' }, { title: 'Use an Unsplash photo as a cover', body: - 'The cover picker has a second tab, Unsplash, that searches Unsplash’s free photos with an access key of your own. The first time, the tab says how to get one: at unsplash.com/developers, open Your apps, create a New Application (a free Unsplash account is enough) and copy its Access Key; then press Enter on Open Settings and paste the key into Settings → Typography → Covers → Unsplash access key (“Set Unsplash Access Key…” in the command palette goes there too). Save checks the key with one request to Unsplash and keeps it unless Unsplash refuses it, Test asks again and says how many of the hour’s requests are left, and Clear removes it. The desktop app keeps the key in your system’s secure storage and the web client in your browser, never in `config.toml` or the vault, and sends it only to api.unsplash.com: searches go from your machine straight to Unsplash, and zennotes.org takes no part. A new Unsplash app allows 50 requests an hour until Unsplash approves it for production; when they run out the tab says so, and if Unsplash stops accepting the key it offers Change key. Press Tab in the picker to switch between Covers and Unsplash (what you typed comes along), press `Space l u` in Vim mode, run “Add Cover from Unsplash…” from the command palette, or type `:cover unsplash mountains` to open the tab on a search. Photos appear as you type; move with the arrow keys (or Ctrl+N/P, Ctrl+J/K) and press Enter to make the selected one the cover. The line under the grid credits the selected photo’s photographer, with links to their profile and to Unsplash. Unsplash’s rules keep its photos on Unsplash, so unlike a gradient or a gallery photo the picture is not copied into `assets/`: the note links to it, `banner: "https://images.unsplash.com/…"`, with the credit on the two lines under it, `banner_credit` (the photographer) and `banner_credit_url` (their Unsplash profile). Hover the cover to see “Photo by … on Unsplash” with both links; a published note’s page always shows it. The picture needs a connection to load. One you have seen before usually still shows offline; otherwise the cover says it could not load the picture from images.unsplash.com. To keep a photo for offline use, download it from unsplash.com and drop it on the picker. The Covers tab never goes online. Choosing another cover or removing this one takes the credit away with it.' + 'The cover picker has a second tab, Unsplash, that searches Unsplash’s free photos with an access key of your own. The first time, the tab says how to get one: at unsplash.com/developers, open Your apps, create a New Application (a free Unsplash account is enough) and copy its Access Key; then press Enter on Open Settings and paste the key into Settings → Typography → Covers → Unsplash access key (“Set Unsplash Access Key…” in the command palette goes there too). Save checks the key with one request to Unsplash and keeps it unless Unsplash refuses it, Test asks again and says how many of the hour’s requests are left, and Clear removes it. The desktop app keeps the key in your system’s secure storage and the web client in your browser, never in `config.toml` or the vault, and sends it only to api.unsplash.com: searches go from your machine straight to Unsplash, and zennotes.org takes no part. A new Unsplash app allows 50 requests an hour until Unsplash approves it for production; when they run out the tab says so, and if Unsplash stops accepting the key it offers Change key. Press Tab in the picker to switch between Covers and Unsplash (what you typed comes along), press `Space l u` in Vim mode, run “Add Cover from Unsplash…” from the command palette, or type `:cover unsplash mountains` to open the tab on a search. Photos appear as you type; move with the arrow keys (or Ctrl+N/P, Ctrl+J/K) and press Enter to make the selected one the cover. The line under the grid credits the selected photo’s photographer, with links to their profile and to Unsplash. Unsplash’s rules keep its photos on Unsplash, so unlike a gradient or a gallery photo the picture is not copied into `assets/`: the note links to it, `banner: "https://images.unsplash.com/…"`, with the credit on the two lines under it, `banner_credit` (the photographer) and `banner_credit_url` (their Unsplash profile). In the editor the three lines fold into one, `cover · Unsplash photo by …`, until the cursor moves onto it. Hover the cover to see “Photo by … on Unsplash” with both links; a published note’s page always shows it. The picture needs a connection to load. One you have seen before usually still shows offline; otherwise the cover says it could not load the picture from images.unsplash.com. To keep a photo for offline use, download it from unsplash.com and drop it on the picker. The Covers tab never goes online. Choosing another cover or removing this one takes the credit away with it.' }, { title: 'Give a vault a name of its own', @@ -1269,7 +1269,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Font size and line height', detail: 'Tune reading density in the editor and preview.' }, { label: 'Reading width and editor width', detail: 'Cap long lines so wide windows stay readable.' }, { label: 'Content alignment', detail: 'Center note content in its column or left-align it to the pane edge.' }, - { label: 'Note covers', detail: 'Where a note’s cover image shows: on top of the text, scrolling away with it (the default); pinned above the editor and Read view so it stays in sight; or hidden. Hidden only stops drawing covers; the `banner` frontmatter stays in the note, and Quick Look on macOS follows it too.' }, + { label: 'Note covers', detail: 'Where a note’s cover image shows: on top of the text, scrolling away with it (the default); pinned above the editor and Read view so it stays in sight; or hidden. Hidden only stops drawing covers; the `banner` frontmatter stays in the note, the editor shows its lines in full instead of folding them into one `cover` line, and Quick Look on macOS follows it too.' }, { label: 'Line numbers', detail: 'Switch between off, absolute, and relative gutter numbering.' }, { label: 'Line number position', detail: 'Keep the gutter next to the centered text or pin it to the editor edge.' } ] diff --git a/packages/app-core/src/lib/note-cover-gallery.ts b/packages/app-core/src/lib/note-cover-gallery.ts index f3670e910..b7b912514 100644 --- a/packages/app-core/src/lib/note-cover-gallery.ts +++ b/packages/app-core/src/lib/note-cover-gallery.ts @@ -20,6 +20,7 @@ import rockyMountainsThumb from '../assets/covers/thumbs/rocky-mountains.webp' import wheatFieldThumb from '../assets/covers/thumbs/wheat-field.webp' import { NOTE_COVER_GRADIENTS, + builtinCoverFileKey, gradientCoverDataUrl, gradientCoverFileName, gradientCoverSvg @@ -136,3 +137,10 @@ export const PHOTO_COVERS: BuiltinCover[] = [ () => import('../assets/covers/blue-marble.webp?inline') ) ] + +/** The built-in cover a vault reference names by the file name a pick wrote, + * in whichever folder it landed, or null for any other image. */ +export function builtinCoverForAsset(ref: string): BuiltinCover | null { + const name = builtinCoverFileKey(ref) + return [...GRADIENT_COVERS, ...PHOTO_COVERS].find((cover) => cover.fileName === name) ?? null +} diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index dcf347f46..68140a912 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -2268,6 +2268,19 @@ html[data-completed-task-style="gray-strikethrough"] .cm-editor .cm-task-done * border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } +/* A cover's lines folded into one (cm-cover-fold.ts): muted like a key, and + * always one row. A name too long for the card ends in an ellipsis, with the + * whole line in the tooltip. */ +.cm-editor .cm-frontmatter-cover { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: top; + color: rgb(var(--z-grey-1)); + cursor: text; +} .cm-editor .tok-heading, .cm-editor .tok-heading1, .cm-editor .tok-heading2, diff --git a/packages/shared-domain/src/note-cover-gradients.ts b/packages/shared-domain/src/note-cover-gradients.ts index bcc343727..74569eaca 100644 --- a/packages/shared-domain/src/note-cover-gradients.ts +++ b/packages/shared-domain/src/note-cover-gradients.ts @@ -54,9 +54,10 @@ export function gradientCoverDataUrl(gradient: NoteCoverGradient): string { return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(gradientCoverSvg(gradient))}` } -/** The built-in gradient a vault reference names by its file name, wherever - * the assets folder lives, or null for any other file. */ -export function builtinGradientForAsset(ref: string): NoteCoverGradient | null { +/** The file name a vault reference ends in, the way a built-in cover is + * known by it: wherever the assets folder lives, lower-cased, decoded, and + * without a query or fragment. */ +export function builtinCoverFileKey(ref: string): string { const path = ref.split(/[?#]/, 1)[0] ?? ref let name = path.slice(path.lastIndexOf('/') + 1).toLowerCase() try { @@ -64,6 +65,13 @@ export function builtinGradientForAsset(ref: string): NoteCoverGradient | null { } catch { // Keep the name as written. } + return name +} + +/** The built-in gradient a vault reference names by its file name, wherever + * the assets folder lives, or null for any other file. */ +export function builtinGradientForAsset(ref: string): NoteCoverGradient | null { + const name = builtinCoverFileKey(ref) return NOTE_COVER_GRADIENTS.find((gradient) => gradientCoverFileName(gradient) === name) ?? null } diff --git a/packages/shared-domain/src/note-cover.test.ts b/packages/shared-domain/src/note-cover.test.ts index cd7c603c3..bc028252d 100644 --- a/packages/shared-domain/src/note-cover.test.ts +++ b/packages/shared-domain/src/note-cover.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import { rewriteAssetReferences } from './asset-link-rename' import { formatNoteCoverY, + noteCoverFieldKeys, parseNoteCoverTarget, parseNoteCoverY, readNoteCover, + readNoteCoverLines, removeNoteCover, setNoteCover, setNoteCoverPosition @@ -172,3 +174,39 @@ describe('a cover follows its file through an asset rename', () => { expect(readNoteCover(result.body)?.target).toEqual({ kind: 'vault', href: 'assets/trip.png' }) }) }) + +describe('the lines a cover owns', () => { + const unsplash = 'https://images.unsplash.com/photo-1?ixid=abc&w=2400&q=80&auto=format' + + it('names every key the cover writes, under the key the note uses', () => { + expect(noteCoverFieldKeys('banner')).toEqual(['banner', 'banner_y', 'banner-y', 'banner_credit', 'banner_credit_url']) + expect(noteCoverFieldKeys('cover')).toEqual(['cover', 'cover_y', 'cover-y', 'cover_credit', 'cover_credit_url']) + }) + + it('finds the image, focal point and credit lines, and none of the others', () => { + const body = + '---\ntags: [travel]\nbanner: "' + + unsplash + + '"\nbanner_credit: Kalen Emsley\nbanner_credit_url: "https://unsplash.com/@kalenemsley"\ndue: 2026-11-02\nbanner_y: 0.4\n---\n# Trip\n' + const found = readNoteCoverLines(body) + expect(found?.cover.credit?.name).toBe('Kalen Emsley') + expect(found?.lines).toEqual([2, 3, 4, 6]) + }) + + it('follows `cover:` and leaves a `banner` line it does not read alone', () => { + expect(readNoteCoverLines('---\ncover: "[[c.png]]"\ncover_y: 0.2\nbanner_y: 0.9\n---\n')?.lines).toEqual([1, 2]) + // banner wins over cover, so the cover lines are the banner's. + expect(readNoteCoverLines('---\ncover: "[[c.png]]"\nbanner: "[[b.png]]"\n---\n')?.lines).toEqual([2]) + }) + + it('takes the lines a value runs on to, and only the first line of a repeated key', () => { + const body = '---\nbanner: a.png\nbanner_credit: >-\n Jane\n\n Doe\n\ntitle: Trip\nbanner: b.png\n---\n' + expect(readNoteCoverLines(body)?.lines).toEqual([1, 2, 3, 4, 5]) + }) + + it('is null when there is no cover to show', () => { + expect(readNoteCoverLines('# Trip\n')).toBeNull() + expect(readNoteCoverLines('---\nbanner:\n---\n')).toBeNull() + expect(readNoteCoverLines('---\nbanner: file:///x.png\nbanner_y: 0.2\n---\n')).toBeNull() + }) +}) diff --git a/packages/shared-domain/src/note-cover.ts b/packages/shared-domain/src/note-cover.ts index be440decb..816015b9a 100644 --- a/packages/shared-domain/src/note-cover.ts +++ b/packages/shared-domain/src/note-cover.ts @@ -63,19 +63,30 @@ export interface NoteCover { credit?: NoteCoverCredit } +/** A top-level `key: value` line. Indented lines (list items, nested maps) + * are not keys. */ +const TOP_LEVEL_FIELD_RE = /^([A-Za-z0-9_][\w-]*)\s*:\s*(.*)$/ + /** Top-level `key: value` lines of the leading frontmatter block, keys - * lower-cased. Indented lines (list items, nested maps) are not keys. */ + * lower-cased. When a key repeats, its first line wins. */ function topLevelFields(body: string): Map<string, string> { const fields = new Map<string, string>() const block = FRONTMATTER_BLOCK_RE.exec(body)?.[1] if (block == null) return fields for (const line of block.split(/\r?\n/)) { - const kv = line.match(/^([A-Za-z0-9_][\w-]*)\s*:\s*(.*)$/) + const kv = line.match(TOP_LEVEL_FIELD_RE) if (kv && !fields.has(kv[1].toLowerCase())) fields.set(kv[1].toLowerCase(), kv[2]) } return fields } +/** Every key a cover under `key` owns: the image, its focal point in both + * spellings, and the credit. Setting a new image clears the rest, removing + * the cover clears them all, and the editor folds exactly these lines. */ +export function noteCoverFieldKeys(key: NoteCoverKey): readonly string[] { + return [key, `${key}_y`, `${key}-y`, `${key}_credit`, `${key}_credit_url`] +} + /** A YAML scalar's text. A double-quoted one is JSON-compatible the way * `yamlValue` writes it, a single-quoted one doubles its quotes, and only an * unquoted one can carry a trailing `# comment`. */ @@ -177,6 +188,11 @@ function coverKeyFor(body: string): NoteCoverKey { return readNoteCover(body)?.key ?? 'banner' } +/** Every key the cover owns, set to be removed. */ +function clearedFields(key: NoteCoverKey): Record<string, null> { + return Object.fromEntries(noteCoverFieldKeys(key).map((field) => [field, null])) +} + /** Drop a frontmatter block that the edit left with nothing in it, so a cover * added to a note without frontmatter and then removed leaves the note as it * was. */ @@ -209,11 +225,8 @@ function insertCreditLines(body: string, key: NoteCoverKey, credit: NoteCoverCre export function setNoteCover(body: string, target: NoteCoverTarget, credit?: NoteCoverCredit | null): string { const key = coverKeyFor(body) const next = updateFrontmatterFields(body, { - [key]: noteCoverValue(target), - [`${key}_y`]: null, - [`${key}-y`]: null, - [`${key}_credit`]: null, - [`${key}_credit_url`]: null + ...clearedFields(key), + [key]: noteCoverValue(target) }) const name = credit?.name.trim() return name ? insertCreditLines(next, key, { name, url: credit?.url ?? null }) : next @@ -233,13 +246,48 @@ export function setNoteCoverPosition(body: string, y: number): string { export function removeNoteCover(body: string): string { const cover = readNoteCover(body) if (!cover) return body - return dropEmptyFrontmatter( - updateFrontmatterFields(body, { - [cover.key]: null, - [`${cover.key}_y`]: null, - [`${cover.key}-y`]: null, - [`${cover.key}_credit`]: null, - [`${cover.key}_credit_url`]: null - }) - ) + return dropEmptyFrontmatter(updateFrontmatterFields(body, clearedFields(cover.key))) +} + +export interface NoteCoverLines { + cover: NoteCover + /** Line numbers in the body, counting the opening `---` as line 0. */ + lines: number[] +} + +/** + * The note's cover and the frontmatter lines that hold it: the line of each + * key the cover owns (a repeated key's first line, the one `readNoteCover` + * reads), with the indented lines under it where YAML lets a value run on. + * The editor folds these lines; a cover no surface shows (a `banner:` with + * nothing usable in it) has none. + */ +export function readNoteCoverLines(body: string): NoteCoverLines | null { + const cover = readNoteCover(body) + const block = FRONTMATTER_BLOCK_RE.exec(body)?.[1] + if (!cover || block == null) return null + const owned = new Set(noteCoverFieldKeys(cover.key)) + const lines: number[] = [] + // Blank lines inside a value count only when an indented line follows. + let pending: number[] = [] + let inCover = false + block.split(/\r?\n/).forEach((text, index) => { + const line = index + 1 + const kv = text.match(TOP_LEVEL_FIELD_RE) + if (kv) { + const key = kv[1].toLowerCase() + inCover = owned.delete(key) + pending = [] + if (inCover) lines.push(line) + } else if (!text.trim()) { + if (inCover) pending.push(line) + } else if (inCover && /^\s/.test(text)) { + lines.push(...pending, line) + pending = [] + } else { + inCover = false + pending = [] + } + }) + return { cover, lines } } From 3055e077fe5e693259420a1dc356cc81da7b8c2b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 16:20:12 -0500 Subject: [PATCH 30/34] Docs(whats-new): the 2.79.0 highlights --- packages/app-core/src/lib/whats-new.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index b17d06b33..1ef255109 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,27 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.79.0', + highlights: [ + { + title: 'A timeline of what’s due', + body: 'The Timeline panel lays every open task with a date down one line: overdue work in red, today, then each day that has work. Tag a task #milestone to mark it with a diamond. Mod+7, or Space n in Vim mode.' + }, + { + title: 'Any command can take a key', + body: 'Every command in the palette can be given a key of its own, a leader sequence in Vim mode included. Highlight one and press Alt+Enter, or use the Commands group in Settings, Keymap.' + }, + { + title: 'Share to ZenNotes on macOS', + body: 'Choose Share, ZenNotes in Safari or any app to save a page or the text you selected as a note. Each vault picks where shares land.' + }, + { + title: 'Unsplash covers with your own key', + body: 'Add your Unsplash Access Key in Settings to search millions of photos for a note’s cover. Searches go straight from your computer to Unsplash, and the photo is credited to its photographer.' + } + ] + }, { version: '2.78.0', highlights: [ From 77c2afb3a23a608e51117c9fb1439177a2da8e1b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 16:20:13 -0500 Subject: [PATCH 31/34] Release: align desktop and shared packages at 2.79.0 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5b3820503..2788ac17b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.78.0", + "version": "2.79.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 8cf9dd682..3af5c9bb7 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.78.0", + "version": "2.79.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 615499ed9..a2a8f85fc 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.78.0", + "version": "2.79.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 8b40853b8..0eb655233 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.78.0", + "version": "2.79.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 666c906f0..cd66ab17d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.78.0", + "version": "2.79.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.78.0", + "version": "2.79.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.78.0", + "version": "2.79.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.78.0", + "version": "2.79.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.78.0", + "version": "2.79.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.78.0", + "version": "2.79.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.78.0", + "version": "2.79.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.78.0", + "version": "2.79.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.78.0", + "version": "2.79.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16519,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.78.0" + "version": "2.79.0" } } } diff --git a/package.json b/package.json index 3a7cb3042..28e8e79f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.78.0", + "version": "2.79.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 ad3d82868..3edb68a98 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.78.0", + "version": "2.79.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index af4dd9a84..dbd6a5460 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.78.0", + "version": "2.79.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 72aff5696..57ceb83bd 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.78.0", + "version": "2.79.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 65e7db976..41b4617aa 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.78.0", + "version": "2.79.0", "type": "module", "exports": { ".": "./src/index.ts" From a6c36c80594289ea7514f875162d78095c12c44b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 16:41:02 -0500 Subject: [PATCH 32/34] Fix(share): the share inbox checks a capture's size and reads it through one handle CodeQL flagged a file-system race in drainShareInbox (js/file-system-race, high): it stat()ed a capture to enforce the 2 MB cap, then read it with a separate readFile, so a capture swapped or grown between the two calls could slip past the cap. The share extension writes these files into the app's own Application Support folder, so the window was narrow, but the check should hold anyway. The drain now opens the capture once, reads its size from that handle and reads the text from the same handle, and sets aside anything over the cap whether the stat or the text says so. A capture that is gone or locked is still left for the next drain, as before. Test: a capture over 2 MB is set aside into the unreadable folder and never filed (there was no test for the cap before). --- apps/desktop/src/main/share-inbox.test.ts | 10 +++++++++ apps/desktop/src/main/share-inbox.ts | 26 ++++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/share-inbox.test.ts b/apps/desktop/src/main/share-inbox.test.ts index 2a87d5bda..195e28fe6 100644 --- a/apps/desktop/src/main/share-inbox.test.ts +++ b/apps/desktop/src/main/share-inbox.test.ts @@ -247,6 +247,16 @@ describe('drainShareInbox', () => { expect(filed).toEqual([]) }) + it('sets a capture over the 2 MB cap aside without filing it', async () => { + const huge = { version: 1, id: 'big', text: 'x'.repeat(2 * 1024 * 1024 + 1) } + await write('100-big.json', huge) + const result = await drainShareInbox(deps()) + expect(result).toMatchObject({ setAside: 1, filed: [] }) + expect(filed).toEqual([]) + expect(await pendingShareFiles(dir)).toEqual([]) + expect(await fs.readdir(path.join(dir, SHARE_UNREADABLE_FOLDER))).toEqual(['100-big.json']) + }) + it('sets unreadable files aside, leaves newer ones and drops empty ones', async () => { await write('100-a.json', '{broken') await write('200-b.json', { version: 9, id: 'b', text: 'from the future' }) diff --git a/apps/desktop/src/main/share-inbox.ts b/apps/desktop/src/main/share-inbox.ts index 105024775..44f9a76a2 100644 --- a/apps/desktop/src/main/share-inbox.ts +++ b/apps/desktop/src/main/share-inbox.ts @@ -336,19 +336,29 @@ export async function drainShareInbox(deps: ShareInboxDeps): Promise<ShareDrainR for (const name of names) { const file = path.join(deps.capturesDir, name) - let raw: string + // One handle for the size check and the read, so the file checked is the + // file read: a capture swapped or grown between a stat and a separate + // read could slip past the size cap. + let raw: string | null = null + let tooLarge = false try { - const stat = await fs.stat(file) - if (!stat.isFile()) continue - if (stat.size > MAX_FILE_BYTES) { - await setAside(deps.capturesDir, name) - result.setAside++ - continue + const handle = await fs.open(file, 'r') + try { + const stat = await handle.stat() + if (!stat.isFile()) continue + if (stat.size > MAX_FILE_BYTES) tooLarge = true + else raw = await handle.readFile('utf8') + } finally { + await handle.close() } - raw = await fs.readFile(file, 'utf8') } catch { continue // gone already, or unreadable for now: the next drain retries } + if (tooLarge || raw === null || Buffer.byteLength(raw, 'utf8') > MAX_FILE_BYTES) { + await setAside(deps.capturesDir, name) + result.setAside++ + continue + } const parsed = parseShareCapture(raw) if (parsed.kind === 'newer') { result.waiting++ From b6225eb26b591d1cd6167d42cda2ff570746a2b0 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 16:41:02 -0500 Subject: [PATCH 33/34] Test(ci): the release PR's three test findings, fixed in the tests The 2.79.0 release PR (#1010) failed on three test problems, none of them in the product: - commands.test.ts checked "no Vim key in the palette with Vim mode off" by comparing displayed keys, and on Linux and Windows Open File's Mod+O and Vim's Ctrl+O both read Ctrl+O, so the global key was flagged. The six commands that show a Vim-group key are now checked by id. - test-app-core-browser.mjs replaced a note's text with Mod+A then typing. Since #1006 the first Mod+A leaves a note's leading `# Title` out (the second takes the whole note), so a note that starts with a title kept it and the saved file never matched. Every step that replaces a note's text now selects all twice; on a note without a title both presses select everything, so nothing else changes. - unsplash.test.ts walked a directory with stat() then readFile() on each path, which CodeQL reports as a file-system race even in a test; it now reads the directory's entry types instead. --- apps/desktop/src/main/unsplash.test.ts | 10 +++++----- packages/app-core/src/lib/commands.test.ts | 17 +++++++++++++---- tooling/scripts/test-app-core-browser.mjs | 22 +++++++++++++++------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/unsplash.test.ts b/apps/desktop/src/main/unsplash.test.ts index 63593b229..08a75a524 100644 --- a/apps/desktop/src/main/unsplash.test.ts +++ b/apps/desktop/src/main/unsplash.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -41,10 +41,10 @@ const ok = (json: unknown) => ({ /** Every file under `dir`, read as text. */ async function filesUnder(dir: string): Promise<Array<{ path: string; text: string }>> { const out: Array<{ path: string; text: string }> = [] - for (const name of await readdir(dir)) { - const path = join(dir, name) - if ((await stat(path)).isDirectory()) out.push(...(await filesUnder(path))) - else out.push({ path, text: await readFile(path, 'utf8') }) + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) out.push(...(await filesUnder(path))) + else if (entry.isFile()) out.push({ path, text: await readFile(path, 'utf8') }) } return out } diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 46cd92cf1..a4c864f46 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -686,9 +686,18 @@ describe('Vim-only keys in the palette', () => { const overrides = useStore.getState().keymapOverrides const leader = getKeymapDisplay(overrides, 'vim.leaderPrefix') const pane = getKeymapDisplay(overrides, 'vim.panePrefix') - const vimKeys = ( - ['vim.foldCurrent', 'vim.unfoldCurrent', 'vim.foldAll', 'vim.unfoldAll', 'vim.historyBack', 'vim.historyForward'] as const - ).map((id) => getKeymapDisplay(overrides, id)) + // The commands whose key is a Vim-group key, checked by id: on Linux and + // Windows a Vim key can read the same as a global one (Vim's Ctrl+O and + // Open File's Mod+O both show as Ctrl+O), so a display match alone would + // flag a key that works in both modes. + const vimKeyed: Record<string, string> = { + 'fold.heading': getKeymapDisplay(overrides, 'vim.foldCurrent'), + 'fold.unfold-heading': getKeymapDisplay(overrides, 'vim.unfoldCurrent'), + 'fold.all': getKeymapDisplay(overrides, 'vim.foldAll'), + 'fold.unfold-all': getKeymapDisplay(overrides, 'vim.unfoldAll'), + 'nav.back': getKeymapDisplay(overrides, 'vim.historyBack'), + 'nav.forward': getKeymapDisplay(overrides, 'vim.historyForward') + } const leaks = buildCommands({ includeUnavailable: true }) .filter((command) => { const shown = command.shortcut ?? '' @@ -697,7 +706,7 @@ describe('Vim-only keys in the palette', () => { shown.startsWith(':') || (!!leader && shown.startsWith(`${leader} `)) || (!!pane && shown.startsWith(`${pane} `)) || - vimKeys.includes(shown) + (command.id in vimKeyed && shown === vimKeyed[command.id]) ) }) .map((command) => `${command.id}: ${command.shortcut}`) diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs index 4db02bc1d..8123425c7 100644 --- a/tooling/scripts/test-app-core-browser.mjs +++ b/tooling/scripts/test-app-core-browser.mjs @@ -196,7 +196,15 @@ try { await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers: modifier }) await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers: modifier }) } - await shortcut('a', 'KeyA') + // Select-all leaves a note's leading `# Title` out on the first press and + // takes the whole note on the second (#1006), so every step that replaces a + // note's text presses it twice. On a note without a title both presses + // select everything. + async function selectAll() { + await shortcut('a', 'KeyA') + await shortcut('a', 'KeyA') + } + await selectAll() const saved = '# Package test\n\nSaved from the installed editor: café 日本語. \n\n- [ ] Preserve this task\n' await client.send('Input.insertText', { text: saved }) await until(async () => (await readFile(join(vault, path), 'utf8')) === saved, 'exact UTF-8 save') @@ -228,7 +236,7 @@ try { await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(path)})`) await until(() => client.evaluate(`!!document.querySelector('.cm-content')`), 'reloaded editor') await client.evaluate(`document.querySelector('.cm-content').focus()`) - await shortcut('a', 'KeyA') + await selectAll() await client.send('Input.insertText', { text: '# Grammar\n\nThis is an mispelled sentense.\n' }) await until(() => client.evaluate(`!!document.querySelector('.cm-harper-lint')`), 'Harper worker diagnostics', 60000) const grammarShot = await client.send('Page.captureScreenshot', { format: 'png' }) @@ -246,7 +254,7 @@ try { importPastedImage: input => window.zen.importPastedImage(input) }`) await client.evaluate(`document.querySelector('.cm-content').focus()`) - await shortcut('a', 'KeyA') + await selectAll() const attachmentBody = '# Attachments\n\nKeep this note.\n' await client.send('Input.insertText', { text: attachmentBody }) await until(async () => (await readFile(join(vault, path), 'utf8')) === attachmentBody, 'attachment baseline save') @@ -313,7 +321,7 @@ try { assert.deepEqual(await client.evaluate('window.lastHostCommand'), { command, handled: true }) } await client.evaluate(`document.querySelector('.cm-content').focus()`) - await shortcut('a', 'KeyA') + await selectAll() assert.equal(await client.evaluate('window.packageEditor.hasEditorSelection()'), true) await clickHostCommand('toggle-bold') await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === '**Format me**', 'public bold save') @@ -333,7 +341,7 @@ try { assert.equal(await client.evaluate(`window.packageEditor.runEditorCommand('close-search')`), true) assert.equal(await client.evaluate(`document.activeElement.classList.contains('cm-content')`), true) assert.equal(await client.evaluate(`window.packageEditor.runEditorCommand('close-search')`), false) - await shortcut('a', 'KeyA') + await selectAll() await client.send('Input.insertText', { text: ' ' }) assert.equal(await client.evaluate('window.packageEditor.hasEditorSelection()'), false) await clickHostCommand('set-task-list') @@ -412,7 +420,7 @@ try { await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 10'`), 'next Browse sibling') await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Original inbox/Order/Note 10.md')`), 'adjacent editor ready') await client.evaluate(`document.querySelector('.cm-content').focus()`) - await shortcut('a', 'KeyA') + await selectAll() await client.send('Input.insertText', { text: 'Edited via public sibling navigation: café.' }) await clickAdjacent('next') await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 20'`), 'second Browse sibling') @@ -566,7 +574,7 @@ try { await clickBrowse(`[data-browse-note="${movingNote}"]`) await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(movingNote)} && !!document.querySelector('.cm-content')`), 'note before move') await client.evaluate(`document.querySelector('.cm-content').focus()`) - await shortcut('a','KeyA') + await selectAll() const movedBody = '# Read me\n\nMove this edited note: café 日本語. \n' await client.send('Input.insertText', {text:movedBody}) const movingComment = await fetch(`${api}/api/comments/write`, {method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},body:JSON.stringify({path:movingNote,comments:[{id:'moving-note-comment',body:'Move my discussion',createdAt:1,updatedAt:1}]})}) From 584487ff76273ff85c405cdee364b8b8e579743d Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Wed, 28 Oct 2026 16:41:02 -0500 Subject: [PATCH 34/34] Chore(palette): drop an unused import CodeQL noted in commands.ts noteFolderSubpath stopped being used in commands.ts during the 2.79.0 cycle (CodeQL js/unused-local-variable on the release PR). --- packages/app-core/src/lib/commands.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 28eee8407..e865ddc48 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -66,8 +66,7 @@ import { findDailyNoteForDate, findMonthlyNoteForDate, findWeeklyNoteForDate, - isCalendarToggleAvailable, - noteFolderSubpath + isCalendarToggleAvailable } from './vault-layout' import { runWorkflowById } from './workflow-trigger' import { requestPublishNote } from './publish-note-requests'