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." 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/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/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index d77de0fb8..55bbe82e2 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, @@ -133,6 +134,7 @@ describe('TOML serialization', () => { kanbanGroupBy: 'folder', kanbanCardSort: 'due', tasksListOrder: 'priority', + timelineRange: 'month', kanbanFolderRoot: 'Projects', externalApplicationSchemes: ['zotero', 'obsidian'], ignoredKeys: ['KanaMode', 'F24'] @@ -151,6 +153,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) @@ -168,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']) @@ -181,11 +189,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', () => { @@ -440,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 c15494479..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' @@ -307,6 +308,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', @@ -402,7 +408,7 @@ const SCALAR_FIELDS: Partial> = { 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', @@ -435,6 +441,11 @@ const SCALAR_FIELDS: Partial> = { 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', @@ -655,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 @@ -733,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 = {} + if (rawBindings && typeof rawBindings === 'object' && !Array.isArray(rawBindings)) { + for (const [key, value] of Object.entries(rawBindings as Record)) { + if (typeof value === 'string') bindings[key] = value + } + } + + const lines = [ + '', + '# Keys for command palette commands. Add or uncomment "" = "".', + '# 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 + commandKeymaps?: Record +} { + if (!table || typeof table !== 'object' || Array.isArray(table)) return {} + const actionIds = new Set(KEYMAP_CATALOG.map((entry) => entry.id)) + const overrides: Record = {} + const commands: Record = {} + const courtesy: Record = {} + let sawCommands = false + for (const [key, value] of Object.entries(table as Record)) { + if (key === 'commands' && value && typeof value === 'object' && !Array.isArray(value)) { + sawCommands = true + for (const [id, binding] of Object.entries(value as Record)) { + 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 } { @@ -757,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/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 65a7ca399..4abffcd35 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,16 @@ import { } from "./undo-history-store"; import { renderTikz } from "./tikz"; import { fetchLinkMetadata } from "./link-metadata"; +import { + drainShareInbox, + remoteShareKey, + 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, @@ -286,6 +297,7 @@ import { installRaycastExtension, } from "./raycast-integration"; import { sendUsageCount } from "./usage-count"; +import { unsplash } from "./unsplash"; import { hideTray, parseTraySyncStatus, @@ -418,7 +430,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); @@ -819,6 +834,192 @@ 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 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; +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; + }, + // Each vault says where its shares go (Settings > Notes > Default + // shares location); Quick Notes when it says nothing. + fileLocal: async (root, title, body) => { + 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; + }, + fileRemote: async (profileId, title, body) => { + const client = remoteWorkspaceClient; + if (!client || currentRemoteWorkspaceProfileId !== profileId) { + return null; + } + 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; + }, + 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; + } +} + +let shareVaultsTimer: ReturnType | null = null; + +/** Rewrites the sheet's vault list: the remembered vaults, the current one, + * 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 { + const dir = shareInboxDir; + if (!dir) return; + try { + const file = await withShareDestinations( + shareVaultsFile(await loadConfig()), + async (choice) => { + 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); + }, + ); + 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 { + const dir = await resolveShareInboxDir(); + if (!dir) return; + const captures = path.join(dir, SHARE_CAPTURES_FOLDER); + try { + await fsp.mkdir(captures, { recursive: true }); + } catch (err) { + console.warn("Share inbox unavailable", err); + return; + } + shareInboxDir = dir; + 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. + 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 +2598,10 @@ async function setRemoteWorkspace( baseUrl: client.baseUrl, }; startRemoteWatch(client, capabilities); + // 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) => ({ @@ -3618,6 +3823,18 @@ 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 unsplash.search(query, page)); + handle(IPC.UNSPLASH_TRACK_DOWNLOAD, async (_e, downloadLocation: unknown) => { + 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(); }); @@ -3728,13 +3945,16 @@ 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); // 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; }); @@ -6227,6 +6447,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/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 { + 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 { + 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 { + 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/share-inbox.test.ts b/apps/desktop/src/main/share-inbox.test.ts new file mode 100644 index 000000000..195e28fe6 --- /dev/null +++ b/apps/desktop/src/main/share-inbox.test.ts @@ -0,0 +1,277 @@ +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, + withShareDestinations, + 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('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 => `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 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' }) + 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..44f9a76a2 --- /dev/null +++ b/apps/desktop/src/main/share-inbox.ts @@ -0,0 +1,432 @@ +/** + * 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 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. + * + * 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 + /** 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 { + 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 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 +): Promise { + 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 { + 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 where the local vault files shares; 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) + // 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 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() + } + } 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++ + 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/unsplash.test.ts b/apps/desktop/src/main/unsplash.test.ts new file mode 100644 index 000000000..08a75a524 --- /dev/null +++ b/apps/desktop/src/main/unsplash.test.ts @@ -0,0 +1,141 @@ +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' + +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 { + deleteUnsplashAccessKeySecret, + getUnsplashAccessKeySecret, + setUnsplashAccessKeySecret +} from './secret-store' +import { createDesktopUnsplashClient, unsplashApiBaseUrl } from './unsplash' + +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 +}) + +/** Every file under `dir`, read as text. */ +async function filesUnder(dir: string): Promise> { + const out: Array<{ path: string; text: string }> = [] + 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 +} + +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(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('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 + 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(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(async () => ok({ total_pages: 0, results: [] })) + const client = createDesktopUnsplashClient(fetch, () => 'https://api.unsplash.com') + await setUnsplashAccessKeySecret(KEY) + expect(await client.search('fog', 1)).toMatchObject({ ok: true }) + 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({ + authorization: `Client-ID ${KEY}`, + 'user-agent': 'ZenNotes', + 'accept-language': '*' + }) + }) + + it('says it cannot store a key where the OS has no encryption', async () => { + machine.encryption = false + const fetch = vi.fn() + 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() + }) +}) diff --git a/apps/desktop/src/main/unsplash.ts b/apps/desktop/src/main/unsplash.ts new file mode 100644 index 000000000..cbe155a8f --- /dev/null +++ b/apps/desktop/src/main/unsplash.ts @@ -0,0 +1,49 @@ +/** + * 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). 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 { UNSPLASH_API_BASE_URL, createUnsplashClient, type UnsplashFetch } from '@shared/unsplash' +import { + canStoreSecretsSecurely, + deleteUnsplashAccessKeySecret, + getUnsplashAccessKeySecret, + setUnsplashAccessKeySecret +} from './secret-store' + +/** 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_API_BASE_URL +} + +export function createDesktopUnsplashClient(fetch: UnsplashFetch, baseUrl: () => string) { + return createUnsplashClient({ + fetch, + store: 'system', + keys: { + get: getUnsplashAccessKeySecret, + set: setUnsplashAccessKeySecret, + delete: deleteUnsplashAccessKeySecret, + canStore: canStoreSecretsSecurely + }, + options: () => ({ + baseUrl: baseUrl(), + headers: { 'user-agent': 'ZenNotes', 'accept-language': '*' } + }) + }) +} + +export const unsplash = createDesktopUnsplashClient( + (url, init) => net.fetch(url, init), + () => unsplashApiBaseUrl(process.env, app.isPackaged) +) 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/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 e89a34c71..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' @@ -715,6 +716,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`. @@ -890,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 } } : {}), @@ -1114,6 +1132,7 @@ function normalizeVaultSettings( drawingsLocation: { mode: 'primary' }, databasesLocation: { mode: 'primary' }, tasksLocation: { mode: 'primary' }, + sharesLocation: { mode: 'quick' }, folderIcons: {}, folderColors: {}, favorites: [] @@ -1151,6 +1170,7 @@ function normalizeVaultSettings( drawingsLocation?: unknown databasesLocation?: unknown tasksLocation?: unknown + sharesLocation?: unknown folderIcons?: Record | null folderColors?: Record | null favorites?: unknown @@ -1212,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/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index edc47f36b..a91ed2582 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -10,6 +10,12 @@ import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' +import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashSearchResult +} from '@zennotes/bridge-contract/unsplash' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -264,6 +270,14 @@ 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), + getUnsplashKeyStatus: (): Promise => ipcRenderer.invoke(IPC.UNSPLASH_KEY_STATUS), + saveUnsplashKey: (key: string): Promise => ipcRenderer.invoke(IPC.UNSPLASH_KEY_SAVE, key), + testUnsplashKey: (): Promise => ipcRenderer.invoke(IPC.UNSPLASH_KEY_TEST), + clearUnsplashKey: (): Promise => ipcRenderer.invoke(IPC.UNSPLASH_KEY_CLEAR), checkForAppUpdatesWithUi: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK_WITH_UI), downloadAppUpdate: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_DOWNLOAD), 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-extension/README.md b/apps/share-extension/README.md new file mode 100644 index 000000000..5900f29d3 --- /dev/null +++ b/apps/share-extension/README.md @@ -0,0 +1,60 @@ +# 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 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, + 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, 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. + +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..4b1180836 --- /dev/null +++ b/apps/share-extension/macos/ShareViewController.swift @@ -0,0 +1,500 @@ +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? + /// 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 } + 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 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: "") + 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 + headingLabel.font = .boldSystemFont(ofSize: 14) + headingLabel.lineBreakMode = .byTruncatingMiddle + let header = NSStackView(views: [icon, headingLabel]) + 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 + + // 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), + 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 + 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() { + 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" + // 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) + : content.text + excerptLabel.stringValue = excerptText ?? "" + (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)") + 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, destination: entry["destination"] 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) { + headingLabel.stringValue = "Save to \(selectedVault?.destination ?? "ZenNotes")" + 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 once it is connected to this server." + } else if !appRunning { + notice.stringValue = "ZenNotes files it the next time it opens." + } else { + notice.stringValue = "" + } + notice.isHidden = notice.stringValue.isEmpty + if isViewLoaded { fitToContent() } + } + + // 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/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/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/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/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 72781907a..2dbe18db7 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -75,6 +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 { createUnsplashClient } from '@shared/unsplash' +import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashSearchResult +} from '@zennotes/bridge-contract/unsplash' import type { McpClientId, McpClientStatus, @@ -610,6 +617,48 @@ async function fetchLinkMetadata(url: string): Promise { return { url, ok: false } } +// 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' + +function browserStorage(): Storage | null { + try { + return window.localStorage + } catch { + return null + } +} + +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 => unsplash.search(query, page) +const trackUnsplashDownload = (downloadLocation: string): Promise => unsplash.trackDownload(downloadLocation) +const getUnsplashKeyStatus = (): Promise => unsplash.status() +const saveUnsplashKey = (key: string): Promise => unsplash.save(key) +const testUnsplashKey = (): Promise => unsplash.test() +const clearUnsplashKey = (): Promise => unsplash.clear() + async function revealFolder(_folder: NoteFolder, _subpath: string): Promise { // No OS file manager on the web. } @@ -1588,6 +1637,12 @@ export const httpBridge: ZenBridge = { openExternalUrl: async () => ({ ok: false, error: 'desktop-only' }), openAssetExternally, fetchLinkMetadata, + searchUnsplash, + trackUnsplashDownload, + getUnsplashKeyStatus, + saveUnsplashKey, + testUnsplashKey, + clearUnsplashKey, revealNoteTarget, revealFilePath, moveNote, 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/README.md b/packages/app-core/README.md index 7d471b59a..4e9fa55ed 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,23 @@ 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), 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. 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 +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/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/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 9f9ad5fa4..22dd7168a 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -42,16 +42,13 @@ 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 { 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 { @@ -67,12 +64,26 @@ 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, 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, // 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()) +// 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 | null = null const EDITOR_MODULE_WARMUP_GRACE_MS = 40 @@ -316,60 +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. - */ -function handlePanelKeyWithoutVim(e: KeyboardEvent, focusedPanel: string | null): void { - 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) @@ -482,7 +439,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) }, []) @@ -825,7 +790,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 @@ -955,39 +920,41 @@ function App(): JSX.Element { void state.reopenLastClosedTab() return } + // 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) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.vaultTextSearchOpen) { setVaultTextSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.commandPaletteOpen) { setCommandPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.bufferPaletteOpen) { setBufferPaletteOpen(false) - focusEditorNormalMode() + 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) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } // ⌘1 — toggle sidebar @@ -1014,6 +981,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() @@ -1159,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/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[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/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 6637a2987..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 { focusEditorNormalMode } 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) } - focusEditorNormalMode() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } const closeSelected = async (): Promise => { 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/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/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 + 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 { + await act(async () => root.render(createElement(CommandPalette))) + const input = document.querySelector('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('[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 a01ecde16..aab06455f 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, @@ -27,10 +28,18 @@ 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' +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' @@ -71,11 +80,30 @@ export function CommandPalette(): JSX.Element { const listRef = useRef(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(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( + 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(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` @@ -169,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. @@ -280,7 +310,7 @@ export function CommandPalette(): JSX.Element { revertTheme() } setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } /* -------- Actions -------- */ @@ -304,8 +334,24 @@ export function CommandPalette(): JSX.Element { return } 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 @@ -316,20 +362,49 @@ 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. + // 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(), + { ...latest, focusedPanel: landedPanel }, useCloudSyncStatusStore.getState().conflictReviewOpen || useCloudSyncStatusStore.getState().settingsConflictPromptOpen || getPublishNoteRequest() !== null ) - ) - focusEditorNormalMode() + ) { + if (landing === 'read') focusEditorAfterReadOpen() + else if (landing === 'edit') focusEditorNormalMode() + else focusEditorAfterPaletteClose() + } } catch (err) { console.error('command failed', cmd.id, err) } } + /** + * 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 @@ -372,6 +447,7 @@ export function CommandPalette(): JSX.Element { : 'Pick a vault' return ( + <> closePalette()} closeOnEsc={false}> {canReturnToCommandList(mode, initialMode) && (
@@ -412,6 +488,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') { @@ -608,6 +694,11 @@ export function CommandPalette(): JSX.Element { ? 'keep theme' : 'switch'} + {mode === 'main' && ( + + {formatKeyToken('Alt+Enter')} set key + + )} esc{' '} {!canReturnToCommandList(mode, initialMode) @@ -618,5 +709,29 @@ export function CommandPalette(): JSX.Element {
+ {recordingFor && ( + setRecordingFor(null)} + onSave={(binding) => { + setCommandKeymapBinding(recordingFor.id, binding) + setRecordingFor(null) + }} + onUnbind={() => { + setCommandKeymapBinding(recordingFor.id, '') + setRecordingFor(null) + }} + /> + )} + ) } 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({ )} -
+
{comments.length === 0 && !draft ? (
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({ )}
-
setConnectionPreview(null)}> +
setConnectionPreview(null)} + > ` 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 ` 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 ` gives a command palette command a key (#971): + // `:bind note.trash`, `:bind 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", () => { @@ -1231,10 +1229,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(); @@ -1371,6 +1373,7 @@ const MANUAL_EX_NAMES = new Set([ "wq", "format", "unbind", + "bind", "tasks", "tag", "template", @@ -1402,6 +1405,7 @@ const MANUAL_EX_NAMES = new Set([ "buffers", "ls", "outline", + "timeline", "view", "zen", "zenmode", @@ -1817,6 +1821,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(); @@ -1836,9 +1849,7 @@ export function Editor(): JSX.Element {
- {!zenMode && (activeNote || cloudSyncVisible) && ( - - )} + {statusBarShown && } ); } diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 216f1ac8e..83de982c5 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -40,20 +40,20 @@ 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, 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' @@ -110,10 +110,11 @@ 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' +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' @@ -158,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, @@ -171,6 +178,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' @@ -261,6 +269,7 @@ import { ArrowRightIcon, ArrowUpRightIcon, CalendarIcon, + TimelineIcon, CheckSquareIcon, CloseIcon, PaperclipIcon, @@ -477,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), @@ -506,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). @@ -1055,11 +1074,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 @@ -1101,6 +1122,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }, [revealSidePanel, setCalendarPanelRaw] ) + const setTimelineOpen = useCallback( + (next: SetStateAction) => { + if (next === true) revealSidePanel('timeline') + setTimelineOpenRaw(next) + }, + [revealSidePanel, setTimelineOpenRaw] + ) const [activeOutlineLine, setActiveOutlineLine] = useState(null) const calendarOpen = calendarPanel.open // The calendar panel is a date navigator. It auto-opens while the pane shows @@ -1356,6 +1384,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. @@ -1498,6 +1531,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. @@ -1508,9 +1552,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') } } @@ -1773,7 +1823,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]) @@ -2101,6 +2153,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 @@ -2131,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( @@ -2534,6 +2587,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( @@ -2789,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(() => { @@ -3893,6 +3947,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, @@ -3902,6 +3957,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { content, isActive, outlineOpen, + timelineOpen, zenMode ]) useEffect(() => { @@ -3990,6 +4046,15 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { )} + + + void exportActiveNotePdf()}> @@ -4035,6 +4100,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { calendarAvailable, calendarOpen, toggleCalendarPanel, + timelineOpen, + toggleTimelinePanel, sidePanelFit.tucked, trashActive, deleteActivePermanently, @@ -4394,7 +4461,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 @@ -4732,6 +4799,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {content && sidePanelShown('calendar') && ( )} + {content && sidePanelShown('timeline') && ( + + )} { @@ -4983,7 +5053,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/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/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/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/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/KeymapRecorder.tsx b/packages/app-core/src/components/KeymapRecorder.tsx new file mode 100644 index 000000000..bbee3d7dc --- /dev/null +++ b/packages/app-core/src/components/KeymapRecorder.tsx @@ -0,0 +1,513 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { + findKeymapConflict, + formatKeymapBinding, + getDefaultKeymapBinding, + getKeymapBinding, + getKeymapDisplay, + isChordEvent, + 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 { keymapBindingTakenBy } from '../lib/list-view-keys' +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' ? ( + + ) : ( + + ) +} + +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 ( + +
+
{title}
+
{description}
+
+
+
+
+ Recording +
+
+ {display} +
+
{hint}
+
+ {notices} +
Current: {current}
+
{secondary}
+
+
+ +
+ + +
+
+
+ ) +} + +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 ( +
+ {tone === 'info' ? 'ⓘ' : '⚠'} + {children} +
+ ) +} + +function ActionRecorder({ + definition, + overrides, + commandKeymaps, + vimMode, + onClose, + onSave, + onUnbind +}: { + definition: KeymapDefinition + overrides: KeymapOverrides + commandKeymaps: CommandKeymaps + vimMode: boolean + 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 + // 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 + // 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') { + 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 || takenBy || (leaderKey && commandHits.length > 0) ? ( + <> + {takenBy && !conflict && !blockingCommand && ( + + {takenBy.title} has this key everywhere in + the app, so it would never reach this action. Pick another key, or move that one first. + + )} + {conflict && ( + + Already used by {conflict.title}. Two global + shortcuts can’t share a key. Reset or reassign that one first. + + )} + {blockingCommand && ( + + Already the key of the command{' '} + {blockingCommand.title}. Two of your keys + can’t share one. Unbind or change that command first. + + )} + {leaderKey && commandHits.length > 0 && ( + + Your key sequence for{' '} + + {commandHits.map((hit) => hit.title).join(', ')} + {' '} + starts with this key. Built-in leader keys always win, so that command stops answering + its keys until you move one of them. + + )} + + ) : null + + return ( + 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{' '} + {conflict.title}. Built-in leader keys always + win, so pick another key after the leader. + + ) + } + if (sequence) { + return ( + <> + Overlaps the key sequence of {conflict.title}: + one would start the other. Change one of them first. + + ) + } + return ( + <> + Already used by{' '} + + {conflict.kind === 'command' ? `the command ${conflict.title}` : conflict.title} + + . 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 + 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(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 && {hint}} + {parsed && !parsed.ok && {parsed.error}} + {conflicts.blocking.map((conflict) => ( + + {describeBlocking(conflict, sequence)} + + ))} + {conflicts.shadows.map((conflict) => ( + + This is the default key of {conflict.title}. + Your key wins, so {conflict.title} stops answering it until you move one of them. + + ))} + {sequence && !vimMode && ( + + Leader sequences work with Vim mode on. With it off, this one does nothing until Vim + mode is back on. + + )} + {sequence && !leaderToken && ( + The leader key is unbound, so no sequence can start right now. + )} + + ) + + const leaderName = leaderToken ? getKeymapDisplay(overrides, 'vim.leaderPrefix') : '' + return ( + onSave(binding)} + /> + ) +} + +function useLatest(value: T): { current: T } { + const [ref] = useState(() => ({ current: value })) + ref.current = value + return ref +} 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/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-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 + let originalScrollIntoView: PropertyDescriptor | undefined + let search: ReturnType Promise>> + + const stubZen = (status: UnsplashKeyStatus): void => { + ;(window as unknown as { zen: unknown }).zen = { + searchUnsplash: search, + getUnsplashKeyStatus: vi.fn(async () => status), + resolveVaultAssetUrl: () => '' + } + } + + const open = async (): Promise => { + 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('[data-note-cover-picker] input')! + } + + const press = async (input: HTMLInputElement, key: string): Promise => { + 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 ff5efa312..1f5204ef9 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_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' @@ -14,8 +16,17 @@ import { setCoverFromBuiltin, setCoverFromClipboard, setCoverFromFile, + setCoverFromUnsplash, 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' @@ -26,12 +37,65 @@ 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. */ +export function unsplashMessage(state: UnsplashSearchState, count: number): string | null { + switch (state.status) { + case 'idle': + return 'Search free photos from Unsplash, with your own access key.' + case 'loading': + return 'Searching Unsplash…' + case 'failed': + 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.' + } + 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 @@ -42,17 +106,36 @@ 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) + // 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(null) @@ -62,6 +145,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 +203,54 @@ 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 + 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() }, []) - 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 +277,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 +307,24 @@ 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 (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() moveRow(1) @@ -241,26 +394,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'} +
-
- {sections.length === 0 ? ( -
No covers match.
+
{ + const el = e.currentTarget + if (tab === 'unsplash' && el.scrollTop + el.clientHeight >= el.scrollHeight - 80) loadMore() + }} + > + {sections.length === 0 && tab === 'unsplash' && (needsKey || failure === 'no-key') ? ( + + ) : sections.length === 0 ? ( +
+ {tab === 'unsplash' + ? keyStatus == null && keySupported + ? null + : unsplashMessage(unsplash, choices.length) + : 'No covers match.'} + {panelActions.length > 0 && ( + + )} +
) : ( sections.map((section) => (
@@ -300,7 +503,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,11 +521,20 @@ export function NoteCoverPicker({ notePath }: { notePath: string }): JSX.Element )}
+ {tab === 'unsplash' && } + {unsplashAvailable && ( + + tab {tab === 'covers' ? 'Unsplash' : 'covers'} + + )} ↑↓←→ move - ↵ set cover + ↵{' '} + {panelActions.length > 0 + ? panelActions[Math.min(panelActive, panelActions.length - 1)]?.hint + : 'set cover'} esc close @@ -329,3 +544,98 @@ 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 ( +
+

Unsplash search needs an access key of your own

+

+ 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: +

+
    +
  1. At unsplash.com/developers, open Your apps and create a New Application (a free Unsplash account is all it needs).
  2. +
  3. Copy its Access Key. The Secret Key is not needed.
  4. +
  5. Paste it into Settings → Typography → Covers → Unsplash access key.
  6. +
+ +

+ A new Unsplash app allows 50 requests an hour, plenty for picking covers. The Covers tab needs no key. +

+
+ ) +} + +function PanelActionButtons({ + actions, + active, + onHover +}: { + actions: PanelAction[] + active: number + onHover: (index: number) => void +}): JSX.Element { + return ( +
+ {actions.map((action, i) => ( + + ))} +
+ ) +} + +/** 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/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 {
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/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index 6f14be253..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 { focusEditorNormalMode } 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 } }) ) - focusEditorNormalMode() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } return ( 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 /> )}
-
+
{filtered.length === 0 ? (
{items.length === 0 ? 'Nothing to show.' : 'No matches.'} 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..8aedc3cf4 --- /dev/null +++ b/packages/app-core/src/components/PaletteFocusHandoff.test.ts @@ -0,0 +1,160 @@ +// @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: 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: () => [ + { + id: 'test.read', + title: 'Read Something', + category: 'Go', + run: goBack, + opensToRead: () => true + }, + { id: 'note.new.inbox', title: 'Write Something', category: 'Note', run: newNote }, + { id: 'view.status-bar', title: 'Toggle Something', category: 'View', run: toggle } + ] +})) + +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() + toggle.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('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) + 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/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/components/QuickNotesView.tsx b/packages/app-core/src/components/QuickNotesView.tsx index 221e026be..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() @@ -91,7 +108,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]) @@ -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[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/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/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index f3772ffb9..b0c2de49c 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 focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose +})) 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() + focusEditorAfterReadOpen.mockClear() + focusEditorAfterPaletteClose.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(focusEditorAfterReadOpen).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(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).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 1f6d1cd42..1ac93ad74 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 { + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, + 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() + focusEditorAfterReadOpen() } // 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() + focusEditorAfterReadOpen() return } await openTagViewFor(tagTokens, freeText) @@ -174,7 +178,7 @@ export function SearchPalette(): JSX.Element { const close = (): void => { setSearchOpen(false) - focusEditorNormalMode() + 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 51d195100..e9ab44b7f 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 focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose +})) 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() + focusEditorAfterReadOpen.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(focusEditorAfterReadOpen).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/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 795e849b3..781f56c43 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, @@ -53,11 +55,14 @@ const mocks = vi.hoisted(() => { kanbanFolderRoot: "", kanbanStatuses: [] as string[], keymapOverrides: {} as Record, + commandKeymaps: {} as Record, + setCommandKeymapBinding: vi.fn(), lineNumberMode: "off", 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, @@ -102,6 +107,7 @@ const mocks = vi.hoisted(() => { return { state, runtime: "desktop" as "desktop" | "web", + platform: "linux" as string, capabilities: {} as Record, setSettingsOpen: state.setSettingsOpen, setVaultSettings: state.setVaultSettings, @@ -126,7 +132,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 +168,7 @@ const REACHED_THROUGH_LOCATION_ROW = new Set([ "drawings-folder", "databases-folder", "tasks-folder", + "shares-folder", ]); type BuiltSettingsCategory = SettingsSearchCategory & { @@ -205,6 +212,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; @@ -290,6 +298,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…"]')!; @@ -312,6 +330,62 @@ 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('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "safari")); + const result = [...host.querySelectorAll("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("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("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) => ({ + 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('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "safari")); + return [...host.querySelectorAll("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('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "safari")); + const result = [...host.querySelectorAll("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") => @@ -571,6 +645,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)); @@ -756,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( + '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( + 'input[placeholder="Filter keymaps…"]', + )!; + await act(async () => changeInput(filter, "Toggle checkbox")); + const action = host.querySelector( + '[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 { 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..4821381e1 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,24 @@ import { isMacPlatform, isUnboundBinding, keymapAvailableOnHost, - sequenceTokenFromEvent, shortcutBindingFromEvent, UNBOUND_BINDING, UNBOUND_LABEL, } from "../lib/keymaps"; +import { keymapBindingTakenBy } from "../lib/list-view-keys"; +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, @@ -164,7 +174,10 @@ import { import { CustomCodeLanguagesSettings } from "./CustomCodeLanguagesSettings"; 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 = @@ -594,10 +607,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" && (
@@ -682,6 +704,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); @@ -801,6 +825,14 @@ export function SettingsModal(): JSX.Element { 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); @@ -956,6 +988,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); @@ -1399,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(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 @@ -1409,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(null); const [activeCategory, setActiveCategory] = useState( @@ -1418,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< @@ -1527,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" @@ -1601,6 +1652,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 +2116,13 @@ export function SettingsModal(): JSX.Element { onChange={setShowWindowTitleBar} /> )} + + {/* 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). */} @@ -3465,6 +3545,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: (
setKeymapBinding(id, binding)} + onSetCommandBinding={(id, binding) => + setCommandKeymapBinding(id, binding) + } onResetAll={resetAllKeymaps} />
@@ -3696,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", @@ -3704,6 +3796,7 @@ export function SettingsModal(): JSX.Element { "width", "alignment", "numbers", + "cover", ], searchItems: [ { @@ -3763,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", @@ -3868,18 +3976,6 @@ export function SettingsModal(): JSX.Element { ]} onChange={(next) => setContentAlign(next)} /> - setNoteCoverPlacement(next)} - /> )} + +
+ setNoteCoverPlacement(next)} + /> + {unsplashKeySupported() && ( + + )} +
), }, @@ -4055,6 +4172,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 +4780,7 @@ export function SettingsModal(): JSX.Element { "drawings-location", "databases-location", "tasks-location", + ...(sharesLocationAvailable ? ["shares-location"] : []), "view-settings-scope", ], content: ( @@ -4787,6 +4921,42 @@ export function SettingsModal(): JSX.Element { } /> )} + {sharesLocationAvailable && ( + + void persistVaultSettings({ + ...vaultSettings, + sharesLocation: { ...vaultSettings.sharesLocation, mode }, + }) + } + /> + )} + {sharesLocationAvailable && + vaultSettings.sharesLocation?.mode === "folder" && ( + + void persistVaultSettings({ + ...vaultSettings, + sharesLocation: { mode: "folder", folder: next ?? "" }, + }) + } + /> + )}
{ + 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(null); + const [recording, setRecording] = useState( + 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(); @@ -6324,14 +6531,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; @@ -6349,9 +6548,24 @@ function KeymapSettings({ ): group is ReturnType[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 (
@@ -6365,9 +6579,9 @@ function KeymapSettings({
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.
@@ -6410,14 +6624,30 @@ 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); + // 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 (
@@ -6426,23 +6656,66 @@ function KeymapSettings({ {definition.title} {inactive && ( - + {definition.vimOnly ? "Vim only" : "Non-Vim only"} )} {custom && ( - + Custom )} {conflict && ( Conflicts with {conflict.title} )} + {takenBy && ( + + Never fires: {takenBy.title} has this key + + )} + {commandHits.map((hit) => + definition.scope === "leader" ? ( + + Blocks command {hit.title} + + ) : custom ? ( + + Conflicts with command {hit.title} + + ) : ( + + Taken by command {hit.title} + + ), + )}
{definition.description} @@ -6463,8 +6736,10 @@ function KeymapSettings({ @@ -6475,12 +6750,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 @@ -6488,12 +6758,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 + +
+
+ ); + })} +
+
+ ))} + {commandRows.length > 0 && ( +
+
+ Commands +
+
+ 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. +
+
+ {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 ( +
+
+
+ + {entry.title} + + + {entry.category} + + {inert && ( + + Inert with Vim off + + )} + {custom && ( + + Custom + + )} + {blocking.map((hit) => ( + + Conflicts with {hit.title} + + ))} + {shadows.map((hit) => ( + + Wins over {hit.title} + + ))} +
+
+ {entry.id} + {builtin && <> · also {builtin}} +
+
+
+ + {unbound + ? UNBOUND_LABEL + : formatCommandBinding(binding, overrides)} + + + + @@ -6503,8 +6907,8 @@ function KeymapSettings({ })}
- ))} - {groups.length === 0 && ( + )} + {groups.length === 0 && commandRows.length === 0 && (
No keymaps match your filter.
@@ -6514,21 +6918,30 @@ function KeymapSettings({ {recording && ( 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); }} /> @@ -6621,172 +7034,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( -
-
-
-
- {definition.title} -
-
- {definition.description} -
-
-
-
-
- Recording -
-
- {display} -
-
- {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.`} -
-
- {conflict && ( -
- ⚠ - - Already used by{" "} - {conflict.title}. Two - global shortcuts can’t share a key. Reset or reassign that one - first. - -
- )} -
- Current:{" "} - {isUnboundBinding(currentBinding) - ? UNBOUND_LABEL - : formatKeymapBinding(currentBinding, definition.kind)} -
-
- Default:{" "} - {formatKeymapBinding( - getDefaultKeymapBinding(definition.id), - definition.kind, - )} -
-
-
- -
- - -
-
-
-
, - document.body, - ); -} - function Section({ title, description, @@ -7337,9 +7584,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 +7625,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.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 15bc37c37..4196be904 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"; @@ -338,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)}`; } @@ -732,6 +748,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 +2948,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. @@ -3441,7 +3513,7 @@ export function Sidebar(): JSX.Element { const title = resolveQuickNoteTitle( notes, quickNoteDateTitle, - quickNoteTitlePrefix ?? undefined, + quickNoteTitlePrefix, ); void createAndOpen("quick", "", { title, focusTitle: true }); }} @@ -5258,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", @@ -5583,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", diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index f39e47704..722adca8f 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -7,6 +7,7 @@ import { setHoveredLink, useHoveredLinkStore } from "../lib/hovered-link"; import { cloudSyncAttentionLabel, cloudSyncAttentionIsSettingsOnly, + cloudSyncShownPhase, cloudVaultRemovalLabel, cloudVaultRemovalMessage, connectCloudAccountFromStatusBar, @@ -142,9 +143,11 @@ function CloudSyncStatus({ // while its sync had stopped. const removal = phase === "unlinked" ? removedVault : null; // Signed out because Cloud refused this device: the same, it stopped - // syncing without the person choosing to. - const signedOut = phase === "disconnected" && errorKind === "signed-out"; - const shownPhase = removal || signedOut ? "attention" : phase; + // syncing without the person choosing to (cloudSyncShownPhase). + const shownPhase = cloudSyncShownPhase({ phase, removedVault, errorKind }) as Exclude< + CloudSyncPhase, + "hidden" + >; const label = removal ? cloudVaultRemovalLabel(removal) 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[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/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/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/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 0b1a01548..285d3cc7f 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -2,13 +2,19 @@ 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' 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 = { @@ -88,7 +109,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 +167,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 +185,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(() => { @@ -634,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[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() @@ -746,7 +776,7 @@ export function TasksView(): JSX.Element { return } if ( - vimMode && + listViewSequencesLive(e, vimMode) && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -1064,6 +1094,7 @@ export function TasksView(): JSX.Element { void openTaskAt(task)} onToggleTask={(task) => void toggleTaskFromList(task)} 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/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 { + 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 + 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('[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('[data-timeline-range="month"]')?.click()) + expect(useStore.getState().timelineRange).toBe('month') + expect(rowIds()).toContain('far') + act(() => panel.querySelector('[data-timeline-range="two-weeks"]')?.click()) + expect(rowIds()).not.toContain('far') + act(() => panel.querySelector('[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('[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..36503e1a5 --- /dev/null +++ b/packages/app-core/src/components/TimelinePanel.tsx @@ -0,0 +1,712 @@ +/** + * 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, + isChordEvent, + matchesSequenceToken, + 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' +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 +// 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 + * 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(null) + const filterRef = useRef(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 + } | null>(null) + const hoverTimer = useRef | 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(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>() + useEffect(() => () => clearTimeout(gTimer.current), []) + + const onKeyDown = (event: React.KeyboardEvent): 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 + } + 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 && (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. A + // chord bound to one of the panel's actions works in either mode. + if (!vimMode && !isChordEvent(native) && key.length === 1) return + + 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 (key === 'Escape' || key === 'ArrowLeft') { + consume() + focusEditor() + return + } + if (key === 'ArrowDown' || listViewBindingMatches(native, keymapOverrides, 'nav.moveDown', vimMode)) { + consume() + move(cursorIdx + 1) + return + } + if (key === 'ArrowUp' || listViewBindingMatches(native, keymapOverrides, 'nav.moveUp', vimMode)) { + consume() + move(cursorIdx - 1) + return + } + if (key === 'Home' || key === 'End') { + consume() + move(key === 'Home' ? 0 : rows.length - 1) + return + } + if (key === 'Enter') { + consume() + if (current) void openTaskAt(current.task) + return + } + if ( + (event.shiftKey && key === 'F10') || + key === 'ContextMenu' || + listViewBindingMatches(native, keymapOverrides, 'nav.contextMenu', vimMode) + ) { + 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 && !isChordEvent(native)) 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 ( +
+ + 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" + /> +
+ ) + } + const isCursor = panelFocused && index === cursorIdx + return ( +
{ + 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(' ')} + > + 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" + /> +
+
+ + {task.priority && ( + + {priorityLabel(task.priority)} + + )} + {row.lateDays > 0 && ( + + {row.lateDays}d late + + )} +
+
+ {task.noteTitle} + {row.before !== null && ( + + · {plural(row.before, 'task', 'tasks')} before it + + )} + +
+
+
+ ) + } + + // A node sits on the rail line, which runs down the left edge at left-3. + const node = (className: string): JSX.Element => ( + + ) + + const renderEntry = (entry: TimelineEntry): JSX.Element => { + if (entry.kind === 'quiet') { + return ( +
+ {plural(entry.days, 'quiet day', 'quiet days')} +
+ ) + } + if (entry.kind === 'overdue') { + return ( +
+
+ {node('h-2 w-2 rounded-full bg-danger')} + + Overdue · {entry.rows.length} + +
+
{entry.rows.map(renderRow)}
+
+ ) + } + const label = timelineDayLabel(entry.iso, today) + return ( +
+
+ {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')} + + {entry.today ? `Today · ${label}` : label} + {entry.milestone && · milestone} + +
+ {entry.rows.length > 0 ? ( +
{entry.rows.map(renderRow)}
+ ) : ( + entry.today &&
Nothing due today
+ )} +
+ ) + } + + // 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[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 ( +
+ + +
+
+
Timeline
+
+ {rail.openCount} open + {rail.overdueCount > 0 && · {rail.overdueCount} overdue} +
+
+
+ {RANGE_BUTTONS.map(({ id, label }) => ( + + ))} +
+ { + 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" + /> +
+ +
+ {loadingFirstScan ? ( +
Reading tasks…
+ ) : ( +
+ + {rail.entries.map(renderEntry)} +
+ )} +
+ + {(rail.undatedCount > 0 || rail.laterCount > 0 || keyHints.length > 0) && ( +
+ {(rail.undatedCount > 0 || rail.laterCount > 0) && ( +
+ {rail.laterCount > 0 && rail.rangeEnd && ( + + )} + {rail.undatedCount > 0 && ( + + )} +
+ )} + {keyHints.length > 0 && ( +
+ {keyHints.map(([key, label]) => ( + + {key} {label} + + ))} +
+ )} +
+ )} + + {hover && } + {menu && } +
+ ) +} 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[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/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 = { 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/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[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/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 = { + 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 }): JSX.Element { + const status = useUnsplashKeyStatus() + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(null) + const [message, setMessage] = useState(null) + const ownRef = useRef(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): Promise => { + 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 ( +
+
+
Unsplash access key
+
+ The cover picker’s Unsplash tab searches with a free key of your own. At{' '} + + unsplash.com/developers + + , 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. +
+
+
+ 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" + /> + + + +
+
+ {busy ?? shown.text} +
+
+ ) +} diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index 0f634b465..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 { focusEditorNormalMode } 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' }) - focusEditorNormalMode() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } return ( 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 + + 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-panel-keys.test.ts b/packages/app-core/src/components/VimNav-panel-keys.test.ts new file mode 100644 index 000000000..341f13196 --- /dev/null +++ b/packages/app-core/src/components/VimNav-panel-keys.test.ts @@ -0,0 +1,753 @@ +// @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()), + 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(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 +let selectNote: ReturnType +let setSearchOpen: ReturnType +let setConnectionPreview: ReturnType + +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 + ? `` + : `` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `
${rows}
` + ) + 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) => + `` + ).join('') + document.body.insertAdjacentHTML('beforeend', ``) + 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) => `
  • ` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `
      ${rows}
    ` + ) + layOut('[data-outline-idx]') + jumped = [] + document.querySelectorAll('[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) => `
    +

    Comment ${i}

    +
    + ${['jump', 'reply', 'edit', 'resolve', 'delete'] + .map((action) => ``) + .join('')} +
    +
    ` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `` + ) + // 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('[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 => { + 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('[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']) + // 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', () => { + 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-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()), + 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) => + `` + ).join('') + document.body.insertAdjacentHTML( + 'beforeend', + `
    + +
    Inbox
    +
    Projects
    + ${notes} +
    ` + ) + document.querySelectorAll('[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(`[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 + let selectNote: ReturnType + let toggleCollapseFolder: ReturnType + let setView: ReturnType + let setSearchOpen: ReturnType + + 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 d5e1ac0d0..6b5d2b884 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 @@ -36,6 +39,7 @@ import { findLeaf } from '../lib/pane-layout' import { boundedIndexCount, clampIndex, moveIndex } from '../lib/index-navigation' import { advanceSequence, + formatKeyToken, getKeymapBinding, getKeymapDisplay, getSequenceTokens, @@ -43,10 +47,21 @@ 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' +import { + isSidebarTrashKey, + sidebarHoldsFocus, + 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, @@ -65,7 +80,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, @@ -91,9 +107,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) @@ -103,7 +124,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(null) + // The keys pressed after the leader so far in a command's sequence, while + // the stage is 'leader-cmd' (#971). + const commandLeaderPrefix = useRef([]) const ctrlWTimer = useRef>() const jumpTopTimer = useRef>() const previousBufferTimer = useRef>() @@ -118,8 +142,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) => { @@ -220,6 +245,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 ) @@ -235,17 +263,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) } @@ -281,9 +312,25 @@ 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-cmd') { + return commandLeaderHints(commandKeymaps, whichKeyState.commandPrefix ?? []).map((hint) => ({ + keyLabel: formatKeyToken(hint.key), + label: hint.label, + detail: hint.detail + })) + } if (whichKeyState.stage === 'leader-s') { return [ { @@ -412,6 +459,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) { @@ -421,6 +477,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) @@ -550,6 +610,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 `n` closes the panel + // from inside it and `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 @@ -1132,10 +1207,67 @@ 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 + } + // 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() @@ -1172,6 +1304,13 @@ export function VimNav(): JSX.Element | null { openCoverPicker() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderNoteCoverUnsplash')) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + openUnsplashCoverPicker() + return + } resetLeader() } @@ -1195,6 +1334,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 @@ -1460,39 +1604,124 @@ 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 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 + // 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) 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 ( + 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 + // 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( + `${EXCALIDRAW_SURFACE}, [data-calendar-panel], [data-timeline-panel], [data-selection-toolbar]` + )) + ) { + return + } + handlePanelKey(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): 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. + const sidebarHeldFocus = sidebarHoldsFocus(state) if (state.focusedPanel !== 'sidebar') state.setFocusedPanel('sidebar') const items = getIndexedElements('[data-sidebar-idx]', 'sidebarIdx') 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, 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 = 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. + // `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') || - 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' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || - wantsContextMenu + wantsContextMenu || + wantsTrash || + pageMove !== null if (wantsHandledKey) { e.preventDefault() e.stopImmediatePropagation() @@ -1502,19 +1731,39 @@ export function VimNav(): JSX.Element | null { if (count === 0) return // nothing to navigate - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (wantsTrash) { + if (sidebarHeldFocus && !e.repeat) void trashSidebarCursorRow() + return + } + + if (pageMove) { + const target = + pageMove === 'first' + ? 0 + : pageMove === 'last' + ? max + : moveIndex( + currentPos, + count, + (pageMove === 'pageDown' ? 1 : -1) * pageRowCount(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'), @@ -1532,15 +1781,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 } @@ -1548,7 +1797,7 @@ export function VimNav(): JSX.Element | null { focusEditor() return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (bound('nav.filter')) { state.setSearchOpen(true) return } @@ -1561,29 +1810,34 @@ export function VimNav(): JSX.Element | null { function handleNoteListKey(e: KeyboardEvent, state: ReturnType): 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() @@ -1593,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', @@ -1602,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', @@ -1611,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'), @@ -1633,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 } @@ -1654,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 } @@ -1672,24 +1949,30 @@ export function VimNav(): JSX.Element | null { function handleConnectionsKey(e: KeyboardEvent, state: ReturnType): 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() @@ -1698,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)], @@ -1714,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)], @@ -1723,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'), @@ -1747,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 @@ -1767,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): 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 @@ -1779,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'), @@ -1831,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() } } @@ -1845,6 +2157,13 @@ export function VimNav(): JSX.Element | null { function handleCommentsKey(e: KeyboardEvent, state: ReturnType): 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]') && @@ -1855,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() @@ -1885,34 +2205,45 @@ export function VimNav(): JSX.Element | null { if (state.focusedPanel !== 'comments') state.setFocusedPanel('comments') document.querySelector('[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('[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'), @@ -1932,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) } } @@ -2272,6 +2598,31 @@ export function VimNav(): JSX.Element | null { el?.querySelector(`[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 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) + }) + } + function openContextMenuForIndexedElement(el: HTMLElement | undefined): void { if (!el) return dispatchKeyboardContextMenu(el) @@ -2541,7 +2892,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 ( ): 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[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/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 => ( ) +/** Tabler's `timeline`: a line through four points, for the Timeline panel. */ +export const TimelineIcon = (p: IconProps): JSX.Element => ( + + + + + + + +) + export const KanbanIcon = (p: IconProps): JSX.Element => ( diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index 4eb07cfe8..cc9bec92b 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -34,6 +34,20 @@ 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, and as a command-palette + * 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 } export interface EditorHostRegistration { 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/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 + }) + 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({ + 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({ + 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-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-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-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-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({ + combine: (values) => values.some(Boolean) +}) + const noteCoverSlotSink = Facet.define({ 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-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 `# ` 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-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<void> => 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<boolean>() + +const revealingField = StateField.define<boolean>({ + 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<boolean> { + 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<boolean> { + return setRevealing.of(false) +} 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/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/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<number> { const lines = new Set<number>() - 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 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..f25aad562 --- /dev/null +++ b/packages/app-core/src/lib/command-keymaps.ts @@ -0,0 +1,728 @@ +/** + * 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, + resolveSequenceToken, + 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 && resolveSequenceToken(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-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<string> = 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/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 1a8970384..a4c864f46 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', @@ -502,3 +526,210 @@ 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 }) + }) +}) + +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) + }) +}) + +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) + }) +}) + +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') + }) +}) + +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') + // 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 ?? '' + if (!shown) return false + return ( + shown.startsWith(':') || + (!!leader && shown.startsWith(`${leader} `)) || + (!!pane && shown.startsWith(`${pane} `)) || + (command.id in vimKeyed && shown === vimKeyed[command.id]) + ) + }) + .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 1736d39b5..e865ddc48 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -18,11 +18,13 @@ 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' 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' @@ -45,18 +47,27 @@ import { activeNoteCover, coverNotePath, openCoverPicker, + openUnsplashCoverPicker, removeCover, setCoverFromClipboard, 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' 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 { isCalendarToggleAvailable, noteFolderSubpath } from './vault-layout' +import { + findDailyNoteForDate, + findMonthlyNoteForDate, + findWeeklyNoteForDate, + isCalendarToggleAvailable +} from './vault-layout' import { runWorkflowById } from './workflow-trigger' import { requestPublishNote } from './publish-note-requests' import { activeReferencePath } from './pinned-reference' @@ -87,7 +98,8 @@ async function runFoldCommand(command: FoldCommand): Promise<void> { 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 { @@ -105,10 +117,43 @@ export interface Command { when?: () => boolean /** Runs when the user picks this entry. Async is fine. */ run: () => void | Promise<void> + /** + * 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 + /** + * 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' @@ -119,9 +164,17 @@ 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()) + // 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') @@ -150,7 +203,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 }) } @@ -188,40 +241,51 @@ 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', 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', + 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', 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 }) } }, { 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', shortcut: leaderShortcut('vim.leaderWeeklyNote'), when: () => getState().vaultSettings.weeklyNotes.enabled, - run: () => getState().openThisWeekWeeklyNote() + opensToRead: () => periodicNoteExists(findWeeklyNoteForDate), + run: () => getState().openThisWeekWeeklyNote({ readIfExisting: true }) }, { 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', shortcut: leaderShortcut('vim.leaderMonthlyNote'), when: () => getState().vaultSettings.monthlyNotes.enabled, - run: () => getState().openThisMonthMonthlyNote() + opensToRead: () => periodicNoteExists(findMonthlyNoteForDate), + run: () => getState().openThisMonthMonthlyNote({ readIfExisting: true }) }, { id: 'template.create', @@ -241,7 +305,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() }, { @@ -256,7 +320,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) }, { @@ -264,11 +328,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', @@ -285,6 +350,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', @@ -303,6 +369,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', @@ -314,11 +381,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'), @@ -335,25 +403,25 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'note.save', title: 'Save Note', category: 'Note', - shortcut: ':w', + shortcut: vimOnly(':w'), keywords: 'persist write', - when: () => !!getState().selectedPath, + when: hasSelection, run: () => getState().persistActive() }, { id: 'note.format', title: 'Format Markdown', category: 'Note', - shortcut: ':format', + shortcut: vimOnly(':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 @@ -369,13 +437,15 @@ 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) 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' @@ -384,21 +454,48 @@ 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', - shortcut: chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCover'), + shortcut: vimOnly(chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCover')), when: () => !!coverNotePath(), run: () => { openCoverPicker() } }, + { + 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', + shortcut: vimOnly(chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderNoteCoverUnsplash')), + when: () => !!coverNotePath() && unsplashSearchAvailable(), + run: () => { + 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', 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() @@ -407,10 +504,11 @@ 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', - shortcut: ':cover reposition', + shortcut: vimOnly(':cover reposition'), when: () => !!activeNoteCover(), run: () => { startCoverReposition() @@ -418,10 +516,11 @@ 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', - shortcut: ':cover!', + shortcut: vimOnly(':cover!'), when: () => !!activeNoteCover(), run: async () => { const path = coverNotePath() @@ -430,16 +529,34 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.archive', + // 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', + unavailableReason: () => `Unarchive Note only works on a note in the ${labels().archive}`, title: 'Unarchive Note', category: 'Note', when: () => getState().activeNote?.folder === 'archive', @@ -447,14 +564,32 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'note.trash', + // 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', + 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', @@ -462,6 +597,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', @@ -473,7 +609,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 @@ -486,7 +622,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() } @@ -496,7 +632,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() } @@ -536,7 +672,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 @@ -615,7 +751,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) @@ -626,7 +762,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() @@ -680,20 +816,24 @@ 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', shortcut: shortcut('global.reopenClosedTab'), when: () => getState().closedTabStack.length > 0, + opensToRead: () => true, run: () => getState().reopenLastClosedTab() }, { id: 'tab.next', + unavailableReason: () => 'No other tab to go to', title: 'Next Tab', category: 'Tabs', shortcut: shortcut('tabs.next'), @@ -702,10 +842,12 @@ 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) }, { id: 'tab.previous', + unavailableReason: () => 'No other tab to go to', title: 'Previous Tab', category: 'Tabs', shortcut: shortcut('tabs.previous'), @@ -714,14 +856,16 @@ 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) }, { 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) @@ -729,6 +873,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', @@ -743,6 +888,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', @@ -786,7 +932,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) }, { @@ -795,7 +941,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')) } @@ -806,7 +952,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')) } @@ -817,13 +963,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, @@ -835,12 +982,25 @@ 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: hasNote, + 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', - when: () => !!getState().activeNote, + keywords: 'close hide dismiss right panel pane connections comments outline calendar timeline', + when: hasNote, run: () => { window.dispatchEvent(new Event('zen:close-right-panel')) } @@ -851,7 +1011,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') }, { @@ -860,7 +1020,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') }, { @@ -869,7 +1029,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') }, { @@ -976,36 +1136,36 @@ 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: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('foldAtCursor') }, { 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: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('unfoldAtCursor') }, { 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: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('foldAllOutline') }, { 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: () => !!getState().editorViewRef && !!getState().activeNote, + when: hasEditorNote, run: () => runFoldCommand('unfoldAll') }, { @@ -1076,7 +1236,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 @@ -1093,7 +1253,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) @@ -1105,7 +1265,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 @@ -1140,7 +1300,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 @@ -1152,12 +1312,13 @@ 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 copyLinkAtCursor(view) - view.focus() + // Copying is not typing: a phone keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() } }, { @@ -1165,7 +1326,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 @@ -1176,16 +1337,18 @@ 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() }, { 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() }, { @@ -1194,6 +1357,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() } ) @@ -1204,9 +1368,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'split.right', title: 'Split Right', category: 'Panes', - shortcut: ':vsplit', + shortcut: vimOnly(':vsplit'), keywords: 'vsplit vertical', - when: () => !!getState().selectedPath, + when: hasSelection, run: () => { const st = getState() const path = st.selectedPath @@ -1222,9 +1386,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'split.down', title: 'Split Down', category: 'Panes', - shortcut: ':split', + shortcut: vimOnly(':split'), keywords: 'split horizontal', - when: () => !!getState().selectedPath, + when: hasSelection, run: () => { const st = getState() const path = st.selectedPath @@ -1360,18 +1524,20 @@ 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', + shortcut: vimOnly(':tasks'), keywords: 'tasks todo checklist due waiting done vault', when: () => !isTasksViewActive(getState()), run: () => getState().openTasksView() }, { id: 'view.workflows', + 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. @@ -1380,6 +1546,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'), @@ -1390,9 +1557,10 @@ 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', + shortcut: leaderShortcut('vim.leaderUnresolvedLinks'), keywords: 'unresolved links missing broken dangling wikilinks notes to write later create orphan dead', when: () => !isUnresolvedLinksViewActive(getState()), @@ -1400,9 +1568,10 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'view.tags', + 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() @@ -1433,6 +1602,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', @@ -1442,6 +1612,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', @@ -1454,6 +1625,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', @@ -1462,6 +1634,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', @@ -1470,6 +1643,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', @@ -1478,6 +1652,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', @@ -1486,6 +1661,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', @@ -1494,6 +1670,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', @@ -1502,6 +1679,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', @@ -1510,6 +1688,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', @@ -1519,6 +1698,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' @@ -1533,12 +1713,23 @@ 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) }, + { + // 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', + unavailableReason: () => 'Line numbers already show this way', title: 'Line Numbers: Off', category: 'View', when: () => getState().lineNumberMode !== 'off', @@ -1546,6 +1737,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', @@ -1553,6 +1745,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', @@ -1563,6 +1756,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' @@ -1575,6 +1769,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' @@ -1605,12 +1800,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', @@ -1618,6 +1815,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', @@ -1625,6 +1823,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', @@ -1632,6 +1831,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', @@ -1639,6 +1839,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', @@ -1649,6 +1850,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', @@ -1656,6 +1858,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', @@ -1666,6 +1869,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', @@ -1677,6 +1881,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', @@ -1688,6 +1893,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', @@ -1699,6 +1905,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', @@ -1710,6 +1917,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', @@ -1721,6 +1929,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', @@ -1732,12 +1941,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', @@ -1747,6 +1958,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', @@ -1757,6 +1969,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'), @@ -1765,6 +1978,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', @@ -1773,6 +1987,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' @@ -1783,6 +1998,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', @@ -1791,6 +2007,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', @@ -1806,7 +2023,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) @@ -1814,6 +2031,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, @@ -1823,6 +2041,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', @@ -1856,9 +2075,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', @@ -1933,6 +2152,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', @@ -2055,6 +2275,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', @@ -2106,9 +2327,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', @@ -2420,9 +2640,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 @@ -2438,6 +2657,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/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/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index 5b76808cc..0aab7b6ee 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<string, unknown>, 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 { + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, + focusEditorNormalMode +} from './editor-focus' +import { installNoteEditorHost } from './editor-host' +import { revealedSelectionRanges, sourceRevealExtension } from './cm-source-reveal' describe('focusEditorNormalMode', () => { beforeEach(() => { @@ -62,3 +70,96 @@ 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 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') + } + }) + focusEditorAfterReadOpen() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + }) + + 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 }) + focusEditorAfterReadOpen() + 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([]) + focusEditorAfterReadOpen() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + expect(revealedSelectionRanges(view!.state)).toEqual([]) + }) + + 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 }) + 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 935e76be4..0a45f0911 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,48 @@ export function focusEditorNormalMode( requestAnimationFrame(() => run(attempts)) } + +/** + * 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 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 focusEditorAfterReadOpen( + options: { attempts?: number; delayMs?: number } = {} +): void { + if (!hostOpensNotesToRead()) { + focusEditorNormalMode(options) + return + } + requestAnimationFrame(() => { + const view = useStore.getState().editorViewRef + if (!view) return + if (view.hasFocus) view.contentDOM.blur() + view.dispatch({ effects: readSourceRevealEffect() }) + }) +} + +/** + * 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 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 focusEditorAfterPaletteClose(): 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 diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 44688ef4f..0e5f8e5cb 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', @@ -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, 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', body: @@ -139,7 +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. 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). 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', @@ -151,11 +161,26 @@ 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: '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: @@ -194,7 +219,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', @@ -219,7 +244,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', @@ -277,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', @@ -334,6 +359,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: @@ -342,7 +372,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 <text>` 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 <name>`) and it becomes a chip there; click a chip, run `:filter <name>`, 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 <text>` 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 <name>`) and it becomes a chip there; click a chip, run `:filter <name>`, 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', @@ -357,7 +387,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', @@ -545,6 +575,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.' }, @@ -554,10 +585,11 @@ 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: '↑ / ↓ / 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: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions: they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.) Both walk the same cycle, in the order the panels appear on screen: sidebar → note list → editor → connections → comments → outline → calendar → timeline, and back again.' }, + { keys: '↑ / ↓ / → / ← / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus (the sidebar, the note list, connections, the outline or comments), the arrows move its row cursor, Enter or → opens the row under the cursor, Esc returns focus to the editor, and ← goes back the way `h` does there: from the note list to the sidebar, from connections, the outline and comments to the editor. Shift+F10 or the Context Menu key opens the row’s menu in the sidebar and the note list. In comments, Enter or → jumps to the comment’s text, and Backspace or Delete deletes it after a confirm. With Vim mode off, Home and End jump to the ends and Page Up / Page Down move a screenful of rows; the single-key motions (j / k, gg / G) and the panels’ letter keys stay Vim-only. The sidebar, reached with Alt+H or Focus Sidebar in the command palette, works like a tree: → opens the row or expands its folder, ← collapses a folder, or from a note folds its folder and lands on it (Esc still returns to the editor).' }, { keys: '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.' }, @@ -566,6 +598,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.' }, @@ -617,8 +650,10 @@ 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}`.' }, { 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.' }, @@ -650,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. 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.' }, @@ -661,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.' } ] }, @@ -718,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.' }, @@ -809,6 +846,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', @@ -816,8 +858,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>', @@ -872,7 +919,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', @@ -1094,6 +1141,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', @@ -1127,7 +1179,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', @@ -1167,6 +1224,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.' }, @@ -1211,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.' } ] @@ -1219,12 +1277,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.' } ] }, @@ -1235,6 +1294,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`. 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.' }, @@ -1293,7 +1353,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.' } ] } diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 6faeb60bc..b2af3f2d6 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 @@ -587,3 +592,194 @@ 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) + }) + }) +}) + +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>')) + }) +}) + +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 025f791d9..d9af1ac0a 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" @@ -69,6 +70,7 @@ export type KeymapId = | "vim.leaderToggleFavorite" | "vim.leaderTableToDatabase" | "vim.leaderNoteCover" + | "vim.leaderNoteCoverUnsplash" | "vim.leaderQuickCapture" | "vim.leaderHideToTray" | "vim.leaderTemplatePicker" @@ -77,6 +79,7 @@ export type KeymapId = | "vim.leaderWeeklyNote" | "vim.leaderMonthlyNote" | "vim.leaderCalendar" + | "vim.leaderTimeline" | "vim.leaderUnresolvedLinks" | "vim.panePrefix" | "vim.paneFocusLeft" @@ -125,6 +128,7 @@ export type KeymapId = | "nav.peekPreview" | "nav.restore" | "nav.delete" + | "nav.sidebarTrash" | "nav.toggleTask" | "nav.localEx" | "nav.newQuickNote" @@ -325,6 +329,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", @@ -738,6 +753,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", @@ -827,6 +854,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", @@ -1345,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", @@ -1647,7 +1697,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 }; @@ -1851,13 +1902,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 @@ -1865,13 +1920,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+/) @@ -2069,6 +2145,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, @@ -2196,7 +2297,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..8f4889ae9 --- /dev/null +++ b/packages/app-core/src/lib/list-view-keys.test.ts @@ -0,0 +1,204 @@ +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('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() + 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/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<boolean> { + 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 <arg>`: 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/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/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/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/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 } 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/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/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/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/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/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/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/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>): 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<string, readonly string[]> + +export function noteTagsByPath(notes: readonly Pick<NoteMeta, 'path' | 'tags'>[]): 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) 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/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 new file mode 100644 index 000000000..bf763ae61 --- /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<UnsplashSearchFn>(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<UnsplashSearchFn>((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<UnsplashSearchFn>(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<UnsplashSearchFn>(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<UnsplashSearchFn>(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<UnsplashSearchFn>() + .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: null, limit: 50 }) + 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<UnsplashSearchFn>(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..3390b0fd1 --- /dev/null +++ b/packages/app-core/src/lib/unsplash-search.ts @@ -0,0 +1,220 @@ +/** + * 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. + * + * 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 + /** The key's hourly allowance, when Unsplash said. */ + limit: number | null + } + +export type UnsplashSearchFn = (query: string, page: number) => Promise<UnsplashSearchResult> + +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<string, UnsplashSearchPage>() + 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<typeof setTimeout> | 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<UnsplashSearchResult> => { + 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, limit: null } + } + if (result.ok) cache.set(result.page) + return result + } + + const run = async (query: string, mine: number): Promise<void> => { + const result = await ask(query, 1) + if (mine !== generation || disposed) return + if (!result.ok) { + emit({ status: 'failed', query, reason: result.reason, retryAt: result.retryAt, limit: result.limit }) + 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<UnsplashSearchState>({ status: 'idle' }) + const controller = useRef<UnsplashSearchController | null>(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/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/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/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 { 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/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/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: [ diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index d11a06226..6baf74b54 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'), @@ -1876,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 a4983b641..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, @@ -264,6 +265,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' @@ -299,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' @@ -308,9 +314,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,15 +517,24 @@ 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. */ -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 @@ -521,7 +542,8 @@ export const DEFAULT_PANEL_WIDTHS: PanelWidths = { outline: 260, connections: 288, comments: 360, - calendar: 280 + calendar: 280, + timeline: 290 } function clampPanelWidth(px: number): number { @@ -536,7 +558,8 @@ function normalizePanelWidths(value: unknown): PanelWidths { outline: pick('outline'), connections: pick('connections'), comments: pick('comments'), - calendar: pick('calendar') + calendar: pick('calendar'), + timeline: pick('timeline') } } @@ -558,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. */ @@ -684,6 +711,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. */ @@ -778,6 +808,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 @@ -1112,6 +1144,7 @@ export const DEFAULT_PREFS: Prefs = { vimBlockImeInNormalMode: true, vimWrappedLineMotions: 'display', keymapOverrides: {}, + commandKeymaps: {}, whichKeyHints: true, whichKeyHintMode: 'timed', whichKeyHintTimeoutMs: 900, @@ -1179,6 +1212,7 @@ export const DEFAULT_PREFS: Prefs = { unifiedSidebar: true, darkSidebar: true, showWindowTitleBar: true, + showStatusBar: true, showSidebarChevrons: true, collapsedFolders: [], pinnedRefPath: null, @@ -1215,6 +1249,7 @@ export const DEFAULT_PREFS: Prefs = { kanbanGroupBy: 'status', kanbanCardSort: 'manual', tasksListOrder: 'note', + timelineRange: DEFAULT_TIMELINE_RANGE, kanbanColumnTitles: {}, kanbanColumnOrder: {}, kanbanCardOrder: {}, @@ -1259,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: @@ -1461,6 +1503,8 @@ function normalizePrefs(p: Partial<Prefs>): 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 @@ -1584,6 +1628,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), @@ -2443,6 +2488,7 @@ function collectPrefs(s: { vimBlockImeInNormalMode: boolean vimWrappedLineMotions: VimWrappedLineMotionMode keymapOverrides: KeymapOverrides + commandKeymaps: CommandKeymaps enabledOverrides: Record<string, string> themeTweaks: Record<string, string> whichKeyHints: boolean @@ -2507,6 +2553,7 @@ function collectPrefs(s: { unifiedSidebar: boolean darkSidebar: boolean showWindowTitleBar: boolean + showStatusBar: boolean showSidebarChevrons: boolean collapsedFolders: string[] pinnedRefPath: string | null @@ -2543,6 +2590,7 @@ function collectPrefs(s: { kanbanGroupBy: KanbanGroupBy kanbanCardSort: KanbanCardSort tasksListOrder: TasksListOrder + timelineRange: TimelineRange kanbanColumnTitles: Record<string, string> kanbanColumnOrder: Record<string, string[]> kanbanCardOrder: Record<string, string[]> @@ -2561,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, @@ -2625,6 +2674,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, @@ -2658,6 +2708,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, @@ -2951,12 +3002,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 ) } @@ -3069,6 +3120,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 @@ -3109,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]. */ @@ -3205,6 +3260,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). */ @@ -3227,7 +3283,8 @@ interface Store { /** Auto-title new Quick Notes to today's date instead of the * default "Quick Note <ts>" 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. */ @@ -3326,6 +3383,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). */ @@ -3596,6 +3659,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, @@ -3756,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 @@ -3857,6 +3926,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 @@ -3882,12 +3952,15 @@ interface Store { setQuickNoteDateTitle: (on: boolean) => void setQuickNoteTitlePrefix: (prefix: string | null) => void - openTodayDailyNote: () => Promise<void> - openThisWeekWeeklyNote: () => Promise<void> - openThisMonthMonthlyNote: () => Promise<void> + openTodayDailyNote: (options?: PeriodicNoteOpenOptions) => Promise<void> + openThisWeekWeeklyNote: (options?: PeriodicNoteOpenOptions) => Promise<void> + openThisMonthMonthlyNote: (options?: PeriodicNoteOpenOptions) => Promise<void> 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<void> @@ -3938,9 +4011,9 @@ interface Store { setAutoCalendarPanel: (enabled: boolean) => void setCalendarWeekStart: (start: CalendarWeekStart) => void setCalendarShowWeekNumbers: (show: boolean) => void - openDailyNoteForDate: (date: Date) => Promise<void> - openWeeklyNoteForDate: (date: Date) => Promise<void> - openMonthlyNoteForDate: (date: Date) => Promise<void> + openDailyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise<void> + openWeeklyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise<void> + openMonthlyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise<void> /** 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. */ @@ -6038,6 +6111,7 @@ export const useStore = create<Store>((set, get) => { templatePaletteOpen: false, embedDrawingPaletteOpen: false, noteCoverPickerPath: null, + noteCoverPickerStart: null, noteCoverReposition: null, excalidrawPreviewVersion: 0, templatePaletteMode: 'create', @@ -6060,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, @@ -6127,6 +6202,7 @@ export const useStore = create<Store>((set, get) => { unifiedSidebar: loadPrefs().unifiedSidebar, darkSidebar: loadPrefs().darkSidebar, showWindowTitleBar: loadPrefs().showWindowTitleBar, + showStatusBar: loadPrefs().showStatusBar, showSidebarChevrons: loadPrefs().showSidebarChevrons, collapsedFolders: DEFAULT_PREFS.collapsedFolders, pinnedRefPath: loadPrefs().pinnedRefPath, @@ -6161,6 +6237,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, @@ -7663,6 +7741,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 @@ -9214,7 +9297,8 @@ export const useStore = create<Store>((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 }), @@ -9294,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) => { @@ -9758,6 +9852,10 @@ export const useStore = create<Store>((set, get) => { set({ showWindowTitleBar: on }) savePrefs(collectPrefs(get())) }, + setShowStatusBar: (on) => { + set({ showStatusBar: on }) + savePrefs(collectPrefs(get())) + }, setDarkSidebar: (on) => { set({ darkSidebar: on }) savePrefs(collectPrefs(get())) @@ -9926,7 +10024,7 @@ export const useStore = create<Store>((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 @@ -9947,7 +10045,8 @@ export const useStore = create<Store>((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. @@ -9956,8 +10055,8 @@ export const useStore = create<Store>((set, get) => { } }, - openTodayDailyNote: async () => { - await get().openDailyNoteForDate(new Date()) + openTodayDailyNote: async (options) => { + await get().openDailyNoteForDate(new Date(), options) }, ensureDailyNoteForDate: trackNoteWrite(null, async (date) => { @@ -10141,7 +10240,7 @@ export const useStore = create<Store>((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 @@ -10159,14 +10258,14 @@ export const useStore = create<Store>((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 @@ -10184,11 +10283,11 @@ export const useStore = create<Store>((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) => diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 786385c0c..68140a912 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 @@ -2260,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/bridge-contract/package.json b/packages/bridge-contract/package.json index 529d80ae2..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", @@ -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/app-config.ts b/packages/bridge-contract/src/app-config.ts index 01845a0bd..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 @@ -73,6 +74,7 @@ export const PORTABLE_PREF_KEYS = [ 'themeTweaks', 'darkSidebar', 'showWindowTitleBar', + 'showStatusBar', 'showSidebarChevrons', 'contentAlign', 'noteCoverPlacement', @@ -103,6 +105,7 @@ export const PORTABLE_PREF_KEYS = [ 'kanbanGroupBy', 'kanbanCardSort', 'tasksListOrder', + 'timelineRange', 'kanbanFolderRoot', 'kanbanColumnTitles', 'kanbanStatuses', diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 25bb51b5c..312ae52cd 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -43,6 +43,12 @@ import type { VaultTextSearchToolPaths } from './ipc.js' import type { CustomTemplateFile, WriteTemplateInput } from './templates.js' +import type { + UnsplashKeyCheck, + UnsplashKeySaveResult, + UnsplashKeyStatus, + UnsplashSearchResult +} from './unsplash.js' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -179,6 +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, 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 b109fc9dd..a06bcd3a2 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -112,6 +112,12 @@ 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', + 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', @@ -410,9 +416,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' @@ -427,6 +440,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 +606,11 @@ 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. 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 folderIcons: Record<string, FolderIconId> @@ -670,6 +698,7 @@ export const DEFAULT_VAULT_SETTINGS: VaultSettings = { drawingsLocation: { mode: 'primary' }, databasesLocation: { mode: 'primary' }, tasksLocation: { mode: 'primary' }, + sharesLocation: { mode: 'quick' }, folderIcons: {}, folderColors: {}, favorites: [], @@ -961,6 +990,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 { diff --git a/packages/bridge-contract/src/unsplash.ts b/packages/bridge-contract/src/unsplash.ts new file mode 100644 index 000000000..267dd2cec --- /dev/null +++ b/packages/bridge-contract/src/unsplash.ts @@ -0,0 +1,76 @@ +/** + * 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. */ +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 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 + /** 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/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-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index c7963a24a..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', @@ -142,6 +143,7 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { themeTweaks: {}, darkSidebar: true, showWindowTitleBar: true, + showStatusBar: true, showSidebarChevrons: true, contentAlign: 'center', noteCoverPlacement: 'scroll', @@ -169,6 +171,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/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts new file mode 100644 index 000000000..ab7ede7ab --- /dev/null +++ b/packages/shared-domain/src/commands-catalog.ts @@ -0,0 +1,226 @@ +// 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.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" }, + { 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) +} diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index d8d4e7760..d936ee72e 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" }, @@ -101,6 +102,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" }, @@ -109,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" }, @@ -157,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" }, 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 dfe69df7e..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 @@ -122,6 +124,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' @@ -136,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 8299179f8..816015b9a 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,27 +47,46 @@ 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 } +/** 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`. */ @@ -115,6 +146,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<string, string>, 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 +165,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 } @@ -143,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. */ @@ -152,18 +202,34 @@ 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, { - [key]: noteCoverValue(target), - [`${key}_y`]: null, - [`${key}-y`]: null + const next = updateFrontmatterFields(body, { + ...clearedFields(key), + [key]: noteCoverValue(target) }) + 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,15 +242,52 @@ 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 - return dropEmptyFrontmatter( - updateFrontmatterFields(body, { - [cover.key]: null, - [`${cover.key}_y`]: null, - [`${cover.key}-y`]: 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 } } 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 } 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())}` +} 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/unsplash.test.ts b/packages/shared-domain/src/unsplash.test.ts new file mode 100644 index 000000000..48c3fb396 --- /dev/null +++ b/packages/shared-domain/src/unsplash.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it, vi } from 'vitest' +import { + UNSPLASH_REQUESTS_PER_MINUTE, + checkUnsplashAccessKey, + createUnsplashClient, + isUnsplashDownloadLocation, + isUnsplashImageUrl, + noteCoverCreditLine, + normalizeUnsplashAccessKey, + normalizeUnsplashQuery, + parseUnsplashSearchResponse, + searchUnsplashPhotos, + trackUnsplashPhotoDownload, + unsplashCoverUrl, + unsplashReferralUrl, + type UnsplashFetch, + 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', + description: null, + alt_description: 'fog over a valley', + urls: { raw: RAW, small: `${RAW}&w=400` }, + 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 + } +} + +function response(status: number, json: unknown, headers: Record<string, string> = {}): 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('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 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, + 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 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', 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' + ] + expect(parseUnsplashSearchResponse({ total_pages: 1, results: [...bad, result()] }, 'fog', 1)?.photos).toHaveLength(1) + }) + + 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() + expect(parseUnsplashSearchResponse(null, 'q', 1)).toBeNull() + }) +}) + +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]! + 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(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>() + expect(await searchUnsplashPhotos(fetchFn, { key: KEY, query: ' a ' })).toMatchObject({ ok: true, page: { photos: [] } }) + expect(fetchFn).not.toHaveBeenCalled() + }) + + 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: 'invalid-key', + retryAt: null, + limit: null + }) + 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('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 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 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', () => { + 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..4be7f9cff --- /dev/null +++ b/packages/shared-domain/src/unsplash.ts @@ -0,0 +1,441 @@ +/** + * 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 <name> 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. 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 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_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. */ +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' +} + +/** 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 +} + +/** 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, ' ') + 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 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> + // 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 (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_description, 300) ?? text(e.description, 300), + rawUrl: urls.raw as string, + thumbUrl: urls.small 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 + } +} + +/** 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, + page: number +): UnsplashSearchPage | null { + if (!json || typeof json !== 'object') return null + const body = json as Record<string, unknown> + 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<unknown> +} +export type UnsplashFetch = (url: string, init: RequestInit) => Promise<UnsplashFetchResponse> + +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> +} + +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: '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', + // 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) + } +} + +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 +} + +/** 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: { key: string; query: string; page?: number }, + options: UnsplashApiOptions = {} +): Promise<UnsplashSearchResult> { + const query = normalizeUnsplashQuery(input.query) + 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(`${apiBase(options)}/search/photos?${params}`, apiRequest(input.key, options.headers)) + } catch { + return failed('offline', 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 } : failed('unavailable', null) +} + +/** 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 trackUnsplashPhotoDownload( + fetchFn: UnsplashFetch, + input: { key: string; downloadLocation: string }, + options: UnsplashApiOptions = {} +): Promise<void> { + if (!isUnsplashDownloadLocation(input.downloadLocation)) return + const location = new URL(input.downloadLocation) + try { + 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 } +} + +/** The credit line drawn on a cover: "Photo by <name> 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 + } +} 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, 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" 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}]})})