diff --git a/apps/desktop/build/after-pack.js b/apps/desktop/build/after-pack.js index b5e5a543..f3549728 100644 --- a/apps/desktop/build/after-pack.js +++ b/apps/desktop/build/after-pack.js @@ -36,6 +36,21 @@ 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. + */ +async function quickLookSigning(context) { + const { findIdentity, isSignAllowed } = require('app-builder-lib/out/codeSign/macCodeSign') + const config = context.packager.platformSpecificBuildOptions + if (!isSignAllowed(false) || config.identity === null) return null + const { keychainFile } = await context.packager.codeSigningInfo.value + const identity = await findIdentity('Developer ID Application', config.identity, keychainFile) + return identity ? { identity: identity.hash, keychain: keychainFile, timestamp: config.timestamp || undefined } : null +} + exports.default = async function afterPack(context) { const platform = context.electronPlatformName if (platform === 'darwin' || platform === 'linux') { @@ -55,6 +70,21 @@ exports.default = async function afterPack(context) { // cli-install.ts reads the same name. await stageTerminalArtifact({ platform, arch, localDirectory, allowLocal, output: path.join(resources, 'zn-cli') }) } + if (platform === 'darwin') { + // The Quick Look preview for Markdown files (apps/quicklook). It lands in + // 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 { 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`), + arch, + appId: context.packager.appInfo.id, + version: context.packager.appInfo.version, + signing: await quickLookSigning(context), + selfTest: process.env.ZENNOTES_QUICKLOOK_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 db679764..e68a5033 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.61.0", + "version": "2.62.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 57a4e7c3..3501b0fe 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -17,6 +17,21 @@ vi.mock('electron', () => ({ } })) +// A gate on the atomic writer, so a test can hold one of the module's own +// writes mid-flight: the moment a watcher read can still observe the write +// before it. Every other write goes straight through. +const writeGate: { hold: Promise | null } = { hold: null } +vi.mock('./vault', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomic: async (absPath: string, data: string): Promise => { + if (writeGate.hold) await writeGate.hold + return actual.writeFileAtomic(absPath, data) + } + } +}) + import { getConfigDir, getConfigFilePath, @@ -59,6 +74,7 @@ beforeEach(async () => { }) afterEach(async () => { + writeGate.hold = null await stopAppConfigWatcher() for (const key of ENV_KEYS) { if (savedEnv[key] === undefined) delete process.env[key] @@ -115,6 +131,7 @@ describe('TOML serialization', () => { systemFolderLabels: { inbox: 'In' }, savedTaskFilters: { 'Project alpha': '@project:alpha', Blocked: '@status:blocked' }, kanbanGroupBy: 'folder', + kanbanCardSort: 'due', kanbanFolderRoot: 'Projects', externalApplicationSchemes: ['zotero', 'obsidian'], ignoredKeys: ['KanaMode', 'F24'] @@ -146,6 +163,8 @@ describe('TOML serialization', () => { // The [saved_filters] table keeps the order the chips show (#731). expect(text).toContain('kanban_folder_root = "Projects"') expect(round.kanbanGroupBy).toBe('folder') + expect(text).toContain('kanban_card_sort = "due"') + expect(round.kanbanCardSort).toBe('due') expect(round.kanbanFolderRoot).toBe('Projects') expect(text).toContain('ignored_keys = ["KanaMode", "F24"]') expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian']) @@ -245,24 +264,39 @@ describe('persistence + cache', () => { }) it('does not let a stale watcher read of an earlier own-write clobber the cache', async () => { - // Regression for a Windows CI flake: two rapid writes let the file watcher - // observe the FIRST own-write out of order; with only a single-value - // loop-guard it treated that stale read as an external edit and reverted - // the freshly-merged cache. The watcher must recognize any recent own-write. + // Regression for a Windows CI flake: with two writes in a row, the watcher + // can read the file while the second is still landing and see the FIRST. + // With only a single-value loop-guard that read counted as an external + // edit and reverted the freshly-merged cache. process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-stale-') - await initAppConfig(() => {}) + const changes: AppConfigPortable[] = [] + await initAppConfig((next) => changes.push(next)) await setPortableConfig({ editorFontSize: 18 }) - const staleText = await readFile(getConfigFilePath(), 'utf8') - await setPortableConfig({ editorFontSize: 22 }) - - // Write the earlier own-write back and let the debounced watcher process it. - await writeFile(getConfigFilePath(), staleText) - await new Promise((resolve) => setTimeout(resolve, 700)) + const earlierText = await readFile(getConfigFilePath(), 'utf8') + // Let that write settle, so only the second one being in flight is what + // makes the read below stale. + await delay(1800) + + // Hold the second write mid-flight, and let the watcher read the file + // meanwhile: it still holds the earlier own-write. + let release = (): void => {} + writeGate.hold = new Promise((resolve) => (release = resolve)) + const second = setPortableConfig({ editorFontSize: 22 }) + await writeFile(getConfigFilePath(), earlierText) + await delay(800) + expect(getPortableConfigSnapshot().editorFontSize).toBe(22) - // It's a known own-write, so the cache keeps the latest merged value. + writeGate.hold = null + release() + await second + // Past the settle window the file holds the newer write, so the re-check + // finds nothing to apply either. + await delay(2500) expect(getPortableConfigSnapshot().editorFontSize).toBe(22) - }) + expect(changes).toHaveLength(0) + expect(await readFile(getConfigFilePath(), 'utf8')).toContain('font_size = 22') + }, 10000) it('ensureConfigFile creates the file when missing', async () => { process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg2-') @@ -299,6 +333,47 @@ describe('persistence + cache', () => { }) describe('file watching', () => { + it('applies a hand edit that puts back an earlier version of the file', async () => { + // The user changes a setting in the app, then sets it back in config.toml: + // the file is now byte for byte a text the app wrote before. It is still + // the user's edit, and the file is the source of truth. + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-putback-') + const changes: AppConfigPortable[] = [] + await initAppConfig((next) => changes.push(next)) + + await setPortableConfig({ editorFontSize: 18 }) + const earlierText = await readFile(getConfigFilePath(), 'utf8') + await setPortableConfig({ editorFontSize: 22 }) + await delay(2000) + + await writeFile(getConfigFilePath(), earlierText) + await waitFor(() => changes.length > 0, 4000) + expect(changes.at(-1)?.editorFontSize).toBe(18) + expect(getPortableConfigSnapshot().editorFontSize).toBe(18) + }, 10000) + + it('applies a put-back that lands while the app is still writing, once its writes settle', async () => { + // Right after the app's own write, the watcher cannot tell a stale read of + // that earlier text from the user putting it back. It waits for the writes + // to settle and looks again; a text still in the file then is the user's. + process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-putback-early-') + const changes: AppConfigPortable[] = [] + await initAppConfig((next) => changes.push(next)) + + await setPortableConfig({ editorFontSize: 18 }) + const earlierText = await readFile(getConfigFilePath(), 'utf8') + await setPortableConfig({ editorFontSize: 22 }) + await writeFile(getConfigFilePath(), earlierText) + + // First read: possibly stale, so the cache holds. + await delay(700) + expect(getPortableConfigSnapshot().editorFontSize).toBe(22) + // The re-check after the writes settle finds it still there. + await waitFor(() => changes.length > 0, 6000) + expect(changes.at(-1)?.editorFontSize).toBe(18) + expect(getPortableConfigSnapshot().editorFontSize).toBe(18) + }, 10000) + it('notifies on external edits but not on its own writes', async () => { process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg4-') const changes: AppConfigPortable[] = [] diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index aefa6dd0..82647f12 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -401,6 +401,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'kanban_group_by', comment: 'status | priority | folder (each note\'s own folder; see kanban_folder_root) | field:' }, + kanbanCardSort: { + section: 'view', + tomlKey: 'kanban_card_sort', + comment: 'manual | due: cards in a Kanban column keep the order you drag them into, or follow their due dates' + }, kanbanFolderRoot: { section: 'view', tomlKey: 'kanban_folder_root', @@ -746,13 +751,11 @@ let watcher: FSWatcher | null = null let writeQueue: Promise = Promise.resolve() // Texts we've written ourselves. The watcher's loop-guard compares the file -// against `lastKnownText` (our latest write), but rapid writes — two -// setPortableConfig calls in a row — can make the watcher observe an EARLIER -// own-write out of order, especially on Windows where atomic-rename events -// don't coalesce as cleanly. Remembering the last several own-writes lets the -// watcher recognize a stale read of any of them and skip it, instead of -// clobbering the freshly-merged in-memory cache (which reverted settings to -// their defaults). Bounded so it can't grow unbounded. +// against `lastKnownText` (our latest write), but with two setPortableConfig +// calls in a row the watcher can read the file around the second write and see +// the FIRST (a Windows CI flake). Treating that stale read as an edit clobbered +// the freshly-merged in-memory cache and reverted settings to their defaults. +// Bounded so it can't grow unbounded. const ownWrites = new Set() const MAX_OWN_WRITES = 16 function rememberOwnWrite(text: string): void { @@ -764,6 +767,36 @@ function rememberOwnWrite(text: string): void { } } +// A stale read can only happen while our writes are landing, so an earlier +// own-write counts as one only then: during a write, or briefly after the last +// one, for a read that began before it finished. Outside that window the same +// text is someone putting it back (a hand edit, a dotfile sync), and the file +// wins. Remembering the texts forever swallowed exactly that edit. +const OWN_WRITE_SETTLE_MS = 1500 +let ownWritesInFlight = 0 +let ownWritesSettledAt = 0 +let recheckTimer: ReturnType | null = null + +function ownWritesSettling(): boolean { + return ownWritesInFlight > 0 || Date.now() - ownWritesSettledAt < OWN_WRITE_SETTLE_MS +} + +/** writeFileAtomic for a text this module wrote, counted while it lands. */ +async function writeOwnText(text: string): Promise { + ownWritesInFlight += 1 + try { + await writeFileAtomic(getConfigFilePath(), text) + } finally { + ownWritesInFlight -= 1 + ownWritesSettledAt = Date.now() + } +} + +function clearRecheck(): void { + if (recheckTimer) clearTimeout(recheckTimer) + recheckTimer = null +} + const WATCH_DEBOUNCE_MS = 150 async function readConfigFile(): Promise<{ text: string; portable: AppConfigPortable } | null> { @@ -802,19 +835,34 @@ function debounce( function startWatching(): void { const file = getConfigFilePath() watcher?.close().catch(() => {}) - watcher = chokidar.watch(file, { + clearRecheck() + const active = chokidar.watch(file, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 } }) + watcher = active const handle = debounce(() => { void (async () => { const res = await readConfigFile() - if (!res) return - // Our own writes (and no-op saves) match lastKnownText — skip them so we - // never feed our writes back into the renderer. Also skip a stale read of - // any recent own-write the watcher observed out of order, so it can't - // clobber the in-memory cache with an older serialized state. - if (res.text === lastKnownText || ownWrites.has(res.text)) return + // A read that outlived its watcher answers for a file nobody watches. + if (!res || watcher !== active) return + // Our latest write, or a save that changed nothing: never fed back. + if (res.text === lastKnownText) return + // An earlier own-write while ours are still landing may be a stale read + // (see OWN_WRITE_SETTLE_MS): look again once they settle. If the text is + // still there then, nothing of ours explains it, and it applies below. + if (ownWrites.has(res.text) && ownWritesSettling()) { + clearRecheck() + const wait = + ownWritesInFlight > 0 + ? OWN_WRITE_SETTLE_MS + : ownWritesSettledAt + OWN_WRITE_SETTLE_MS - Date.now() + recheckTimer = setTimeout(() => { + recheckTimer = null + handle() + }, Math.max(0, wait)) + return + } cache = res.portable lastKnownText = res.text onChangeCb?.({ ...cache }) @@ -846,7 +894,7 @@ export async function initAppConfig( lastKnownText = canonical rememberOwnWrite(canonical) try { - await writeFileAtomic(getConfigFilePath(), canonical) + await writeOwnText(canonical) } catch (err) { console.error('Failed to normalize config.toml', err) lastKnownText = res.text @@ -879,7 +927,7 @@ export function setPortableConfig(partial: AppConfigPortable): Promise { lastKnownText = text rememberOwnWrite(text) try { - await writeFileAtomic(getConfigFilePath(), text) + await writeOwnText(text) } catch (err) { console.error('Failed to write config.toml', err) } @@ -901,6 +949,7 @@ export async function ensureConfigFile(): Promise { /** Test/teardown hook: stop the file watcher. */ export async function stopAppConfigWatcher(): Promise { + clearRecheck() await watcher?.close().catch(() => {}) watcher = null } diff --git a/apps/desktop/src/main/cloud-sync-filesystem.test.ts b/apps/desktop/src/main/cloud-sync-filesystem.test.ts index 05e0fe47..d3af0116 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.test.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { chmod, lstat, @@ -20,7 +20,13 @@ import { cloudSyncUploadSource } from './cloud-sync-upload-source' import type { CloudSyncChange } from '@zennotes/bridge-contract/cloud-sync' -import type { CloudSyncTrackedItem } from '@zennotes/shared-domain/cloud-sync-engine' +import { releaseCloudSyncStagedFile } from '@zennotes/shared-domain/cloud-sync-content' +import { + CloudSyncCoordinator, + type CloudSyncRemote, + type CloudSyncRepositoryConflict +} from '@zennotes/shared-domain/cloud-sync-coordinator' +import type { CloudSyncState, CloudSyncTrackedItem } from '@zennotes/shared-domain/cloud-sync-engine' const roots: string[] = [] @@ -38,13 +44,13 @@ function hash(contents: string): string { return createHash('sha256').update(contents).digest('hex') } -function upsert(path: string, contents: string): CloudSyncChange { +function upsert(path: string, contents: string, previousPath: string | null = null): CloudSyncChange { return { sequence: 2, item_id: 'item-remote', type: 'upsert', path, - previous_path: null, + previous_path: previousPath, revision: 2, content: { encoding: 'utf8', @@ -68,6 +74,45 @@ function tracked(path: string, contents: string): CloudSyncTrackedItem { } } +/** Lands an upsert the way a large Cloud body arrives: staged on disk first. */ +async function applyStaged( + root: string, + change: CloudSyncChange, + previous: CloudSyncTrackedItem | undefined +): Promise { + const { content, ...metadata } = change + const { data, ...contentMetadata } = content! + const reference = { ...contentMetadata, item_id: change.item_id, revision: change.revision } + const repository = new DesktopCloudSyncRepository(root, { + stagingDirectory: await temporaryRoot(), + fetchImplementation: async () => new Response(data) + }) + const file = await repository.stageCloudContent({ + reference, + previewLimitBytes: 0, + getInstruction: async () => ({ + item_id: reference.item_id, + revision: reference.revision, + content: contentMetadata, + download: { + method: 'GET', + url: 'https://objects.example.test/immutable', + headers: {}, + expires_at: '2099-10-01T12:00:00Z' + } + }) + }) + try { + return await repository.applyStagedCloudContent( + { ...metadata, content_ref: reference }, + previous, + file + ) + } finally { + await releaseCloudSyncStagedFile(file) + } +} + describe('DesktopCloudSyncRepository', () => { it('scans text and binary vault files while excluding local sync state', async () => { const root = await temporaryRoot() @@ -582,3 +627,169 @@ describe('DesktopCloudSyncRepository: decisions and writes', () => { expect((await stat(path.join(root, 'private.md'))).mode & 0o777).toBe(0o600) }) }) + +// A Cloud restore brings a note back out of Trash as an upsert whose +// previous_path is where the note was, not as a move. Writing only the new +// path left the old file behind; the next scan found it untracked and the +// push uploaded it as a brand-new note to every device. Small bodies land +// through `apply`, large ones are staged on disk and land through +// `applyStagedCloudContent`, and both have to retire the old path. +describe.each([ + { + mode: 'inline', + land: (root: string, change: CloudSyncChange, previous: CloudSyncTrackedItem | undefined) => + new DesktopCloudSyncRepository(root).apply(change, previous) + }, + { mode: 'staged', land: applyStaged } +])('DesktopCloudSyncRepository: an upsert that changes path ($mode)', ({ land }) => { + it.each(['agreed', 'restored'])('removes the old file once the new path holds %j', async (contents) => { + const root = await temporaryRoot() + await mkdir(path.join(root, 'trash'), { recursive: true }) + await writeFile(path.join(root, 'trash', 'Note.md'), 'agreed') + + const conflict = await land( + root, + upsert('Note.md', contents, 'trash/Note.md'), + tracked('trash/Note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect(await readFile(path.join(root, 'Note.md'), 'utf8')).toBe(contents) + expect(await readdir(path.join(root, 'trash'))).toEqual([]) + }) + + // How a replay finds the vault after a crash between writing the new path + // and removing the old one. + it('removes the old file when the new path already holds the incoming bytes', async () => { + const root = await temporaryRoot() + await mkdir(path.join(root, 'trash'), { recursive: true }) + await writeFile(path.join(root, 'trash', 'Note.md'), 'agreed') + await writeFile(path.join(root, 'Note.md'), 'restored') + + const conflict = await land( + root, + upsert('Note.md', 'restored', 'trash/Note.md'), + tracked('trash/Note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect(await readFile(path.join(root, 'Note.md'), 'utf8')).toBe('restored') + expect(await readdir(path.join(root, 'trash'))).toEqual([]) + }) + + it.each([ + { target: 'empty', atTarget: null }, + { target: 'already holding the incoming bytes', atTarget: 'restored' } + ])('keeps an old file edited on this device, with the new path $target', async ({ atTarget }) => { + const root = await temporaryRoot() + await mkdir(path.join(root, 'trash'), { recursive: true }) + await writeFile(path.join(root, 'trash', 'Note.md'), 'local edit') + if (atTarget !== null) await writeFile(path.join(root, 'Note.md'), atTarget) + + const conflict = await land( + root, + upsert('Note.md', 'restored', 'trash/Note.md'), + tracked('trash/Note.md', 'agreed') + ) + + expect(conflict).toMatchObject({ + code: 'LOCAL_EDIT_CONFLICT', + path: 'trash/Note.md', + conflict_copy_path: null, + local: { path: 'trash/Note.md', content: { data: 'local edit' } } + }) + expect(await readFile(path.join(root, 'trash', 'Note.md'), 'utf8')).toBe('local edit') + expect(await readFile(path.join(root, 'Note.md'), 'utf8').catch(() => null)).toBe(atTarget) + }) + + it('writes an upsert that keeps its path in place, as before', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'agreed') + await writeFile(path.join(root, 'other.md'), 'leave me alone') + + const conflict = await land(root, upsert('note.md', 'remote edit'), tracked('note.md', 'agreed')) + + expect(conflict).toBeUndefined() + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('remote edit') + expect(await readFile(path.join(root, 'other.md'), 'utf8')).toBe('leave me alone') + expect((await readdir(root)).sort()).toEqual(['note.md', 'other.md']) + }) + + it('never removes an untracked file that previous_path names', async () => { + const root = await temporaryRoot() + await mkdir(path.join(root, 'trash'), { recursive: true }) + await writeFile(path.join(root, 'trash', 'Note.md'), 'not tracked here') + + const conflict = await land(root, upsert('Note.md', 'restored', 'trash/Note.md'), undefined) + + expect(conflict).toBeUndefined() + expect(await readFile(path.join(root, 'Note.md'), 'utf8')).toBe('restored') + expect(await readFile(path.join(root, 'trash', 'Note.md'), 'utf8')).toBe('not tracked here') + }) + + // On a case-insensitive volume (the macOS and Windows default) both + // spellings open one file, so removing the "old" one would remove the note + // that was just written. A case-sensitive volume has two files to tidy. + it.each(['agreed', 'remote edit'])( + 'keeps exactly one file when an upsert only changes the case of its path (%j)', + async (contents) => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'agreed') + + const conflict = await land( + root, + upsert('Note.md', contents, 'note.md'), + tracked('note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect(await readdir(root)).toHaveLength(1) + expect(await readFile(path.join(root, 'Note.md'), 'utf8')).toBe(contents) + } + ) +}) + +describe('DesktopCloudSyncRepository: a Cloud restore from Trash', () => { + it('lands the restored note once and uploads nothing back', async () => { + const root = await temporaryRoot() + await mkdir(path.join(root, 'trash'), { recursive: true }) + await writeFile(path.join(root, 'trash', 'Note.md'), 'agreed') + const restore = upsert('Note.md', 'agreed', 'trash/Note.md') + let state: CloudSyncState | null = { + version: 1, + vault_id: 'vault', + cursor: restore.sequence - 1, + items: { + [restore.item_id]: { ...tracked('trash/Note.md', 'agreed'), item_id: restore.item_id } + } + } + const remote: CloudSyncRemote = { + manifest: vi.fn(), + changes: vi.fn(async (_vault: string, after: number) => ({ + data: after < restore.sequence ? [restore] : [], + cursor: restore.sequence, + has_more: false + })), + mutate: vi.fn(async () => ({ acknowledged: [], cursor: restore.sequence, conflicts: [] })) + } + const coordinator = new CloudSyncCoordinator( + 'vault', + remote, + new DesktopCloudSyncRepository(root), + { + load: async () => state, + save: async (next) => { + state = next + } + }, + { itemId: () => 'item-duplicate', operationId: () => 'operation' } + ) + + const result = await coordinator.sync() + + expect(result).toMatchObject({ pulled: 1, pushed: 0 }) + expect(remote.mutate).not.toHaveBeenCalled() + expect(await readFile(path.join(root, 'Note.md'), 'utf8')).toBe('agreed') + expect(await readdir(path.join(root, 'trash'))).toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/cloud-sync-filesystem.ts b/apps/desktop/src/main/cloud-sync-filesystem.ts index 79186b32..c7d70063 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto' -import { constants as fsConstants, createReadStream, promises as fs, type Stats } from 'node:fs' +import { constants as fsConstants, createReadStream, promises as fs, type BigIntStats, type Stats } from 'node:fs' import path from 'node:path' import { atomicWriteTarget, renameWithRetry } from './atomic-write' import { stageDesktopCloudContent, copyVerifiedCloudFile, type DesktopCloudDownloadOptions, type DesktopCloudStagedHandle } from './cloud-sync-download' @@ -107,7 +107,9 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { change.revision < file.reference.revision) throw new Error('Invalid staged Cloud change.') cloudSyncStagedHandle(file, change.content_ref) const atTarget = await this.localItemOrNull(change.path) - if (atTarget?.content.sha256 === file.reference.sha256 && atTarget.content.byte_length === file.reference.byte_length) return + if (atTarget?.content.sha256 === file.reference.sha256 && atTarget.content.byte_length === file.reference.byte_length) { + return await this.retirePreviousPath(change, previous) + } for (const candidate of new Set([previous?.path ?? change.path, change.path])) { const local = candidate === change.path ? atTarget : await this.localItemOrNull(candidate) if (local && (!previous || local.content.sha256 !== previous.sha256)) { @@ -120,6 +122,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } } await this.publishFile(change.path, handle.path, file.reference, atTarget?.content.sha256 ?? null, handle.signal) + return await this.retirePreviousPath(change, previous) } async resolveStagedCloudConflict(input: CloudSyncStagedConflict): Promise { @@ -242,9 +245,12 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { if (!change.content) throw new Error(`Upsert change ${change.sequence} did not include content`) const atTarget = await this.readIfExists(change.path) // Already byte-for-byte what the change carries. There is nothing to - // write and nothing to conflict over, so adopt the file and move on. - // Without this, a file both sides already agree on stopped sync dead. - if (atTarget && sha256(atTarget) === change.content.sha256) return + // write and nothing to conflict over at the target, so adopt the file + // and move on. Without this, a file both sides already agree on stopped + // sync dead. + if (atTarget && sha256(atTarget) === change.content.sha256) { + return await this.retirePreviousPath(change, previous) + } const guardPath = previous?.path ?? change.path const unvouched = await this.firstUnvouchedPath( @@ -259,7 +265,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } await this.write(change.path, await decodeContent(change.content)) - return + return await this.retirePreviousPath(change, previous) } const previousPath = previous?.path ?? change.previous_path ?? change.path @@ -485,6 +491,40 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { return null } + /** + * Remove the file an upsert carried this item away from, once the new path + * holds the Cloud bytes. A Cloud restore brings a note back from Trash as + * an upsert whose `previous_path` is the old location, not as a move, and a + * file left there is untracked by the next scan, which uploads it as a new + * note to every device. Only the file this device tracked and still vouches + * for goes; an edit made here is reported instead, never removed. + */ + private async retirePreviousPath( + change: CloudSyncChange, + previous: CloudSyncTrackedItem | undefined + ): Promise { + if (!previous || previous.path === change.path) return + if (await this.sameFile(previous.path, change.path)) return + const unvouched = await this.firstUnvouchedPath([previous.path], previous) + if (unvouched) return localConflict(unvouched, await this.localItemOrNull(unvouched)) + await fs.rm(this.resolve(previous.path), { force: true }) + } + + /** + * Whether two vault paths reach one file. A case-insensitive volume (the + * macOS and Windows default) opens `Note.md` and `note.md` as the same + * note, so removing the "old" spelling after a case-only rename would + * remove the note just written. Identities are compared as bigints because + * Windows file ids overflow a double; a volume that reports no ids at all + * answers "same", which keeps the old file, the safe way to be wrong. + */ + private async sameFile(left: string, right: string): Promise { + const [a, b] = await Promise.all( + [left, right].map((relPath) => statIfExists(this.resolve(relPath))) + ) + return a !== null && b !== null && a.dev === b.dev && a.ino === b.ino + } + /** Park the incoming version beside the local file rather than over it. */ private async keepBoth(relPath: string, bytes: Buffer): Promise { // Settings are answered, not merged: the newest cloud version replaces any @@ -760,6 +800,15 @@ async function exists(absolutePath: string): Promise { } } +async function statIfExists(absolutePath: string): Promise { + try { + return await fs.stat(absolutePath, { bigint: true }) + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } +} + function isCloudSyncState(value: unknown, vaultId: string): value is CloudSyncState { if (!value || typeof value !== 'object') return false const state = value as Partial diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 9c53fb62..bae27e49 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -4,13 +4,15 @@ import os from 'node:os' import path from 'node:path' import type { CloudAccountStatus, + CloudBackupSchedule, + CloudBackupScheduleResponse, CloudBackupSnapshotItemCollection, CloudSyncManifestResponse, CloudSyncMutationRequest, CloudSyncVault } from '@zennotes/bridge-contract/cloud-sync' import { afterEach, describe, expect, it, vi } from 'vitest' -import { CloudServiceRequestError } from './cloud-sync-client' +import { CloudServiceRequestError, createCloudSyncClient } from './cloud-sync-client' import { DesktopCloudSyncService, type DesktopCloudSyncServiceDependencies } from './cloud-sync-service' const temporaryDirectories: string[] = [] @@ -110,7 +112,7 @@ async function setup( cursor: body.mutations.length })), listBackups: vi.fn(async () => ({ data: [] })), - backupSchedule: vi.fn(async () => ({ + backupSchedule: vi.fn(async (): Promise => ({ data: { enabled: false, frequency: 'daily' as const, @@ -604,6 +606,33 @@ describe('DesktopCloudSyncService', () => { await syncing }) + it('hands the window why the last automatic backup did not happen, as the service sent it', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 0, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { service, client, localRoot } = await setup([remoteVault]) + await service.link(localRoot, remoteVault.id) + const schedule: CloudBackupSchedule = { + enabled: true, + frequency: 'daily', + next_backup_at: '2026-10-05T03:00:00.000Z', + last_backup_at: '2026-10-02T03:00:00.000Z', + last_failure: { + code: 'AUTOMATIC_BACKUP_FAILED', + message: 'The latest automatic backup failed after several attempts.', + details: { message: 'The automatic backup could not be created after several attempts.' }, + occurred_at: '2026-10-04T03:00:00.000Z' + } + } + client.backupSchedule.mockResolvedValueOnce({ data: schedule }) + + await expect(service.backupSchedule(localRoot)).resolves.toEqual(schedule) + }) + it('manages backups only through the linked cloud vault', async () => { const remoteVault: CloudSyncVault = { id: 'vault-1', @@ -729,6 +758,45 @@ describe('DesktopCloudSyncService', () => { await expect(service.createBackup(localRoot)).rejects.toThrow('Resolve sync conflicts') expect(client.createBackup).not.toHaveBeenCalled() }) + + it('says which plan limit a refused backup ran into before the error crosses IPC', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 0, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { service, client, localRoot } = await setup([remoteVault]) + await service.link(localRoot, remoteVault.id) + const cloud = createCloudSyncClient( + 'https://zennotes.org', + 'secret-token', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 'BACKUP_QUOTA_EXCEEDED', + message: 'This backup would exceed your plan limits.', + details: { limit: 'max_snapshot_items', current: 12_345, allowed: 10_000 } + } + }), + { status: 409, headers: { 'Content-Type': 'application/json' } } + ) + ) + ) + const refusal = await cloud.createBackup('vault-1').catch((error: unknown) => error) + client.createBackup.mockRejectedValueOnce(refusal) + + const thrown = await service.createBackup(localRoot, 'Release day').catch((error: unknown) => error) + + // ipcMain.handle sends a rejection to the window as String(error), so the + // details must already be in the sentence. + expect(String(thrown)).toBe( + 'Error: This vault has 12,345 files, and backups on your plan hold up to 10,000. Remove files you no longer need, or contact support to raise the limit.' + ) + expect((thrown as Error).cause).toBeInstanceOf(CloudServiceRequestError) + }) }) diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index d5379214..f58d3af5 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -25,7 +25,11 @@ import type { CloudSyncVault, CloudVaultLink } from '@zennotes/bridge-contract/cloud-sync' -import { cloudBackupItemsPage, restoreCloudBackup } from '@zennotes/shared-domain/cloud-backup' +import { + cloudBackupCreateError, + cloudBackupItemsPage, + restoreCloudBackup +} from '@zennotes/shared-domain/cloud-backup' import { CLOUD_SYNC_SETTINGS_CONFLICT_PATH, CLOUD_SYNC_VAULT_SETTINGS_PATH @@ -220,7 +224,11 @@ export class DesktopCloudSyncService { const summary = await this.sync(localRoot) assertBackupReady(summary) const { client, link } = await this.linkedConnection(localRoot) - return (await client.createBackup(link.vault_id, label)).data + try { + return (await client.createBackup(link.vault_id, label)).data + } catch (error) { + throw cloudBackupCreateError(error) + } } async deleteBackup(localRoot: string, backupId: string): Promise { diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index ff40e481..33a62bdd 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -1037,6 +1037,30 @@ describe('listNotes metadata parsing', () => { }) }) +describe('listAssets symlinks', () => { + it('lists a file reached through a symlink, so an embed of it is not called missing', async () => { + const root = await makeTempDir('zennotes-asset-symlink-') + await ensureVaultLayout(root) + await mkdir(path.join(root, 'assets'), { recursive: true }) + const srcDir = await makeTempDir('zennotes-asset-symlink-src-') + const external = path.join(srcDir, 'clip.mp4') + await writeFile(external, Buffer.from([1, 2, 3])) + try { + await symlink(external, path.join(root, 'assets', 'clip.mp4')) + await symlink(path.join(srcDir, 'gone.mp4'), path.join(root, 'assets', 'gone.mp4')) + } catch { + // Creating symlinks can require privileges (e.g. Windows); skip there. + return + } + + const assets = await listAssets(root) + + expect(assets.map((asset) => asset.path)).toContain('assets/clip.mp4') + expect(assets.find((asset) => asset.path === 'assets/clip.mp4')?.size).toBe(3) + expect(assets.map((asset) => asset.path)).not.toContain('assets/gone.mp4') + }) +}) + describe('listNotes symlinks', () => { it('lists a note reached through a symlink into the vault', async () => { const root = await makeTempDir('zennotes-symlink-') diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index bef1c2c9..25e192f8 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -4313,7 +4313,7 @@ export async function listAssets(root: string): Promise { ancestors.delete(childReal) continue } - if (!entry.isFile()) continue + if (!entry.isFile() && !entry.isSymbolicLink()) continue if (isAtomicWriteTempPath(entry.name)) continue if (entry.name.toLowerCase().endsWith('.md')) continue // Excalidraw drawings are a first-class file type (listed with notes), not @@ -4327,6 +4327,10 @@ export async function listAssets(root: string): Promise { } catch { continue } + // A link to a file is an attachment like any other: zen-asset serves it, + // and an embed of a file missing from this list says the file is not on + // this device. A broken link, or one to anything but a file, is not one. + if (!stat.isFile()) continue const rel = toPosix(path.relative(root, full)) out.push({ path: rel, diff --git a/apps/quicklook/README.md b/apps/quicklook/README.md new file mode 100644 index 00000000..fe54c663 --- /dev/null +++ b/apps/quicklook/README.md @@ -0,0 +1,44 @@ +# Quick Look preview (macOS) + +Space on a Markdown file in Finder shows it rendered the ZenNotes way, with an +Open in ZenNotes button. Three pieces ship inside `ZenNotes.app/Contents/PlugIns`: + +- `src/`: the page. It renders with app-core's own Preview, in the user's theme + and fonts from `~/.config/zennotes/config.toml`, read-only. Typst math and the + plot renderers stay out (KaTeX typesets math), as do the Excalidraw fonts. +- `macos/Extension/`: `ZenNotesQuickLook.appex`, the sandboxed Quick Look + extension. It serves the page and the files the note embeds over its own + `zenql://` scheme, behind a Content-Security-Policy that allows nothing else, + so a preview never goes online. WebKit's helper processes still need the + network-client entitlement to start at all. +- `macos/Opener/`: `ZenNotesQuickLookOpener.xpc`, inside the extension. Quick + Look's sandbox lets a preview launch nothing, so this unsandboxed service opens + the note in the ZenNotes that contains it (the same route as a Finder + double-click) and outside links in the browser. It re-checks every request. + +## Building + +`scripts/build-extension.mjs` builds the page once, compiles both Swift targets +for the architecture, and signs them. 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: electron-builder never signs anything under +`Contents/PlugIns`. + +## Testing locally + +1. `npm run test:run --workspace @zennotes/quicklook` for the page logic. +2. Pack with the self-test compiled in: + `cd apps/desktop && ZENNOTES_QUICKLOOK_SELFTEST=1 npx electron-builder --dir`. + A note containing `quicklook-selftest-open` then presses the button by + itself, and one containing `quicklook-selftest-keys` sends `j j j`, `G` and + `gg`; results go to the log (`log show --predicate 'subsystem BEGINSWITH + "com.adibhanna.zennotes"'`). Release and CI builds never compile it in. +3. Register the packed app's extension and preview a note: + `pluginkit -a /Contents/PlugIns/ZenNotesQuickLook.appex`, then + `qlmanage -p note.md`. Registering a copy of the app also registers its URL + scheme and document types, and the button launches it with your real + profile: give a test copy its own bundle identifiers (app, extension and + opener), drop its `CFBundleURLTypes` and `CFBundleDocumentTypes`, add + `ZEN_PERF=1` and scratch `ZENNOTES_USER_DATA_PATH` / `ZENNOTES_CONFIG_DIR` to + its `LSEnvironment`, re-sign it, and unregister it afterwards + (`pluginkit -r`, `lsregister -u`). diff --git a/apps/quicklook/macos/Extension/Extension.entitlements b/apps/quicklook/macos/Extension/Extension.entitlements new file mode 100644 index 00000000..1e0c5cff --- /dev/null +++ b/apps/quicklook/macos/Extension/Extension.entitlements @@ -0,0 +1,20 @@ + + + + + + com.apple.security.app-sandbox + + + com.apple.security.network.client + + + com.apple.security.temporary-exception.files.absolute-path.read-only + + / + + + diff --git a/apps/quicklook/macos/Extension/Info.plist b/apps/quicklook/macos/Extension/Info.plist new file mode 100644 index 00000000..ce872071 --- /dev/null +++ b/apps/quicklook/macos/Extension/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ZenNotes + CFBundleExecutable + ZenNotesQuickLook + CFBundleIdentifier + __APP_ID__.QuickLook + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ZenNotesQuickLook + CFBundlePackageType + XPC! + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 12.0 + NSExtension + + NSExtensionAttributes + + QLSupportedContentTypes + + net.daringfireball.markdown + + QLSupportsSearchableItems + + + NSExtensionPointIdentifier + com.apple.quicklook.preview + NSExtensionPrincipalClass + PreviewViewController + + + diff --git a/apps/quicklook/macos/Extension/NotePreview.swift b/apps/quicklook/macos/Extension/NotePreview.swift new file mode 100644 index 00000000..8e7483fc --- /dev/null +++ b/apps/quicklook/macos/Extension/NotePreview.swift @@ -0,0 +1,319 @@ +import Foundation +import UniformTypeIdentifiers + +/// One previewed note: its text, the page that renders it, and the files it +/// embeds. The page reaches nothing but this extension's own zenql:// scheme, +/// and of the disk only the files listed here. +struct NotePreview { + static let scheme = "zenql" + static let pageURL = URL(string: "zenql://app/index.html")! + + /// Larger notes show their beginning; the rest is a click away in the app. + private static let markdownLimit = 4 * 1024 * 1024 + /// Embedded files above this size are left out of the preview. + private static let assetLimit: Int64 = 512 * 1024 * 1024 + private static let mediaExtensions: Set = [ + "png", "jpg", "jpeg", "gif", "webp", "avif", "apng", "svg", "bmp", "tiff", "heic", + "pdf", "mp3", "m4a", "aac", "flac", "ogg", "wav", "mp4", "m4v", "mov", "ogv", "webm" + ] + + let fileURL: URL + let markdown: String + private let viewer: URL + private let page: Data + private let assets: [URL] + + init(fileURL: URL, viewer: URL) throws { + self.fileURL = fileURL + self.viewer = viewer.standardizedFileURL + + let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) + var text = String(data: data.prefix(Self.markdownLimit), encoding: .utf8) + ?? String(decoding: data.prefix(Self.markdownLimit), as: UTF8.self) + if data.count > Self.markdownLimit { + text += "\n\n---\n\n*Quick Look shows the beginning of this note. Open it in ZenNotes to read the rest.*\n" + } + markdown = text + + let noteDirectory = fileURL.deletingLastPathComponent().standardizedFileURL + let vaultRoot = Self.vaultRoot(above: noteDirectory) + var resolver = AssetResolver(noteDirectory: noteDirectory, vaultRoot: vaultRoot) + var files: [URL] = [] + var urls: [String: String] = [:] + for reference in Self.references(in: text) { + guard let file = resolver.resolve(reference) else { continue } + let index: Int + if let known = files.firstIndex(of: file) { + index = known + } else { + files.append(file) + index = files.count - 1 + } + let name = file.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "file" + let url = "zenql://app/asset/\(index)/\(name)" + urls[reference.written] = url + urls[reference.decoded] = url + } + assets = files + + let notePath: String + if let vaultRoot, fileURL.standardizedFileURL.path.hasPrefix(vaultRoot.path + "/") { + notePath = String(fileURL.standardizedFileURL.path.dropFirst(vaultRoot.path.count + 1)) + } else { + notePath = fileURL.lastPathComponent + } + let payload: [String: Any] = [ + "title": fileURL.deletingPathExtension().lastPathComponent, + "markdown": text, + "notePath": notePath, + "assets": urls, + "config": Self.configText() + ] + page = try Self.page(payload: payload) + } + + static func isOutsideLink(_ link: String) -> Bool { + guard let scheme = URL(string: link.trimmingCharacters(in: .whitespaces))?.scheme?.lowercased() else { return false } + return ["http", "https", "mailto"].contains(scheme) + } + + // MARK: - Serving + + func response(for url: URL, range: String?) throws -> (head: URLResponse, body: Data) { + let path = url.path + if path == "/index.html" { return Self.respond(url, page, type: "text/html") } + if path.hasPrefix("/asset/") { + let parts = path.split(separator: "/") + guard parts.count >= 2, let index = Int(parts[1]), assets.indices.contains(index) else { + throw URLError(.fileDoesNotExist) + } + return try Self.serve(assets[index], for: url, range: range) + } + let file = viewer.appendingPathComponent(String(path.dropFirst())).standardizedFileURL + guard file.path.hasPrefix(viewer.path + "/") else { throw URLError(.noPermissionsToReadFile) } + return try Self.serve(file, for: url, range: nil) + } + + private static func serve(_ file: URL, for url: URL, range: String?) throws -> (head: URLResponse, body: Data) { + let type = mimeType(file.pathExtension) + guard let range, let bounds = byteRange(range, file: file) else { + return respond(url, try Data(contentsOf: file, options: .mappedIfSafe), type: type) + } + // Audio and video seek with byte ranges. + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + try handle.seek(toOffset: UInt64(bounds.start)) + let body = try handle.read(upToCount: bounds.end - bounds.start + 1) ?? Data() + let head = HTTPURLResponse(url: url, statusCode: 206, httpVersion: "HTTP/1.1", headerFields: [ + "Content-Type": type, + "Content-Length": String(body.count), + "Content-Range": "bytes \(bounds.start)-\(bounds.start + body.count - 1)/\(bounds.size)", + "Accept-Ranges": "bytes" + ])! + return (head, body) + } + + /// Uniform Type Identifiers knows no MIME type for some web formats. + private static func mimeType(_ fileExtension: String) -> String { + switch fileExtension.lowercased() { + case "js", "mjs": return "text/javascript" + case "woff2": return "font/woff2" + case "woff": return "font/woff" + case "otf": return "font/otf" + case "wasm": return "application/wasm" + default: return UTType(filenameExtension: fileExtension)?.preferredMIMEType ?? "application/octet-stream" + } + } + + private static func respond(_ url: URL, _ body: Data, type: String) -> (head: URLResponse, body: Data) { + let head = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: [ + "Content-Type": type, + "Content-Length": String(body.count), + "Accept-Ranges": "bytes" + ])! + return (head, body) + } + + private static func byteRange(_ header: String, file: URL) -> (start: Int, end: Int, size: Int)? { + guard header.hasPrefix("bytes="), + let size = (try? file.resourceValues(forKeys: [.fileSizeKey]))?.fileSize, size > 0 else { return nil } + let spec = header.dropFirst("bytes=".count).split(separator: ",").first ?? "" + let sides = spec.split(separator: "-", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) } + guard sides.count == 2 else { return nil } + if sides[0].isEmpty, let suffix = Int(sides[1]), suffix > 0 { + return (max(0, size - suffix), size - 1, size) + } + guard let start = Int(sides[0]), start < size else { return nil } + let end = min(Int(sides[1]) ?? size - 1, size - 1) + return end >= start ? (start, end, size) : nil + } + + // MARK: - The page + + private static func page(payload: [String: Any]) throws -> Data { + let json = try JSONSerialization.data(withJSONObject: payload, options: []) + // `<` never appears raw inside the script element, so no text in a note + // can close it. + let embedded = String(decoding: json, as: UTF8.self).replacingOccurrences(of: "<", with: "\\u003c") + let policy = [ + "default-src 'self' zenql: data: blob:", + "script-src 'self' zenql: 'unsafe-eval'", + "style-src 'self' zenql: 'unsafe-inline'", + "img-src 'self' zenql: data: blob:", + "media-src 'self' zenql: data: blob:", + "font-src 'self' zenql: data:", + "worker-src 'self' zenql: blob:", + "connect-src 'self' zenql:", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'" + ].joined(separator: "; ") + let html = """ + + + + + + + + + + +
+ + + + """ + return Data(html.utf8) + } + + /// The user's ZenNotes settings, read for the theme and fonts. A sandboxed + /// extension's home is its container, so the real one comes from the + /// user database. + private static func configText() -> String { + guard let entry = getpwuid(getuid()), let directory = entry.pointee.pw_dir else { return "" } + let file = URL(fileURLWithPath: String(cString: directory)).appendingPathComponent(".config/zennotes/config.toml") + guard let data = try? Data(contentsOf: file), data.count < 1024 * 1024 else { return "" } + return String(decoding: data, as: UTF8.self) + } + + // MARK: - Embedded files + + struct Reference { + let written: String + let decoded: String + let wikilink: Bool + } + + /// `![[name]]` and `![alt](path)` references, as written. + static func references(in markdown: String) -> [Reference] { + var found: [Reference] = [] + let range = NSRange(markdown.startIndex..., in: markdown) + let wikilink = try! NSRegularExpression(pattern: #"!\[\[([^\]\|#\^\n]+)[^\]\n]*\]\]"#) + for match in wikilink.matches(in: markdown, range: range) { + guard let target = Range(match.range(at: 1), in: markdown) else { continue } + let name = markdown[target].trimmingCharacters(in: .whitespaces) + found.append(Reference(written: name, decoded: name, wikilink: true)) + } + let image = try! NSRegularExpression(pattern: #"!\[[^\]\n]*\]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\s*\)"#) + for match in image.matches(in: markdown, range: range) { + guard let target = Range(match.range(at: 1), in: markdown) else { continue } + var href = String(markdown[target]) + if href.hasPrefix("<"), href.hasSuffix(">") { href = String(href.dropFirst().dropLast()) } + if href.contains("://") || href.lowercased().hasPrefix("data:") || href.lowercased().hasPrefix("mailto:") { continue } + let bare = href.split(separator: "#", maxSplits: 1).first.map(String.init) ?? href + let path = bare.split(separator: "?", maxSplits: 1).first.map(String.init) ?? bare + found.append(Reference(written: href, decoded: path.removingPercentEncoding ?? path, wikilink: false)) + } + return found + } + + /// The nearest folder above the note that holds a `.zennotes` folder. + static func vaultRoot(above directory: URL) -> URL? { + let home = getpwuid(getuid()).flatMap { $0.pointee.pw_dir.map { String(cString: $0) } } + var current = directory + for _ in 0..<32 { + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: current.appendingPathComponent(".zennotes").path, isDirectory: &isDirectory), + isDirectory.boolValue { + return current + } + if current.path == "/" || current.path == home { return nil } + current = current.deletingLastPathComponent() + } + return nil + } + + struct AssetResolver { + let noteDirectory: URL + let vaultRoot: URL? + private var byName: [String: URL]? + + init(noteDirectory: URL, vaultRoot: URL?) { + self.noteDirectory = noteDirectory + self.vaultRoot = vaultRoot + } + + /// A file the note embeds, found the way ZenNotes finds it: a path + /// beside the note or from the vault root, and a bare `![[name]]` by + /// its name anywhere in the vault. Only media files inside the vault + /// (or the note's folder outside one) are ever served. + mutating func resolve(_ reference: Reference) -> URL? { + let path = reference.decoded + guard !path.isEmpty else { return nil } + var candidates: [URL] = [] + if path.hasPrefix("/") { + if let vaultRoot { candidates.append(vaultRoot.appendingPathComponent(String(path.dropFirst()))) } + } else { + candidates.append(noteDirectory.appendingPathComponent(path)) + if let vaultRoot { candidates.append(vaultRoot.appendingPathComponent(path)) } + } + for candidate in candidates { + if let file = allowed(candidate) { return file } + } + guard reference.wikilink, !path.contains("/") else { return nil } + return findByName(path).flatMap(allowed) + } + + /// The reference must sit inside the vault (or the note's folder); a + /// symlinked attachment may point elsewhere, as it plays in the app. + private func allowed(_ candidate: URL) -> URL? { + let link = candidate.standardizedFileURL + let root = (vaultRoot ?? noteDirectory).standardizedFileURL + guard link.path.hasPrefix(root.path + "/") else { return nil } + let file = link.resolvingSymlinksInPath() + guard NotePreview.mediaExtensions.contains(file.pathExtension.lowercased()), + let values = try? file.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + Int64(values.fileSize ?? 0) <= NotePreview.assetLimit else { return nil } + return file + } + + /// Every media file in the vault by lower-cased name, built once and only + /// when a bare name is not beside the note. + private mutating func findByName(_ name: String) -> URL? { + if byName == nil { + var index: [String: URL] = [:] + if let vaultRoot, + let walk = FileManager.default.enumerator( + at: vaultRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) { + var visited = 0 + for case let file as URL in walk { + visited += 1 + if visited > 50_000 { break } + if file.lastPathComponent == "node_modules" { walk.skipDescendants(); continue } + guard NotePreview.mediaExtensions.contains(file.pathExtension.lowercased()) else { continue } + let key = file.lastPathComponent.lowercased() + if index[key] == nil { index[key] = file } + } + } + byName = index + } + return byName?[name.lowercased()] + } + } +} diff --git a/apps/quicklook/macos/Extension/PreviewViewController.swift b/apps/quicklook/macos/Extension/PreviewViewController.swift new file mode 100644 index 00000000..1cba5027 --- /dev/null +++ b/apps/quicklook/macos/Extension/PreviewViewController.swift @@ -0,0 +1,190 @@ +import Cocoa +import OSLog +import Quartz +import WebKit + +/// Implemented by the opener service bundled inside this extension. Quick +/// Look's sandbox lets a preview launch nothing, not even the app it ships +/// in; the service runs outside it. +@objc(ZenNotesQuickLookOpening) protocol ZenNotesQuickLookOpening { + func openNote(atPath path: String, reply: @escaping (Bool) -> Void) + func openLink(_ link: String, reply: @escaping (Bool) -> Void) +} + +private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ZenNotesQuickLook", category: "preview") + +/// Space on a Markdown file in Finder: the note rendered the way ZenNotes +/// renders it, by the app's own Preview code loaded from this bundle, with an +/// Open in ZenNotes button. +@objc(PreviewViewController) +final class PreviewViewController: NSViewController, QLPreviewingController, WKNavigationDelegate, WKScriptMessageHandler, WKURLSchemeHandler { + private var webView: WKWebView! + private var preview: NotePreview? + private var completion: ((Error?) -> Void)? + private var opener: NSXPCConnection? + + override func loadView() { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.setURLSchemeHandler(self, forURLScheme: NotePreview.scheme) + configuration.userContentController.add(WeakMessageHandler(self), name: "zennotes") + // Page errors reach the system log (Console.app), where a blank preview + // can be diagnosed. A user script runs outside the page's own policy. + configuration.userContentController.addUserScript( + WKUserScript(source: Self.errorReporter, injectionTime: .atDocumentStart, forMainFrameOnly: true) + ) + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: configuration) + webView.autoresizingMask = [.width, .height] + webView.navigationDelegate = self + #if QUICKLOOK_SELFTEST + if #available(macOS 13.3, *) { webView.isInspectable = true } + #endif + self.webView = webView + view = webView + } + + func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) { + do { + preview = try NotePreview(fileURL: url, viewer: Bundle.main.resourceURL!.appendingPathComponent("viewer")) + } catch { + log.error("cannot preview \(url.lastPathComponent, privacy: .public): \(error.localizedDescription, privacy: .public)") + handler(error) + return + } + completion = handler + webView.load(URLRequest(url: NotePreview.pageURL)) + // Quick Look shows the panel once the handler is called. The page calls + // back when the note has rendered; one that never does must not hold + // the panel empty. + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.finishPreparing() } + } + + private func finishPreparing() { + guard let completion else { return } + self.completion = nil + completion(nil) + #if QUICKLOOK_SELFTEST + let summary = "JSON.stringify({ ready: document.readyState, root: document.getElementById('zen-quicklook-root')?.childElementCount ?? -1, zen: typeof window.zen, theme: document.documentElement.dataset.theme || '', images: [...document.images].map((i) => i.currentSrc.split('/').pop() + ':' + i.naturalWidth), katex: document.querySelectorAll('.katex').length, mermaid: document.querySelectorAll('svg[id^=mermaid], .mermaid svg').length, text: (document.body.innerText || '').slice(0, 120) })" + webView.evaluateJavaScript(summary) { result, _ in + log.error("selftest page: \(String(describing: result), privacy: .public)") + } + if preview?.markdown.contains("quicklook-selftest-open") == true { + webView.evaluateJavaScript("document.querySelector('[data-quicklook-open]')?.click()") + } + if preview?.markdown.contains("quicklook-selftest-keys") == true { + let keys = """ + const press = (key, extra = {}) => window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...extra })) + const settle = () => new Promise((resolve) => setTimeout(resolve, 200)) + press('j'); press('j'); press('j'); await settle() + const afterJ = window.scrollY + press('G', { shiftKey: true }); await settle() + const afterG = window.scrollY + press('g'); press('g'); await settle() + return JSON.stringify({ afterJ, afterG, afterGG: window.scrollY, height: document.documentElement.scrollHeight }) + """ + webView.callAsyncJavaScript(keys, arguments: [:], in: nil, in: .page) { result in + log.error("selftest keys: \(String(describing: try? result.get()), privacy: .public)") + } + } + #endif + } + + // MARK: - Messages from the page + + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return } + switch action { + case "ready": + finishPreparing() + case "log": + let text = String(describing: body["message"] ?? "").prefix(500) + log.error("page: \(text, privacy: .public)") + case "open": + guard let path = preview?.fileURL.path else { return } + openerProxy()?.openNote(atPath: path) { opened in + log.info("open in ZenNotes: \(opened ? "done" : "refused", privacy: .public)") + } + case "openLink": + guard let link = body["url"] as? String, NotePreview.isOutsideLink(link) else { return } + openerProxy()?.openLink(link) { _ in } + default: + break + } + } + + private func openerProxy() -> ZenNotesQuickLookOpening? { + if opener == nil, let identifier = Bundle.main.bundleIdentifier { + let connection = NSXPCConnection(serviceName: identifier + ".Opener") + connection.remoteObjectInterface = NSXPCInterface(with: ZenNotesQuickLookOpening.self) + connection.invalidationHandler = { [weak self] in + DispatchQueue.main.async { self?.opener = nil } + } + connection.resume() + opener = connection + } + return opener?.remoteObjectProxyWithErrorHandler { error in + log.error("opener unavailable: \(error.localizedDescription, privacy: .public)") + } as? ZenNotesQuickLookOpening + } + + // MARK: - Navigation + + /// The page never navigates: links go through the opener, so anything + /// leaving the extension's own scheme is refused. + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + decisionHandler(navigationAction.request.url?.scheme == NotePreview.scheme ? .allow : .cancel) + } + + // MARK: - zenql:// scheme + + func webView(_ webView: WKWebView, start task: WKURLSchemeTask) { + guard let url = task.request.url, let preview else { + task.didFailWithError(URLError(.fileDoesNotExist)) + return + } + do { + let response = try preview.response(for: url, range: task.request.value(forHTTPHeaderField: "Range")) + task.didReceive(response.head) + task.didReceive(response.body) + task.didFinish() + } catch { + task.didFailWithError(error) + } + } + + func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {} +} + +extension PreviewViewController { + /// Uncaught errors, failed loads (a script or stylesheet the page could not + /// fetch) and rejected promises, forwarded to the extension's log. + static let errorReporter = """ + (() => { + const post = (message) => window.webkit?.messageHandlers?.zennotes?.postMessage({ action: 'log', message: String(message) }) + window.addEventListener('error', (event) => { + const target = event.target + if (target && target !== window && (target.src || target.href)) post('failed to load ' + (target.src || target.href)) + else post(event.message + ' at ' + event.filename + ':' + event.lineno) + }, true) + window.addEventListener('unhandledrejection', (event) => post('unhandled rejection: ' + ((event.reason && event.reason.stack) || event.reason))) + })() + """ +} + +/// The content controller keeps its message handlers alive, and the view +/// controller keeps the web view; a weak hop breaks that cycle. +private final class WeakMessageHandler: NSObject, WKScriptMessageHandler { + weak var target: WKScriptMessageHandler? + + init(_ target: WKScriptMessageHandler) { + self.target = target + } + + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + target?.userContentController(controller, didReceive: message) + } +} diff --git a/apps/quicklook/macos/Opener/Info.plist b/apps/quicklook/macos/Opener/Info.plist new file mode 100644 index 00000000..6973feae --- /dev/null +++ b/apps/quicklook/macos/Opener/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ZenNotesQuickLookOpener + CFBundleIdentifier + __APP_ID__.QuickLook.Opener + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ZenNotesQuickLookOpener + CFBundlePackageType + XPC! + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 12.0 + XPCService + + ServiceType + Application + + + diff --git a/apps/quicklook/macos/Opener/main.swift b/apps/quicklook/macos/Opener/main.swift new file mode 100644 index 00000000..0c16a261 --- /dev/null +++ b/apps/quicklook/macos/Opener/main.swift @@ -0,0 +1,69 @@ +import AppKit + +/// Matches the protocol in the extension's PreviewViewController.swift. +@objc(ZenNotesQuickLookOpening) protocol ZenNotesQuickLookOpening { + func openNote(atPath path: String, reply: @escaping (Bool) -> Void) + func openLink(_ link: String, reply: @escaping (Bool) -> Void) +} + +/// Hands a previewed note to the ZenNotes this service ships inside, and an +/// outside link to the browser. Quick Look's sandbox lets the preview itself +/// launch nothing. What it asks is checked here again: only an existing +/// Markdown file, only into this ZenNotes, and only web or mail links. +final class Opener: NSObject, ZenNotesQuickLookOpening { + /// ZenNotes.app/Contents/PlugIns/.appex/Contents/XPCServices/.xpc + private static let app: URL? = { + var url = Bundle.main.bundleURL + for _ in 0..<6 { url.deleteLastPathComponent() } + guard url.pathExtension == "app", + let ownIdentifier = Bundle.main.bundleIdentifier, + let appIdentifier = Bundle(url: url)?.bundleIdentifier, + ownIdentifier.hasPrefix(appIdentifier + ".") else { return nil } + return url + }() + + func openNote(atPath path: String, reply: @escaping (Bool) -> Void) { + let file = URL(fileURLWithPath: path) + var isDirectory: ObjCBool = false + guard let app = Opener.app, + ["md", "markdown"].contains(file.pathExtension.lowercased()), + FileManager.default.fileExists(atPath: file.path, isDirectory: &isDirectory), + !isDirectory.boolValue else { + reply(false) + return + } + // The same route as a double-click in Finder: a note inside a known + // vault opens there, anything else in its own window. + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.open([file], withApplicationAt: app, configuration: configuration) { _, error in + reply(error == nil) + } + } + + func openLink(_ link: String, reply: @escaping (Bool) -> Void) { + guard let url = URL(string: link.trimmingCharacters(in: .whitespaces)), + let scheme = url.scheme?.lowercased(), + ["http", "https", "mailto"].contains(scheme) else { + reply(false) + return + } + NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { _, error in + reply(error == nil) + } + } +} + +final class ServiceDelegate: NSObject, NSXPCListenerDelegate { + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + connection.exportedInterface = NSXPCInterface(with: ZenNotesQuickLookOpening.self) + connection.exportedObject = Opener() + connection.resume() + return true + } +} + +let delegate = ServiceDelegate() +let listener = NSXPCListener.service() +listener.delegate = delegate +listener.resume() diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json new file mode 100644 index 00000000..c8ade93e --- /dev/null +++ b/apps/quicklook/package.json @@ -0,0 +1,66 @@ +{ + "name": "@zennotes/quicklook", + "private": true, + "version": "2.62.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", + "scripts": { + "build": "tsc --noEmit && vite build", + "build:nocheck": "vite build", + "typecheck": "tsc --noEmit", + "test:run": "vitest run" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "smol-toml": "^1.7.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.4.3", + "vitest": "^3.2.6" + } +} diff --git a/apps/quicklook/postcss.config.js b/apps/quicklook/postcss.config.js new file mode 100644 index 00000000..2b75bd8a --- /dev/null +++ b/apps/quicklook/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/apps/quicklook/scripts/build-extension.mjs b/apps/quicklook/scripts/build-extension.mjs new file mode 100644 index 00000000..5a7e9574 --- /dev/null +++ b/apps/quicklook/scripts/build-extension.mjs @@ -0,0 +1,112 @@ +// Builds the Quick Look extension (ZenNotesQuickLook.appex) into a packaged +// ZenNotes.app. 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, with +// its own entitlements (Quick Look only loads sandboxed extensions) and the +// same identity the app is signed with. +import { execFile } from 'node:child_process' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +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 workspace = join(here, '..') +const sources = join(workspace, 'macos') + +export const QUICKLOOK_BUNDLE = 'ZenNotesQuickLook.appex' +const OPENER_BUNDLE = 'ZenNotesQuickLookOpener.xpc' +const SWIFT_TARGETS = { arm64: 'arm64-apple-macos12.0', x64: 'x86_64-apple-macos12.0' } + +let viewerBuild = null + +/** The viewer page, built once per process: both architectures share it. */ +export function buildQuickLookViewer() { + viewerBuild ??= run('npm', ['run', 'build:nocheck'], { cwd: workspace, maxBuffer: 64 * 1024 * 1024 }).then(() => { + if (!existsSync(join(workspace, 'dist', 'quicklook.js'))) { + throw new Error('The Quick Look viewer build produced no dist/quicklook.js.') + } + return join(workspace, 'dist') + }) + return viewerBuild +} + +/** + * @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 {{ identity: string, keychain: string | null, timestamp?: string } | null} options.signing + * null signs ad hoc; `timestamp` names the server the app's own signing uses (mac.timestamp) + * @param {boolean} [options.selfTest] compile the button self-test used when validating a build + */ +export async function buildQuickLookExtension({ appPath, arch, appId, version, signing, selfTest = false }) { + const target = SWIFT_TARGETS[arch] + if (!target) throw new Error(`Unsupported Quick Look architecture: ${arch}`) + const viewer = await buildQuickLookViewer() + const work = await mkdtemp(join(tmpdir(), 'zennotes-quicklook-')) + try { + const appex = join(work, QUICKLOOK_BUNDLE) + const opener = join(appex, 'Contents', 'XPCServices', OPENER_BUNDLE) + await mkdir(join(appex, 'Contents', 'MacOS'), { recursive: true }) + await mkdir(join(opener, 'Contents', 'MacOS'), { recursive: true }) + await writePlist(join(sources, 'Extension', 'Info.plist'), join(appex, 'Contents', 'Info.plist'), appId, version) + await writePlist(join(sources, 'Opener', 'Info.plist'), join(opener, 'Contents', 'Info.plist'), appId, version) + + const sdk = (await run('xcrun', ['--show-sdk-path', '--sdk', 'macosx'])).stdout.trim() + const swift = ['swiftc', '-target', target, '-sdk', sdk, '-O'] + const extensionSources = (await readdir(join(sources, 'Extension'))) + .filter((name) => name.endsWith('.swift')) + .map((name) => join(sources, 'Extension', name)) + await run('xcrun', [ + ...swift, + ...(selfTest ? ['-D', 'QUICKLOOK_SELFTEST'] : []), + '-parse-as-library', '-application-extension', + '-module-name', 'ZenNotesQuickLook', + '-Xlinker', '-e', '-Xlinker', '_NSExtensionMain', + '-framework', 'Cocoa', '-framework', 'Quartz', '-framework', 'WebKit', '-framework', 'UniformTypeIdentifiers', + '-o', join(appex, 'Contents', 'MacOS', 'ZenNotesQuickLook'), + ...extensionSources + ], { maxBuffer: 16 * 1024 * 1024 }) + await run('xcrun', [ + ...swift, + '-module-name', 'ZenNotesQuickLookOpener', + '-framework', 'AppKit', + '-o', join(opener, 'Contents', 'MacOS', 'ZenNotesQuickLookOpener'), + join(sources, 'Opener', 'main.swift') + ], { maxBuffer: 16 * 1024 * 1024 }) + await cp(viewer, join(appex, 'Contents', 'Resources', 'viewer'), { recursive: true }) + + // Inside out: the opener, then the extension that contains it. + await sign(opener, signing, null) + await sign(appex, signing, join(sources, 'Extension', 'Extension.entitlements')) + + const plugIns = join(appPath, 'Contents', 'PlugIns') + await mkdir(plugIns, { recursive: true }) + await rm(join(plugIns, QUICKLOOK_BUNDLE), { recursive: true, force: true }) + await run('ditto', [appex, join(plugIns, QUICKLOOK_BUNDLE)]) + } finally { + await rm(work, { recursive: true, force: true }) + } +} + +async function writePlist(template, destination, appId, version) { + const text = (await readFile(template, 'utf8')).replaceAll('__APP_ID__', appId).replaceAll('__VERSION__', version) + await writeFile(destination, text) +} + +async function sign(bundle, signing, entitlements) { + 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', '-') + } + if (entitlements) args.push('--entitlements', entitlements) + await run('codesign', [...args, bundle]) +} diff --git a/apps/quicklook/src/host.ts b/apps/quicklook/src/host.ts new file mode 100644 index 00000000..6954719d --- /dev/null +++ b/apps/quicklook/src/host.ts @@ -0,0 +1,20 @@ +/** Messages the page sends the Swift extension that hosts it. */ +export type QuickLookHostMessage = + | { action: 'ready' } + | { action: 'open' } + | { action: 'openLink'; url: string } + +interface WebKitMessageHandlers { + zennotes?: { postMessage(message: QuickLookHostMessage): void } +} + +export function postToHost(message: QuickLookHostMessage): void { + const handlers = (window as unknown as { webkit?: { messageHandlers?: WebKitMessageHandlers } }).webkit + ?.messageHandlers + handlers?.zennotes?.postMessage(message) +} + +/** The links a preview hands to the browser; everything else stays inert. */ +export function isOutsideLink(href: string): boolean { + return /^(https?:|mailto:)/i.test(href.trim()) +} diff --git a/apps/quicklook/src/keys.test.ts b/apps/quicklook/src/keys.test.ts new file mode 100644 index 00000000..5c171cbf --- /dev/null +++ b/apps/quicklook/src/keys.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { createQuickLookKeys, type QuickLookKey } from './keys' + +function key(value: string, extra: Partial = {}): QuickLookKey { + return { key: value, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, timeStamp: 0, ...extra } +} + +describe('createQuickLookKeys', () => { + it('opens the note on Enter whether Vim mode is on or off', () => { + expect(createQuickLookKeys(false)(key('Enter'))).toBe('open') + expect(createQuickLookKeys(true)(key('Enter'))).toBe('open') + }) + + it('leaves every single letter alone with Vim mode off', () => { + const keys = createQuickLookKeys(false) + for (const letter of ['o', 'j', 'k', 'g', 'G']) expect(keys(key(letter)), letter).toBeNull() + expect(keys(key('d', { ctrlKey: true }))).toBeNull() + }) + + it('maps the Vim motions and `o` with Vim mode on', () => { + const keys = createQuickLookKeys(true) + expect(keys(key('o'))).toBe('open') + expect(keys(key('j'))).toBe('lineDown') + expect(keys(key('k'))).toBe('lineUp') + expect(keys(key('G', { shiftKey: true }))).toBe('bottom') + expect(keys(key('d', { ctrlKey: true }))).toBe('halfDown') + expect(keys(key('u', { ctrlKey: true }))).toBe('halfUp') + }) + + it('reads `gg` only when the second g follows quickly and directly', () => { + const keys = createQuickLookKeys(true) + expect(keys(key('g', { timeStamp: 100 }))).toBeNull() + expect(keys(key('g', { timeStamp: 300 }))).toBe('top') + + expect(keys(key('g', { timeStamp: 1000 }))).toBeNull() + expect(keys(key('g', { timeStamp: 2000 }))).toBeNull() + + expect(keys(key('g', { timeStamp: 3000 }))).toBeNull() + expect(keys(key('j', { timeStamp: 3100 }))).toBe('lineDown') + expect(keys(key('g', { timeStamp: 3200 }))).toBeNull() + }) + + it('never claims a Command or Option shortcut', () => { + const keys = createQuickLookKeys(true) + expect(keys(key('Enter', { metaKey: true }))).toBeNull() + expect(keys(key('j', { altKey: true }))).toBeNull() + }) +}) diff --git a/apps/quicklook/src/keys.ts b/apps/quicklook/src/keys.ts new file mode 100644 index 00000000..c24d26e0 --- /dev/null +++ b/apps/quicklook/src/keys.ts @@ -0,0 +1,52 @@ +export type QuickLookAction = 'open' | 'lineDown' | 'lineUp' | 'halfDown' | 'halfUp' | 'top' | 'bottom' + +export interface QuickLookKey { + key: string + ctrlKey: boolean + metaKey: boolean + altKey: boolean + shiftKey: boolean + /** Milliseconds, as event.timeStamp. */ + timeStamp: number +} + +/** How long the first `g` of `gg` waits for the second. */ +const SEQUENCE_MS = 800 + +/** + * What a key press does in the preview once it has focus. Enter opens the + * note in ZenNotes in either mode. With Vim mode on, `o` opens too and the + * Vim motions move through the note; with it off no single letter does + * anything, so the arrows, Page Up/Down and Space scroll as in any page. + */ +export function createQuickLookKeys(vimMode: boolean): (key: QuickLookKey) => QuickLookAction | null { + let pendingG: number | null = null + return (event) => { + const previous = pendingG + pendingG = null + if (event.metaKey || event.altKey) return null + if (event.key === 'Enter' && !event.ctrlKey && !event.shiftKey) return 'open' + if (!vimMode) return null + if (event.ctrlKey) { + if (event.key === 'd') return 'halfDown' + if (event.key === 'u') return 'halfUp' + return null + } + switch (event.key) { + case 'o': + return 'open' + case 'j': + return 'lineDown' + case 'k': + return 'lineUp' + case 'G': + return 'bottom' + case 'g': + if (previous !== null && event.timeStamp - previous <= SEQUENCE_MS) return 'top' + pendingG = event.timeStamp + return null + default: + return null + } + } +} diff --git a/apps/quicklook/src/main.tsx b/apps/quicklook/src/main.tsx new file mode 100644 index 00000000..34f29904 --- /dev/null +++ b/apps/quicklook/src/main.tsx @@ -0,0 +1,252 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import type { AssetMeta, ImportedAssetKind, NoteContent, VaultInfo } from '@shared/ipc' +import { applyTypographyVariables } from '@renderer/lib/typography-variables' +import { Button } from '@renderer/components/ui/Button' +// The full app stylesheet (prose, themes, KaTeX, highlight, diagram chrome), +// as the share viewer and the PDF export window ship it. +import '@renderer/styles/index.css' +import { readQuickLookPayload, type QuickLookPayload } from './payload' +import { installQuickLookBridge } from './shim' +import { quickLookTheme, readQuickLookPrefs, type QuickLookPrefs } from './prefs' +import { createQuickLookKeys, type QuickLookAction } from './keys' +import { isOutsideLink, postToHost } from './host' + +const payload = readQuickLookPayload() +if (payload) { + // The bridge must exist before any app-core module that reads window.zen. + installQuickLookBridge(payload) + void boot(payload).catch((error) => { + console.error('Quick Look preview could not render this note.', error) + postToHost({ action: 'ready' }) + }) +} else { + postToHost({ action: 'ready' }) +} + +async function boot(data: QuickLookPayload): Promise { + const prefs = readQuickLookPrefs(data.config) + applyAppearance(prefs) + + const [{ useStore }, { LazyPreview }] = await Promise.all([ + import('@renderer/store'), + import('@renderer/components/LazyPreview') + ]) + + const now = Date.now() + const note: NoteContent = { + path: data.notePath, + title: data.title, + folder: 'inbox', + siblingOrder: 0, + createdAt: now, + updatedAt: now, + size: data.markdown.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: Object.keys(data.assets).length > 0, + excerpt: '', + body: data.markdown + } + // The references double as the asset paths, so app-core's resolver lands on + // exactly the keys the extension resolved (the share viewer does the same). + const assetFiles: AssetMeta[] = Object.keys(data.assets).map((ref, index) => ({ + path: ref, + name: ref.split('/').pop() ?? ref, + kind: assetKindOf(ref), + siblingOrder: index, + size: 0, + updatedAt: 0 + })) + useStore.setState({ + vault: { root: '/quicklook', name: 'Quick Look' } satisfies VaultInfo, + notes: [], + assetFiles, + assetFilesListed: true, + selectedPath: note.path, + activeNote: note, + mathRenderer: 'katex' + } as never) + + const root = document.getElementById('zen-quicklook-root') + if (!root) return + root.addEventListener('click', followOrHold, true) + root.addEventListener('change', (event) => event.stopPropagation(), true) + root.addEventListener('contextmenu', (event) => event.stopPropagation(), true) + listenForKeys(prefs.vimMode) + + ReactDOM.createRoot(root).render( + + + + + + ) +} + +function QuickLookPage({ + title, + vimMode, + children +}: { + title: string + vimMode: boolean + children: React.ReactNode +}): React.ReactElement { + return ( + <> +
+ {title} + +
+
{children}
+ + ) +} + +function KeyHint({ label }: { label: string }): React.ReactElement { + return {label} +} + +let readySent = false +function onRendered(): void { + const root = document.getElementById('zen-quicklook-root') + // Wikilinks, tags and task checkboxes act on a vault in the app; here they + // are plain text, as on a public share. + for (const anchor of root?.querySelectorAll('a.wikilink, a.hashtag') ?? []) { + anchor.removeAttribute('href') + } + for (const checkbox of root?.querySelectorAll('input[type="checkbox"]') ?? []) { + checkbox.disabled = true + } + if (readySent) return + readySent = true + // Quick Look shows the panel when the extension says the page is ready, so + // it opens on the rendered note rather than on an empty page. + requestAnimationFrame(() => postToHost({ action: 'ready' })) +} + +/** Outside links open in the browser through the extension; anything that + * would navigate the preview itself is held, except a jump within the note. */ +function followOrHold(event: MouseEvent): void { + const target = event.target instanceof Element ? event.target : null + if (target?.closest('input[type="checkbox"], .zen-task-state-in-progress[data-task-index]')) { + event.preventDefault() + event.stopPropagation() + return + } + const anchor = target?.closest('a') + if (!anchor || anchor.closest('[data-quicklook-open]')) return + const href = anchor.getAttribute('href') ?? '' + if (href.startsWith('#')) return + event.preventDefault() + event.stopPropagation() + if (isOutsideLink(href)) postToHost({ action: 'openLink', url: href.trim() }) +} + +function listenForKeys(vimMode: boolean): void { + const handle = createQuickLookKeys(vimMode) + window.addEventListener('keydown', (event) => { + const target = event.target instanceof HTMLElement ? event.target : null + if (target?.closest('input, textarea, select, [contenteditable="true"]')) return + const action = handle(event) + if (!action) return + event.preventDefault() + perform(action) + }) +} + +function perform(action: QuickLookAction): void { + const line = 40 + const half = Math.round(window.innerHeight / 2) + switch (action) { + case 'open': + postToHost({ action: 'open' }) + return + case 'lineDown': + window.scrollBy({ top: line }) + return + case 'lineUp': + window.scrollBy({ top: -line }) + return + case 'halfDown': + window.scrollBy({ top: half }) + return + case 'halfUp': + window.scrollBy({ top: -half }) + return + case 'top': + window.scrollTo({ top: 0 }) + return + case 'bottom': + window.scrollTo({ top: document.documentElement.scrollHeight }) + } +} + +/** The user's ZenNotes theme and typography; auto mode follows the system. */ +function applyAppearance(prefs: QuickLookPrefs): void { + const html = document.documentElement + const media = window.matchMedia('(prefers-color-scheme: dark)') + const applyTheme = (): void => { + const theme = quickLookTheme(prefs, media.matches) + html.dataset.theme = theme.id + html.dataset.themeMode = theme.mode + html.setAttribute('data-opaque', '') + html.style.colorScheme = theme.mode + } + applyTheme() + media.addEventListener('change', applyTheme) + applyTypographyVariables(html, { + editorFontSize: prefs.editorFontSize, + mathFontScale: prefs.mathFontScale, + editorLineHeight: prefs.editorLineHeight, + previewMaxWidth: prefs.previewMaxWidth, + contentAlign: prefs.contentAlign, + completedTaskStyle: prefs.completedTaskStyle, + mathRenderer: 'katex', + interfaceFont: prefs.interfaceFont, + textFont: prefs.textFont, + monoFont: prefs.monoFont + }) + + const style = document.createElement('style') + style.textContent = ` + /* The app stylesheet treats the document as a fixed-viewport app shell + (height: 100%, overflow: hidden, user-select: none). A preview is a + scrolling document, as the share viewer and PDF export make it. */ + html, body { + height: auto !important; + min-height: 100vh; + margin: 0; + overflow: visible !important; + user-select: text !important; + background: rgb(var(--z-bg)); + } + .zen-quicklook-note { padding: 8px 0 48px; } + .zen-quicklook-note .prose-zen a.wikilink, + .zen-quicklook-note .prose-zen a.wikilink.broken, + .zen-quicklook-note .prose-zen a.hashtag { + color: rgb(var(--z-grey-1)); + border-bottom: 1px dashed rgb(var(--z-grey-dim)); + text-decoration: none; + pointer-events: none; + cursor: default; + } + .zen-quicklook-note .prose-zen input[type="checkbox"] { pointer-events: none; } + ` + document.head.appendChild(style) +} + +function assetKindOf(ref: string): ImportedAssetKind { + const ext = ref.toLowerCase().split('.').pop() ?? '' + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'apng', 'svg'].includes(ext)) return 'image' + if (ext === 'pdf') return 'pdf' + if (['mp3', 'm4a', 'aac', 'flac', 'ogg', 'wav'].includes(ext)) return 'audio' + if (['mp4', 'm4v', 'mov', 'ogv', 'webm'].includes(ext)) return 'video' + return 'file' +} diff --git a/apps/quicklook/src/payload.test.ts b/apps/quicklook/src/payload.test.ts new file mode 100644 index 00000000..6e34c92c --- /dev/null +++ b/apps/quicklook/src/payload.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { readQuickLookPayload } from './payload' +import { lookupQuickLookAsset } from './shim' +import { isOutsideLink } from './host' + +function page(json: unknown): Pick { + const text = typeof json === 'string' ? json : JSON.stringify(json) + return { getElementById: (id: string) => (id === 'zen-quicklook-data' ? ({ textContent: text } as HTMLElement) : null) } +} + +describe('readQuickLookPayload', () => { + it('reads the note the extension embedded', () => { + expect( + readQuickLookPayload( + page({ + title: 'Plan', + markdown: '# Plan', + notePath: 'inbox/Plan.md', + assets: { 'diagram.png': 'zenql://app/asset/0/diagram.png' }, + config: '[vim]\nenabled = true' + }) + ) + ).toEqual({ + title: 'Plan', + markdown: '# Plan', + notePath: 'inbox/Plan.md', + assets: { 'diagram.png': 'zenql://app/asset/0/diagram.png' }, + config: '[vim]\nenabled = true' + }) + }) + + it('keeps only asset URLs the extension serves', () => { + const payload = readQuickLookPayload( + page({ + markdown: '', + assets: { a: 'zenql://app/asset/0/a.png', b: 'https://tracker.example/pixel.gif', c: 'file:///etc/passwd', d: 42 } + }) + ) + expect(payload?.assets).toEqual({ a: 'zenql://app/asset/0/a.png' }) + }) + + it('is null without a payload or with one that is not a note', () => { + expect(readQuickLookPayload({ getElementById: () => null })).toBeNull() + expect(readQuickLookPayload(page('{"markdown": 3}'))).toBeNull() + expect(readQuickLookPayload(page('not json'))).toBeNull() + }) +}) + +describe('lookupQuickLookAsset', () => { + const payload = { assets: { 'my diagram.png': 'zenql://app/asset/0/x.png', 'raw%20name.png': 'zenql://app/asset/1/y.png' } } + + it('finds a reference as written or percent-decoded, ignoring a fragment', () => { + expect(lookupQuickLookAsset(payload, 'my%20diagram.png#page=2')).toBe('zenql://app/asset/0/x.png') + expect(lookupQuickLookAsset(payload, 'raw%20name.png')).toBe('zenql://app/asset/1/y.png') + expect(lookupQuickLookAsset(payload, 'missing.png')).toBeNull() + }) +}) + +describe('isOutsideLink', () => { + it('hands web and mail links to the browser and nothing else', () => { + expect(isOutsideLink('https://zennotes.org')).toBe(true) + expect(isOutsideLink('mailto:hi@zennotes.org')).toBe(true) + expect(isOutsideLink('zennotes://open?path=x')).toBe(false) + expect(isOutsideLink('javascript:alert(1)')).toBe(false) + expect(isOutsideLink('#heading')).toBe(false) + }) +}) diff --git a/apps/quicklook/src/payload.ts b/apps/quicklook/src/payload.ts new file mode 100644 index 00000000..351c24e0 --- /dev/null +++ b/apps/quicklook/src/payload.ts @@ -0,0 +1,44 @@ +/** What the Quick Look extension embeds in #zen-quicklook-data. */ +export interface QuickLookPayload { + /** The file name without its extension. */ + title: string + markdown: string + /** The note's path inside its vault, or its file name outside one. */ + notePath: string + /** A Markdown reference, as written and as decoded, mapped to the + * zenql:// URL the extension serves its file from. */ + assets: Record + /** The text of ~/.config/zennotes/config.toml, or '' when there is none. */ + config: string +} + +export const QUICKLOOK_ASSET_PREFIX = 'zenql://app/asset/' + +export function readQuickLookPayload(doc: Pick = document): QuickLookPayload | null { + const text = doc.getElementById('zen-quicklook-data')?.textContent + if (!text) return null + try { + const parsed = JSON.parse(text) as Partial + if (typeof parsed.markdown !== 'string') return null + return { + title: typeof parsed.title === 'string' && parsed.title ? parsed.title : 'Untitled', + markdown: parsed.markdown, + notePath: typeof parsed.notePath === 'string' && parsed.notePath ? parsed.notePath : 'note.md', + assets: localAssets(parsed.assets), + config: typeof parsed.config === 'string' ? parsed.config : '' + } + } catch { + return null + } +} + +/** Only URLs the extension itself serves: the page has no network, and a + * payload never points it anywhere else. */ +function localAssets(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const assets: Record = {} + for (const [ref, url] of Object.entries(value)) { + if (typeof url === 'string' && url.startsWith(QUICKLOOK_ASSET_PREFIX)) assets[ref] = url + } + return assets +} diff --git a/apps/quicklook/src/prefs.test.ts b/apps/quicklook/src/prefs.test.ts new file mode 100644 index 00000000..4819d157 --- /dev/null +++ b/apps/quicklook/src/prefs.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { defaultQuickLookPrefs, quickLookTheme, readQuickLookPrefs } from './prefs' + +describe('readQuickLookPrefs', () => { + it('reads the look from the sections and keys the desktop writes', () => { + const prefs = readQuickLookPrefs(` +[vim] +enabled = false # comments are fine + +[editor] +font_size = 18 +line_height = 1.5 +preview_max_width = 700 +completed_task_style = "strikethrough" + +[appearance] +theme_family = "catppuccin" +theme_mode = "light" +theme_id = "catppuccin-latte" +content_align = "left" + +[typography] +text_font = "Iowan Old Style" +mono_font = "" +`) + expect(prefs).toMatchObject({ + vimMode: false, + editorFontSize: 18, + editorLineHeight: 1.5, + previewMaxWidth: 700, + completedTaskStyle: 'strikethrough', + themeFamily: 'catppuccin', + themeMode: 'light', + themeId: 'catppuccin-latte', + contentAlign: 'left', + textFont: 'Iowan Old Style', + monoFont: null, + interfaceFont: null + }) + }) + + it('falls back to the app defaults for a missing file, a broken file, or a wrong type', () => { + expect(readQuickLookPrefs('')).toEqual(defaultQuickLookPrefs()) + expect(readQuickLookPrefs('[editor\nfont_size = ')).toEqual(defaultQuickLookPrefs()) + const prefs = readQuickLookPrefs('[editor]\nfont_size = "big"\n[appearance]\ntheme_mode = "sepia"\n[vim]\nenabled = "yes"') + expect(prefs.editorFontSize).toBe(defaultQuickLookPrefs().editorFontSize) + expect(prefs.themeMode).toBe(defaultQuickLookPrefs().themeMode) + expect(prefs.vimMode).toBe(true) + }) +}) + +describe('quickLookTheme', () => { + const prefs = defaultQuickLookPrefs() + + it('uses the chosen theme in a fixed mode', () => { + expect(quickLookTheme({ ...prefs, themeFamily: 'gruvbox', themeMode: 'dark', themeId: 'dark-soft' }, false).id).toBe('dark-soft') + }) + + it('follows the system in auto mode, keeping the chosen variant', () => { + const auto = { ...prefs, themeFamily: 'gruvbox' as const, themeMode: 'auto' as const, themeId: 'dark-soft' } + expect(quickLookTheme(auto, true).id).toBe('dark-soft') + expect(quickLookTheme(auto, false).id).toBe('light-soft') + }) + + it('draws a custom theme with the built-in look of its mode', () => { + expect(quickLookTheme({ ...prefs, themeFamily: 'custom', themeMode: 'dark', themeId: 'my-theme' }, false).id).toBe('apple-dark') + expect(quickLookTheme({ ...prefs, themeFamily: 'custom', themeMode: 'auto', themeId: 'my-theme' }, false).id).toBe('apple-light') + }) +}) diff --git a/apps/quicklook/src/prefs.ts b/apps/quicklook/src/prefs.ts new file mode 100644 index 00000000..ffe1b222 --- /dev/null +++ b/apps/quicklook/src/prefs.ts @@ -0,0 +1,95 @@ +import { parse } from 'smol-toml' +import { PORTABLE_DEFAULTS } from '@shared/app-config' +import { findTheme, resolveAuto, type ThemeFamily, type ThemeOption } from '@renderer/lib/themes' + +/** The look the preview borrows from the user's ZenNotes settings. */ +export interface QuickLookPrefs { + themeFamily: ThemeFamily + themeMode: 'light' | 'dark' | 'auto' + themeId: string + interfaceFont: string | null + textFont: string | null + monoFont: string | null + editorFontSize: number + mathFontScale: number + editorLineHeight: number + previewMaxWidth: number + contentAlign: 'center' | 'left' + completedTaskStyle: string + vimMode: boolean +} + +/** Where each preference lives in config.toml: the same sections and keys the + * desktop writes (apps/desktop/src/main/app-config.ts). */ +const TOML_KEYS: Record = { + themeFamily: ['appearance', 'theme_family'], + themeMode: ['appearance', 'theme_mode'], + themeId: ['appearance', 'theme_id'], + contentAlign: ['appearance', 'content_align'], + interfaceFont: ['typography', 'interface_font'], + textFont: ['typography', 'text_font'], + monoFont: ['typography', 'mono_font'], + editorFontSize: ['editor', 'font_size'], + mathFontScale: ['editor', 'math_font_scale'], + editorLineHeight: ['editor', 'line_height'], + previewMaxWidth: ['editor', 'preview_max_width'], + completedTaskStyle: ['editor', 'completed_task_style'], + vimMode: ['vim', 'enabled'] +} + +export function defaultQuickLookPrefs(): QuickLookPrefs { + const defaults = PORTABLE_DEFAULTS as Record + const prefs = {} as Record + for (const key of Object.keys(TOML_KEYS) as Array) prefs[key] = defaults[key] + return prefs as unknown as QuickLookPrefs +} + +/** + * The preferences in a config.toml, each falling back to the app's default + * when it is missing or of the wrong type. A file that does not parse yields + * the defaults: a preview never fails over settings. + */ +export function readQuickLookPrefs(configText: string): QuickLookPrefs { + const prefs = defaultQuickLookPrefs() + let doc: Record + try { + doc = configText.trim() ? (parse(configText) as Record) : {} + } catch { + return prefs + } + for (const [name, [section, key]] of Object.entries(TOML_KEYS) as Array<[keyof QuickLookPrefs, [string, string]]>) { + const table = doc[section] + const value = table && typeof table === 'object' ? (table as Record)[key] : undefined + const fallback = prefs[name] + if (value === undefined) continue + if (typeof fallback === 'number' && typeof value === 'number' && Number.isFinite(value)) { + ;(prefs as unknown as Record)[name] = value + } else if (typeof fallback === 'boolean' && typeof value === 'boolean') { + ;(prefs as unknown as Record)[name] = value + } else if ((typeof fallback === 'string' || fallback === null) && typeof value === 'string') { + // An empty font name means the platform default, as in the app. + ;(prefs as unknown as Record)[name] = value === '' && fallback === null ? null : value + } + } + if (prefs.themeMode !== 'light' && prefs.themeMode !== 'dark' && prefs.themeMode !== 'auto') { + prefs.themeMode = defaultQuickLookPrefs().themeMode + } + if (prefs.contentAlign !== 'left') prefs.contentAlign = 'center' + for (const font of ['interfaceFont', 'textFont', 'monoFont'] as const) { + if (prefs[font] === '') prefs[font] = null + } + return prefs +} + +/** + * The theme the preview draws with. A custom theme's stylesheet lives with + * the app, so its family falls back to the built-in light or dark look of the + * same mode. + */ +export function quickLookTheme(prefs: QuickLookPrefs, prefersDark: boolean): ThemeOption { + const family: ThemeFamily = prefs.themeFamily === 'custom' ? 'apple' : prefs.themeFamily + if (prefs.themeMode === 'auto') return findTheme(resolveAuto(family, prefersDark, prefs.themeId)) + const chosen = findTheme(prefs.themeId) + if (chosen.id === prefs.themeId && prefs.themeFamily !== 'custom') return chosen + return findTheme(resolveAuto(family, prefs.themeMode === 'dark', prefs.themeId)) +} diff --git a/apps/quicklook/src/shim.ts b/apps/quicklook/src/shim.ts new file mode 100644 index 00000000..e3a8d924 --- /dev/null +++ b/apps/quicklook/src/shim.ts @@ -0,0 +1,75 @@ +import type { ZenAppInfo, ZenBridge, ZenCapabilities } from '@bridge-contract/bridge' +import type { TikzRenderResponse } from '@shared/ipc' +import appPackage from '../package.json' +import { QUICKLOOK_ASSET_PREFIX, type QuickLookPayload } from './payload' + +const QUICKLOOK_CAPABILITIES: ZenCapabilities = { + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: false, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false, + supportsCloudSync: false, + supportsCustomCodeLanguages: false +} + +const QUICKLOOK_APP_INFO: ZenAppInfo = { + name: 'zennotes-quicklook', + productName: 'ZenNotes', + version: appPackage.version, + description: 'Quick Look preview for Markdown files', + homepage: 'https://zennotes.org', + runtime: 'web' +} + +function decodeRef(href: string): string { + const cleaned = href.split('#')[0]?.split('?')[0] ?? href + try { + return decodeURIComponent(cleaned) + } catch { + return cleaned + } +} + +/** The URL the extension serves a referenced file from, or null: a file it + * did not find (or will not serve) is shown as missing, never fetched. */ +export function lookupQuickLookAsset(payload: Pick, href: string): string | null { + for (const ref of [href, decodeRef(href)]) { + const url = Object.hasOwn(payload.assets, ref) ? payload.assets[ref] : null + if (url?.startsWith(QUICKLOOK_ASSET_PREFIX)) return url + } + return null +} + +/** + * A minimal `window.zen` for app-core's Preview pipeline: asset references + * resolve to the files the extension found next to the note, and everything + * that would change the vault is absent, since a preview is read-only. + */ +export function installQuickLookBridge(payload: QuickLookPayload): void { + const bridge: Partial = { + getCapabilities: () => QUICKLOOK_CAPABILITIES, + getConfigSync: () => null, + getAppInfo: () => QUICKLOOK_APP_INFO, + platformSync: () => 'darwin' as const, + platform: async () => 'darwin' as const, + listCustomCodeLanguages: async () => [], + readWorkspaceState: async () => null, + renderTikz: async (): Promise => ({ + ok: false, + error: 'TikZ diagrams render in ZenNotes. Open the note to see this one.' + }), + resolveVaultAssetUrl: (_vaultRoot: string, assetPath: string): string | null => + lookupQuickLookAsset(payload, assetPath), + resolveLocalAssetUrl: (_vaultRoot: string, _notePath: string, href: string): string | null => + lookupQuickLookAsset(payload, href), + getPathForFile: () => null, + clipboardWriteText: (text: string): void => { + void navigator.clipboard?.writeText(text) + }, + clipboardReadText: (): string => '' + } + window.zen = Object.freeze(bridge) as ZenBridge +} diff --git a/apps/quicklook/src/unavailable.ts b/apps/quicklook/src/unavailable.ts new file mode 100644 index 00000000..cf0d6f9c --- /dev/null +++ b/apps/quicklook/src/unavailable.ts @@ -0,0 +1,7 @@ +/** Typst math and the plot renderers are not part of the Quick Look preview: + * math typesets with KaTeX, and a plot shows its source. */ +function unavailable(): never { + throw new Error('This renderer is not part of the Quick Look preview. Open the note in ZenNotes to see it.') +} +export const JSXGraph = { initBoard: unavailable } +export default Object.assign(unavailable, { JSXGraph }) diff --git a/apps/quicklook/tailwind.config.js b/apps/quicklook/tailwind.config.js new file mode 100644 index 00000000..f299fc3c --- /dev/null +++ b/apps/quicklook/tailwind.config.js @@ -0,0 +1,2 @@ +import preset from '../../packages/app-core/build/tailwind-preset.cjs' +export default { ...preset, content: ['./src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'] } diff --git a/apps/quicklook/tsconfig.json b/apps/quicklook/tsconfig.json new file mode 100644 index 00000000..eb0fa1d9 --- /dev/null +++ b/apps/quicklook/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "useDefineForClassFields": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@renderer/*": ["../../packages/app-core/src/*"], + "@shared/*": ["../../packages/shared-domain/src/*"], + "@bridge-contract/*": ["../../packages/bridge-contract/src/*"], + "@zennotes/app-core/*": ["../../packages/app-core/src/*"], + "@zennotes/bridge-contract/*": ["../../packages/bridge-contract/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/quicklook/vite.config.ts b/apps/quicklook/vite.config.ts new file mode 100644 index 00000000..cde5c61a --- /dev/null +++ b/apps/quicklook/vite.config.ts @@ -0,0 +1,61 @@ +import { resolve } from 'node:path' +import { defineConfig, type Plugin } from 'vite' +import react from '@vitejs/plugin-react' +import { zenNotesAssets } from '../../packages/app-core/build/vite.mjs' + +/** + * Typst math needs a 28 MB WebAssembly compiler and its own fonts, and it only + * runs for someone who picked Typst as their math renderer. The preview always + * typesets with KaTeX, so those asset URLs resolve to nothing here. + */ +function withoutTypstAssets(): Plugin { + const empty = '\0zennotes-quicklook:empty-url' + return { + name: 'zennotes-quicklook-without-typst', + enforce: 'pre', + resolveId(id) { + if (/^@myriaddreamin\/typst-ts-(web-compiler|renderer)\/wasm\?url$/.test(id)) return empty + if (/\/typst-fonts\/[^/]+\.otf\?url$/.test(id)) return empty + return null + }, + load(id) { + return id === empty ? 'export default ""' : null + } + } +} + +// The Swift extension serves one generated page that loads quicklook.js and +// quicklook.css from its bundle; lazy chunks load relative to the entry. +export default defineConfig({ + root: __dirname, + base: './', + resolve: { + dedupe: ['react', 'react-dom'], + alias: [ + // Any Markdown file can be previewed, including one that came from + // somewhere else, so the plot renderers that evaluate expressions stay + // out, as on public shares. + { find: /^(jsxgraph|function-plot|@myriaddreamin\/typst\.ts)$/, replacement: resolve(__dirname, 'src/unavailable.ts') }, + { find: '@renderer', replacement: resolve(__dirname, '../../packages/app-core/src') }, + { find: '@shared', replacement: resolve(__dirname, '../../packages/shared-domain/src') }, + { find: '@bridge-contract', replacement: resolve(__dirname, '../../packages/bridge-contract/src') } + ] + }, + plugins: [react(), zenNotesAssets({ harper: false, excalidraw: false }), withoutTypstAssets()], + build: { + outDir: 'dist', + emptyOutDir: true, + chunkSizeWarningLimit: 3500, + sourcemap: false, + cssCodeSplit: false, + rollupOptions: { + input: resolve(__dirname, 'src/main.tsx'), + output: { + entryFileNames: 'quicklook.js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: (info) => + info.name?.endsWith('.css') ? 'quicklook.css' : 'assets/[name]-[hash][extname]' + } + } + } +}) diff --git a/apps/quicklook/vitest.config.ts b/apps/quicklook/vitest.config.ts new file mode 100644 index 00000000..13a148ef --- /dev/null +++ b/apps/quicklook/vitest.config.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: [ + { find: '@renderer', replacement: resolve(__dirname, '../../packages/app-core/src') }, + { find: '@shared', replacement: resolve(__dirname, '../../packages/shared-domain/src') }, + { find: '@bridge-contract', replacement: resolve(__dirname, '../../packages/bridge-contract/src') } + ] + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'] + } +}) diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 32b1f258..04f765a4 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.61.0", + "version": "2.62.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index b618314c..84762f46 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.61.0", + "version": "2.62.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index a132642d..12f3d8d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.61.0", + "version": "2.62.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.61.0", + "version": "2.62.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.61.0", + "version": "2.62.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -872,9 +872,82 @@ "node": ">=18" } }, + "apps/quicklook": { + "name": "@zennotes/quicklook", + "version": "2.62.0", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "smol-toml": "^1.7.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.4.3", + "vitest": "^3.2.6" + } + }, + "apps/quicklook/node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "apps/quicklook/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.61.0", + "version": "2.62.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +1018,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.61.0", + "version": "2.62.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -2670,66 +2743,12 @@ "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", "license": "MIT" }, - "node_modules/@excalidraw/excalidraw/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@excalidraw/excalidraw/node_modules/immutable": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", - "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", - "license": "MIT" - }, "node_modules/@excalidraw/excalidraw/node_modules/points-on-curve": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", "license": "MIT" }, - "node_modules/@excalidraw/excalidraw/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/@excalidraw/excalidraw/node_modules/roughjs": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", @@ -2748,23 +2767,6 @@ "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", "license": "MIT" }, - "node_modules/@excalidraw/excalidraw/node_modules/sass": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", - "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", - "license": "MIT", - "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@excalidraw/laser-pointer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", @@ -3781,11 +3783,9 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", @@ -3821,13 +3821,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3843,13 +3841,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3865,13 +3861,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3887,13 +3881,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3909,13 +3901,11 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3931,13 +3921,11 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3953,13 +3941,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3975,13 +3961,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3997,13 +3981,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4019,13 +4001,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4041,13 +4021,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4063,13 +4041,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4082,10 +4058,8 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=12" }, @@ -6150,6 +6124,10 @@ "resolved": "apps/desktop", "link": true }, + "node_modules/@zennotes/quicklook": { + "resolved": "apps/quicklook", + "link": true + }, "node_modules/@zennotes/share-viewer": { "resolved": "apps/share-viewer", "link": true @@ -6270,6 +6248,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -6563,6 +6542,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6663,6 +6643,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -8184,10 +8165,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -9136,6 +9115,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -9278,6 +9258,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -10165,10 +10146,7 @@ "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/inflight": { "version": "1.0.6", @@ -10238,6 +10216,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -10291,6 +10270,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10310,6 +10290,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -10322,6 +10303,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -12140,10 +12122,8 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-api-version": { "version": "0.2.1", @@ -12546,6 +12526,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12904,6 +12885,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -14047,10 +14029,7 @@ "version": "1.103.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.103.1.tgz", "integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", @@ -14070,10 +14049,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "readdirp": "^5.0.0" }, @@ -14088,10 +14064,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -15194,6 +15167,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -16382,7 +16356,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.61.0", + "version": "2.62.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16443,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.61.0", + "version": "2.62.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.61.0", + "version": "2.62.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16462,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.61.0" + "version": "2.62.0" } } } diff --git a/package.json b/package.json index a3513518..31c6ec73 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.61.0", + "version": "2.62.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { @@ -13,7 +13,8 @@ ], "overrides": { "@excalidraw/excalidraw": { - "nanoid": "3.3.18" + "nanoid": "3.3.18", + "sass": "1.103.1" }, "@excalidraw/mermaid-to-excalidraw": { "@mermaid-js/parser": "1.2.1", diff --git a/packages/app-core/README.md b/packages/app-core/README.md index bf6cf953..71a2476a 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -11,7 +11,7 @@ implementation details. - `@zennotes/app-core/navigation`: note navigation and Home behavior for shells. - `@zennotes/app-core/notes`: prompted note moves and renames with host-session and save guards. - `@zennotes/app-core/shell`: immutable note metadata, shell observations, and mobile Browse ordering. -- `@zennotes/app-core/browse`: folder/database rows, date-directory settings, and confirmed folder actions for native drawers. +- `@zennotes/app-core/browse`: folder, database, and file rows, date-directory settings, and confirmed folder actions for native drawers. - `@zennotes/app-core/editor`: run formatting/search commands, inspect selection, configure native typing/insets, and import attachments without accessing CodeMirror or the store. @@ -187,26 +187,36 @@ const rows = getBrowseDirectory(snapshot, directory, { folders: pinnedFolderDirectories }) // A folder row's directory becomes the next local drawer location. -// Note and database row paths are navigation targets: +// Note, database, and file row paths are navigation targets: await openNote(rows.databases[0].path) +await openNote(rows.files[0].path) ``` The directory argument and folder pins are relative to the primary notes area. The empty string means its root. Results contain separate `folders`, `databases`, -and `notes` arrays. Folders sort by title with pinned folders first; databases +`notes`, and `files` arrays. Folders sort by title with pinned folders first; databases sort by title without pin partitioning; notes use the shared Browse ordering. Empty folders remain visible. Database internals under `.base` never become ordinary drawer rows, including nested `pages/` directories. -Database paths are opaque app-generated navigation targets. Pass them to +File rows are the attachments and other non-note files the desktop sidebar lists +in the same folder: each carries its name with the extension, its kind (`image`, +`pdf`, `audio`, `video`, or `file`), and its last change. They follow the note sort, +with the created orders using the last change, since files carry no creation time. +Files in the root asset folders (`assets/`, `attachements/`, `_assets/`), in the +other system folders, under `.zennotes/`, or inside a database never become +Browse rows; the Assets view still lists every file. + +Database and file paths are opaque app-generated navigation targets. Pass them to `openNote`; do not construct their URLs or treat them as filesystem paths. App-core handles custom system-folder mappings and notes stored at the vault root. The snapshot exposes no mutable `FolderEntry` or `VaultSettings` objects. Subscriptions behave like `subscribeShell`: no initial notification, only public changes, coherent previous/next snapshots, and a returned disposer. Unchanged -folder and date data retain identity when notes change. Pins remain host-owned, -keyed by the native host's stable vault token. +folder, file, and date data retain identity when notes change, and file rows keep +it when the same files are read back. Pins remain host-owned, keyed by the native +host's stable vault token. ### Folder and database actions @@ -217,6 +227,10 @@ keyed by the native host's stable vault token. - `requestMoveBrowseDirectory(host, directory)` prompts for a new parent for an ordinary folder or an entire database, using the move-note prompt's `inbox[/path]` values. Only existing notes-area folders are offered, never the directory itself, its descendants, or a database. The leaf name, and so a database's `.base` suffix, is kept. A destination that already holds that name is blocked in the prompt, and the host still refuses to overwrite. The store carries open tabs (database tabs included), folder icons and colors, favorites, and manual order to the new path. Pins are host-owned: a host that pins folders re-keys them after `completed`. - `requestDeleteBrowseDirectory(host, directory)` confirms permanent deletion of an ordinary folder or an entire database, with the appropriate warning. +A completed rename, move, or delete re-reads the file index, because hosts +without a file watcher hear about no file the folder carried, and file rows would +stay under the old directory until the next resync. + All directories are relative to the primary notes area. Root deletion, missing rows, database internals, invalid names, and overlapping dialogs are rejected. The actions return `completed`, `cancelled`, `stale`, or `unavailable`; host I/O diff --git a/packages/app-core/package.json b/packages/app-core/package.json index ea4bb582..eba324be 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.61.0", + "version": "2.62.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 176f6625..e2f6ad02 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -8,6 +8,7 @@ import { initOverrides } from './store' import { resolveAuto, findTheme } from './lib/themes' +import { applyTypographyVariables } from './lib/typography-variables' import { injectActiveTheme, injectOverrides, @@ -35,10 +36,10 @@ import { eventMatchesUserOverride, isMacPlatform, matchesShortcut, - matchesSequenceToken, TAB_SELECT_KEYMAP_IDS } from './lib/keymaps' import { selectActiveBuffer } from './lib/buffer-navigation' +import { resolveCloseShortcut } from './lib/close-shortcut' import { focusPaneOrEdgePanel } from './lib/pane-nav' import { activatePanelRow, @@ -655,39 +656,22 @@ function App(): JSX.Element { }, [themeTweaks]) // Apply editor font size + line height + all three font families as - // CSS variables. Each family has its own fallback stack so leaving it - // unset gracefully uses the platform default. + // CSS variables (shared with the Quick Look preview). useEffect(() => { - const html = document.documentElement - html.style.setProperty('--z-editor-font-size', `${editorFontSize}px`) - html.style.setProperty('--z-math-scale', String(mathFontScale / 100)) - html.style.setProperty('--z-editor-line-height', String(editorLineHeight)) - html.style.setProperty('--z-preview-max-width', `${previewMaxWidth}px`) - html.style.setProperty('--z-editor-max-width', `${editorMaxWidth}px`) - html.dataset.contentAlign = contentAlign - html.dataset.completedTaskStyle = completedTaskStyle - html.dataset.mathRenderer = mathRenderer - html.dataset.lineNumberPosition = lineNumberPosition - - const setFont = (name: string, value: string | null, fallback: string): void => { - if (value) html.style.setProperty(name, `"${value}", ${fallback}`) - else html.style.removeProperty(name) - } - setFont( - '--z-interface-font', + applyTypographyVariables(document.documentElement, { + editorFontSize, + mathFontScale, + editorLineHeight, + previewMaxWidth, + editorMaxWidth, + contentAlign, + completedTaskStyle, + mathRenderer, + lineNumberPosition, interfaceFont, - '-apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, system-ui, sans-serif' - ) - setFont( - '--z-text-font', textFont, - '"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace' - ) - setFont( - '--z-mono-font', - monoFont, - '"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace' - ) + monoFont + }) }, [editorFontSize, mathFontScale, editorLineHeight, previewMaxWidth, editorMaxWidth, contentAlign, completedTaskStyle, mathRenderer, lineNumberPosition, interfaceFont, textFont, monoFont]) // Keep the markdown/preview pipeline pointed at the active math engine, even @@ -862,27 +846,17 @@ function App(): JSX.Element { void state.toggleRecentNote() return } - if (matchesShortcut(e, overrides, 'global.closeActiveTab')) { - // On Linux/Windows `Mod+W` (close tab) resolves to Ctrl+W, which is also - // the vim pane-focus prefix (`hjkl`) and insert-mode word delete. - // When vim mode is on AND a tab is open, reserve Ctrl+W for vim (close - // tabs via :q / :bd / the palette). With no tab open the prefix has - // nothing to act on, so fall through and close the window. On macOS - // close-tab is Cmd+W, so the vim guard never matches there. - const hasActiveTab = !!state.selectedPath - if ( - state.vimMode && - hasActiveTab && - matchesSequenceToken(e, overrides, 'vim.panePrefix') - ) { - return - } + const closeAction = resolveCloseShortcut(e, state) + if (closeAction) { + if (closeAction === 'vim') return e.preventDefault() - if (hasActiveTab) { + if (closeAction === 'close-settings') { + // The same hand-off as Settings' own Escape and Done (#415). + state.setSettingsOpen(false) + focusEditorNormalMode() + } else if (closeAction === 'close-tab') { void state.closeActiveNote() - } else { - // No tab left to close — close the window, matching native Cmd+W - // (macOS) / Ctrl+W behavior even with vim mode on (#192). + } else if (closeAction === 'close-window') { window.zen.windowClose() } return diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts index e59fefee..3488aaf7 100644 --- a/packages/app-core/src/browse-actions.test.ts +++ b/packages/app-core/src/browse-actions.test.ts @@ -21,6 +21,7 @@ async function setup() { const remove = vi.fn(async () => {}) const createDatabase = vi.fn(async () => undefined) const renameDatabase = vi.fn(async () => {}) + const refreshAssets = vi.fn(async () => {}) useStore.setState({ vault: { root: '/test', name: 'Test' }, folders: ['Work', 'Work/Nested', 'People.base'].map((subpath) => ({ @@ -32,7 +33,8 @@ async function setup() { renameFolder: rename, deleteFolder: remove, createDatabase, - renameDatabase + renameDatabase, + refreshAssets }) const host = { isCurrent: () => true } const answer = (value: string | null) => { @@ -55,6 +57,7 @@ async function setup() { remove, createDatabase, renameDatabase, + refreshAssets, host, answer, confirm @@ -179,6 +182,36 @@ describe('public Browse actions', () => { await other }) + // The phones have no file watcher, so nothing else reports the files a + // folder action moved or deleted, and Browse would keep listing them under + // the old directory until the next foreground. + it('re-reads the file index after a completed folder rename, move or delete, and only then', async () => { + const s = await setup() + const renamed = s.requestRenameBrowseFolder(s.host, 'Work/Nested') + s.answer('Renamed') + expect(await renamed).toBe('completed') + const moved = s.requestMoveBrowseDirectory(s.host, 'Work/Nested') + s.answer('inbox') + expect(await moved).toBe('completed') + const deleted = s.requestDeleteBrowseDirectory(s.host, 'Work') + s.confirm(true) + expect(await deleted).toBe('completed') + expect(s.refreshAssets).toHaveBeenCalledTimes(3) + + const cancelled = s.requestDeleteBrowseDirectory(s.host, 'Work') + s.confirm(false) + expect(await cancelled).toBe('cancelled') + let current = true + const stale = s.requestRenameBrowseFolder({ isCurrent: () => current }, 'Work') + current = false + s.answer('Elsewhere') + expect(await stale).toBe('stale') + const created = s.requestCreateBrowseFolder(s.host, 'Work') + s.answer('Research') + expect(await created).toBe('completed') + expect(s.refreshAssets).toHaveBeenCalledTimes(3) + }) + it('rejects host errors and releases the pending action', async () => { const s = await setup() s.create.mockRejectedValueOnce(new Error('Read-only vault')) diff --git a/packages/app-core/src/browse.test.ts b/packages/app-core/src/browse.test.ts index 073a5973..5a0ed095 100644 --- a/packages/app-core/src/browse.test.ts +++ b/packages/app-core/src/browse.test.ts @@ -1,14 +1,28 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderEntry, NoteMeta } from '@bridge-contract/ipc' +import type { AssetMeta, FolderEntry, ImportedAssetKind, NoteMeta } from '@bridge-contract/ipc' import { databaseTabPath } from '@shared/databases' +import { assetTabPath } from './lib/asset-tabs' +import type { NoteSortOrder } from './shell' const disposers: Array<() => void> = [] const folder = ( subpath: string, kind: FolderEntry['folder'] = 'inbox' ): FolderEntry => ({ folder: kind, subpath, siblingOrder: 0 }) +const asset = ( + path: string, + kind: ImportedAssetKind = 'file', + updatedAt = 0 +): AssetMeta => ({ + path, + name: path.split('/').pop()!, + kind, + siblingOrder: 0, + size: 1, + updatedAt +}) const note = (path: string): NoteMeta => ({ path, title: path.split('/').pop()!.replace(/\.md$/, ''), @@ -68,6 +82,7 @@ describe('public Browse model', () => { snapshot, snapshot.folders, snapshot.databases, + snapshot.files, snapshot.folders[0], snapshot.databases[0], snapshot.dateDirectories @@ -100,7 +115,8 @@ describe('public Browse model', () => { expect(s.getBrowseDirectory(s.getBrowseSnapshot(), 'Empty')).toEqual({ folders: [], databases: [], - notes: [] + notes: [], + files: [] }) }) @@ -126,7 +142,7 @@ describe('public Browse model', () => { ]) expect(rows.databases.map((row) => row.title)).toEqual(['Accounts', 'Zoo']) expect(rows.notes.map((row) => row.title)).toEqual(['C', 'A', 'B']) - for (const value of [rows, rows.folders, rows.databases, rows.notes]) + for (const value of [rows, rows.folders, rows.databases, rows.notes, rows.files]) expect(Object.isFrozen(value)).toBe(true) }) @@ -138,8 +154,12 @@ describe('public Browse model', () => { folder('People.BASE/Other.base'), folder('People.base-notes') ]) - s.useStore.setState({ notes: [note('inbox/People.BASE/pages/Hidden.md')] }) + s.useStore.setState({ + notes: [note('inbox/People.BASE/pages/Hidden.md')], + assetFiles: [asset('inbox/People.BASE/pages/photo.png', 'image')] + }) const snapshot = s.getBrowseSnapshot() + expect(snapshot.files).toEqual([]) expect(snapshot.folders.map((row) => row.directory)).toEqual([ 'People.base-notes' ]) @@ -154,7 +174,8 @@ describe('public Browse model', () => { expect(s.getBrowseDirectory(snapshot, directory)).toEqual({ folders: [], databases: [], - notes: [] + notes: [], + files: [] }) }) @@ -283,4 +304,116 @@ describe('public Browse model', () => { s.useStore.setState({ folders: [] }) expect(listener).toHaveBeenCalledTimes(1) }) + + // The desktop sidebar's primary tree takes its files the same way, so the + // root asset folders, the other system folders and `.zennotes` never reach + // a folder row, and the Files page stays the place to see all of them. + it.each(['inbox', 'root'] as const)( + 'places files in their primary-folder directory as the desktop sidebar does, in %s mode', + async (primaryNotesLocation) => { + const s = await setup([folder('Work')]) + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation, + systemFolderPaths: { inbox: '01 - Notes', archive: 'Old' } + } + }) + const prefix = primaryNotesLocation === 'root' ? '' : '01 - Notes/' + s.useStore.setState({ + assetFiles: [ + asset(`${prefix}Work/Report 2024.pdf`, 'pdf', 5), + asset(`${prefix}Scan.png`, 'image', 3), + asset('assets/Pasted image.png', 'image'), + asset('attachements/Legacy.png', 'image'), + asset('Old/Saved.pdf', 'pdf'), + asset('trash/Gone.pdf', 'pdf'), + asset('.zennotes/export.zip') + ] + }) + const snapshot = s.getBrowseSnapshot() + expect(snapshot.files).toEqual([ + { + path: assetTabPath(`${prefix}Work/Report 2024.pdf`), + directory: 'Work', + name: 'Report 2024.pdf', + kind: 'pdf', + updatedAt: 5 + }, + { + path: assetTabPath(`${prefix}Scan.png`), + directory: '', + name: 'Scan.png', + kind: 'image', + updatedAt: 3 + } + ]) + expect(s.getBrowseDirectory(snapshot).files.map((row) => row.name)).toEqual([ + 'Scan.png' + ]) + expect(s.getBrowseDirectory(snapshot, 'Work').files.map((row) => row.name)).toEqual([ + 'Report 2024.pdf' + ]) + } + ) + + // Files carry no creation time, so the created orders use their last + // change, as the desktop note list orders files. + it('orders files by the note sort: names naturally, otherwise most recent first', async () => { + const s = await setup() + s.useStore.setState({ + assetFiles: [ + asset('inbox/File 10.pdf', 'pdf', 30), + asset('inbox/File 2.pdf', 'pdf', 10), + asset('inbox/archive.zip', 'file', 20) + ] + }) + const names = (): string[] => + s.getBrowseDirectory(s.getBrowseSnapshot()).files.map((row) => row.name) + expect(names()).toEqual(['archive.zip', 'File 2.pdf', 'File 10.pdf']) + const recent = ['File 10.pdf', 'archive.zip', 'File 2.pdf'] + const oldest = ['File 2.pdf', 'archive.zip', 'File 10.pdf'] + const expected: Array<[NoteSortOrder, string[]]> = [ + ['name-desc', ['File 10.pdf', 'File 2.pdf', 'archive.zip']], + ['updated-desc', recent], + ['updated-asc', oldest], + ['created-desc', recent], + ['created-asc', oldest], + ['none', recent], + ['manual', recent] + ] + for (const [noteSortOrder, rows] of expected) { + s.useStore.setState({ noteSortOrder }) + expect(names()).toEqual(rows) + } + }) + + it('freezes file rows and keeps their identity until the files or the folder layout change', async () => { + const s = await setup() + s.useStore.setState({ assetFiles: [asset('inbox/Scan.png', 'image')] }) + const before = s.getBrowseSnapshot() + expect(before.files[0]).not.toBe(s.useStore.getState().assetFiles[0]) + for (const value of [before.files, before.files[0]]) + expect(Object.isFrozen(value)).toBe(true) + const listener = vi.fn() + disposers.push(s.subscribeBrowse(listener)) + // Every non-note change re-reads the whole file index; reading the same + // files back must not wake the drawer. + s.useStore.setState({ assetFiles: [asset('inbox/Scan.png', 'image')] }) + expect(s.getBrowseSnapshot()).toBe(before) + expect(listener).not.toHaveBeenCalled() + s.useStore.setState({ + assetFiles: [asset('inbox/Scan.png', 'image'), asset('inbox/Work/Report.pdf', 'pdf')] + }) + expect(listener).toHaveBeenCalledOnce() + expect(s.getBrowseSnapshot().notes).toBe(before.notes) + s.useStore.setState({ + vaultSettings: { ...s.useStore.getState().vaultSettings, primaryNotesLocation: 'root' } + }) + expect(s.getBrowseSnapshot().files.map((row) => row.directory)).toEqual([ + 'inbox', + 'inbox/Work' + ]) + expect(before.files[0].directory).toBe('') + }) }) diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts index 6af0e1f6..70c7a5ba 100644 --- a/packages/app-core/src/browse.ts +++ b/packages/app-core/src/browse.ts @@ -1,5 +1,10 @@ import { useSyncExternalStore } from 'react' -import type { FolderEntry } from '@bridge-contract/ipc' +import type { + AssetMeta, + FolderEntry, + ImportedAssetKind, + VaultSettings +} from '@bridge-contract/ipc' import { csvPathForFormDir, databaseTabPath, @@ -17,6 +22,9 @@ import { type ShellSnapshot } from './shell' import { parentDirOf } from './lib/manual-order' +import { browseNoteComparator } from './lib/note-order' +import { assetBelongsToFolderView, assetFolderSubpath } from './lib/vault-layout' +import { assetTabPath } from './lib/asset-tabs' export interface BrowseFolder { /** Relative to the primary notes area, not the vault root. */ @@ -29,12 +37,25 @@ export interface BrowseDatabase extends BrowseFolder { readonly path: string } +/** A file other than a note or drawing, as the desktop sidebar lists it in its folder. */ +export interface BrowseFile { + /** Opaque application path; pass to the public navigation openNote action. */ + readonly path: string + /** The folder holding the file, relative to the primary notes area; empty at its root. */ + readonly directory: string + /** The file name with its extension. */ + readonly name: string + readonly kind: ImportedAssetKind + readonly updatedAt: number +} + export interface BrowseSnapshot { /** Display/change metadata. Native persistence uses the host's stable vault token. */ readonly vault: ShellSnapshot['vault'] readonly folders: readonly BrowseFolder[] readonly databases: readonly BrowseDatabase[] readonly notes: readonly ShellNote[] + readonly files: readonly BrowseFile[] readonly noteSortOrder: NoteSortOrder /** Enabled directory settings, unchanged; null when disabled. Patterns are not expanded. */ readonly dateDirectories: Readonly<{ @@ -53,12 +74,16 @@ export interface BrowseDirectory { readonly folders: readonly BrowseFolder[] readonly databases: readonly BrowseDatabase[] readonly notes: readonly ShellNote[] + readonly files: readonly BrowseFile[] } let folderSource: readonly FolderEntry[] | undefined let primaryDirectory = '' let folders: readonly BrowseFolder[] = Object.freeze([]) let databases: readonly BrowseDatabase[] = Object.freeze([]) +let fileSource: readonly AssetMeta[] | undefined +let fileLayout = '' +let files: readonly BrowseFile[] = Object.freeze([]) let dates: BrowseSnapshot['dateDirectories'] = Object.freeze({ daily: null, weekly: null, @@ -100,6 +125,23 @@ export function getBrowseSnapshot(): BrowseSnapshot { folders = Object.freeze([...folderRows.values()]) databases = Object.freeze([...databaseRows.values()]) } + // Every system folder's directory decides where a file belongs, not only + // the primary one: a file under a remapped archive is the archive's. + const layout = JSON.stringify([ + settings.primaryNotesLocation, + ...(['inbox', 'quick', 'archive', 'trash'] as const).map((folder) => + resolveFolderPath(folder, settings.systemFolderPaths) + ) + ]) + if (fileSource !== state.assetFiles || fileLayout !== layout) { + fileSource = state.assetFiles + fileLayout = layout + const rows = browseFiles(state.assetFiles, settings) + // Any non-note change re-reads the whole file index into a new array. + // The same files read back keep the delivered rows, so subscribers are + // not woken for nothing. + if (!sameFiles(rows, files)) files = Object.freeze(rows) + } const daily = settings.dailyNotes.enabled ? settings.dailyNotes.directory : null const weekly = settings.weeklyNotes.enabled ? settings.weeklyNotes.directory : null const monthly = settings.monthlyNotes.enabled ? settings.monthlyNotes.directory : null @@ -111,6 +153,7 @@ export function getBrowseSnapshot(): BrowseSnapshot { noteSortOrder: shell.noteSortOrder, folders, databases, + files, dateDirectories: dates } if ( @@ -143,7 +186,59 @@ export function useBrowseSnapshot(): BrowseSnapshot { return useSyncExternalStore(subscribeReact, getBrowseSnapshot, getBrowseSnapshot) } -/** Immediate mobile Browse rows, with separate folder, database, and note groups. */ +/** + * The desktop sidebar's files for the primary tree (its buildTree): the root + * asset folders, the other system folders and `.zennotes` never reach a + * folder, and a file inside a database stays out like the database's records. + */ +function browseFiles(assets: readonly AssetMeta[], settings: VaultSettings): BrowseFile[] { + const rows: BrowseFile[] = [] + for (const asset of assets) { + if (!assetBelongsToFolderView(asset, 'inbox', '', settings)) continue + const directory = assetFolderSubpath(asset, settings) + if (formDirContaining(directory)) continue + rows.push( + Object.freeze({ + path: assetTabPath(asset.path), + directory, + name: asset.name, + kind: asset.kind, + updatedAt: asset.updatedAt + }) + ) + } + return rows +} + +function sameFiles(a: readonly BrowseFile[], b: readonly BrowseFile[]): boolean { + return ( + a.length === b.length && + a.every((row, index) => { + const other = b[index] + return ( + row.path === other.path && + row.directory === other.directory && + row.name === other.name && + row.kind === other.kind && + row.updatedAt === other.updatedAt + ) + }) + ) +} + +/** Files follow the note sort. They carry no creation time, so the created + * orders use their last change, as the desktop note list orders files. */ +function browseFileComparator(order: NoteSortOrder): (a: BrowseFile, b: BrowseFile) => number { + const compare = browseNoteComparator(order) + const sortable = (row: BrowseFile) => ({ + title: row.name, + updatedAt: row.updatedAt, + createdAt: row.updatedAt + }) + return (a, b) => compare(sortable(a), sortable(b)) +} + +/** Immediate mobile Browse rows, with separate folder, database, note, and file groups. */ export function getBrowseDirectory( snapshot: BrowseSnapshot, directory = '', @@ -153,7 +248,8 @@ export function getBrowseDirectory( return Object.freeze({ folders: Object.freeze([]), databases: Object.freeze([]), - notes: Object.freeze([]) + notes: Object.freeze([]), + files: Object.freeze([]) }) const childFolders = snapshot.folders .filter((row) => parentDirOf(row.directory) === directory) @@ -169,7 +265,12 @@ export function getBrowseDirectory( .filter((row) => parentDirOf(row.directory) === directory) .sort((a, b) => a.title.localeCompare(b.title)) ), - notes: getBrowseNotes(snapshot, directory, pins.notes) + notes: getBrowseNotes(snapshot, directory, pins.notes), + files: Object.freeze( + snapshot.files + .filter((row) => row.directory === directory) + .sort(browseFileComparator(snapshot.noteSortOrder)) + ) }) } diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index 5e86b975..f9d3c104 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -896,6 +896,263 @@ describe("CloudSettings", () => { } }); + describe("opened for This vault, as Review on the Cloud status opens it", () => { + const linked = { + base_url: "https://zennotes.org", + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }; + const needsAttention: CloudSyncRunSummary = { + cursor: 7, + pulled: 0, + pushed: 0, + conflicts: [], + bootstrap_conflicts: [], + local_conflicts: [ + { + code: "LOCAL_EDIT_CONFLICT", + path: "inbox/Plan.md", + conflict_copy_path: "inbox/Plan (cloud conflict).md", + }, + ], + }; + let scrolled: Element[]; + let originalScrollIntoView: PropertyDescriptor | undefined; + + beforeEach(() => { + scrolled = []; + originalScrollIntoView = Object.getOwnPropertyDescriptor( + Element.prototype, + "scrollIntoView", + ); + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value(this: Element) { + scrolled.push(this); + }, + }); + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue(linked); + useCloudSyncStatusStore.setState({ + phase: "attention", + vaultName: "Cloud Notes", + lastSummary: needsAttention, + error: "Cloud sync kept both versions of 1 changed file. Review the conflict copies.", + }); + }); + + afterEach(() => { + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, "scrollIntoView", originalScrollIntoView); + } else { + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; + } + (document.activeElement as HTMLElement | null)?.blur(); + }); + + async function open(props: { + revealVault?: boolean; + onVaultRevealed?: () => void; + }): Promise { + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + ...props, + }), + ), + ); + } + + function vaultSection(): HTMLElement | null { + return host.querySelector('[data-settings-search-id="cloud-vault"]'); + } + + it("brings the files that need attention into view and hands them the keyboard", async () => { + const onVaultRevealed = vi.fn(); + await open({ revealVault: true, onVaultRevealed }); + + const files = host.querySelector('[aria-label="Files that need attention"]'); + const attention = files?.closest("[data-cloud-attention]"); + expect(attention).toBeTruthy(); + expect(vaultSection()?.contains(attention!)).toBe(true); + expect(document.activeElement).toBe(attention); + // This vault's top first, for the vault it is about, then the files. + expect(scrolled).toEqual([vaultSection(), attention]); + expect(onVaultRevealed).toHaveBeenCalledOnce(); + }); + + it("lands on the notice when this vault's Cloud vault went away", async () => { + useCloudSyncStatusStore.setState({ + phase: "unlinked", + lastSummary: null, + error: null, + removedVault: { vaultName: "Cloud QA iPhone", reason: "deleted" }, + }); + mocks.getCloudVaultLink.mockResolvedValue(null); + await open({ revealVault: true }); + + const notice = host.querySelector("[data-cloud-vault-removed]"); + expect(notice?.hasAttribute("data-cloud-attention")).toBe(true); + expect(document.activeElement).toBe(notice); + }); + + it("hands This vault itself the keyboard when nothing in it waits, as Set up asks", async () => { + useCloudSyncStatusStore.setState({ phase: "unlinked", lastSummary: null, error: null }); + mocks.getCloudVaultLink.mockResolvedValue(null); + await open({ revealVault: true }); + + expect(host.textContent).toContain("Create a new cloud vault"); + expect(document.activeElement).toBe(vaultSection()); + expect(scrolled).toEqual([vaultSection()]); + }); + + it("leaves the keyboard where the person moved it while the page loaded", async () => { + const elsewhere = document.createElement("input"); + document.body.append(elsewhere); + try { + elsewhere.focus(); + const onVaultRevealed = vi.fn(); + await open({ revealVault: true, onVaultRevealed }); + + expect(document.activeElement).toBe(elsewhere); + expect(scrolled).toEqual([]); + // Asked and answered: coming back to the page does not try again. + expect(onVaultRevealed).toHaveBeenCalledOnce(); + } finally { + elsewhere.remove(); + } + }); + + it("stays at the top of the page when nothing asked for This vault", async () => { + await open({}); + + expect(host.querySelector("[data-cloud-attention]")).not.toBeNull(); + expect(document.activeElement).toBe(document.body); + expect(scrolled).toEqual([]); + }); + }); + + describe("the storage card while Settings stays open", () => { + const linked = { + base_url: "https://zennotes.org", + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }; + const uploaded: CloudSyncRunSummary = { + cursor: 9, + pulled: 0, + pushed: 3, + conflicts: [], + bootstrap_conflicts: [], + local_conflicts: [], + }; + const grown: CloudServiceAccount = { + ...serviceAccount, + usage: { + ...serviceAccount.usage!, + sync: { ...serviceAccount.usage!.sync, items: 41 }, + }, + }; + let visibility: DocumentVisibilityState; + + function show(state: DocumentVisibilityState): void { + visibility = state; + document.dispatchEvent(new Event("visibilitychange")); + } + + /** Lets a pause pass, and whatever it set off land. */ + async function wait(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); + } + + beforeEach(async () => { + visibility = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibility, + }); + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue(linked); + mocks.getCloudSettingsConflict.mockResolvedValue(null); + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + expect(host.textContent).toContain("38 synced files across 2 vaults"); + vi.useFakeTimers(); + mocks.getCloudServiceAccount.mockClear(); + mocks.getCloudServiceAccount.mockResolvedValue(grown); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (document as { visibilityState?: unknown }).visibilityState; + }); + + it("reads usage again once sync runs settle, once for a burst of runs", async () => { + await act(async () => useCloudSyncStatusStore.setState({ lastSummary: uploaded })); + await wait(2_000); + await act(async () => + useCloudSyncStatusStore.setState({ lastSummary: { ...uploaded, cursor: 10 } }), + ); + await wait(2_000); + expect(mocks.getCloudServiceAccount).not.toHaveBeenCalled(); + + await wait(2_000); + expect(mocks.getCloudServiceAccount).toHaveBeenCalledOnce(); + expect(host.textContent).toContain("41 synced files across 2 vaults"); + + await wait(30_000); + expect(mocks.getCloudServiceAccount).toHaveBeenCalledOnce(); + }); + + it("leaves usage alone after a run that moved nothing", async () => { + await act(async () => + useCloudSyncStatusStore.setState({ lastSummary: { ...uploaded, pushed: 0 } }), + ); + await wait(30_000); + + expect(mocks.getCloudServiceAccount).not.toHaveBeenCalled(); + expect(host.textContent).toContain("38 synced files across 2 vaults"); + }); + + it("reads nothing while the window is hidden, and reads usage once it is shown", async () => { + show("hidden"); + await act(async () => useCloudSyncStatusStore.setState({ lastSummary: uploaded })); + await wait(30_000); + expect(mocks.getCloudServiceAccount).not.toHaveBeenCalled(); + + show("visible"); + await wait(5_000); + expect(mocks.getCloudServiceAccount).toHaveBeenCalledOnce(); + expect(host.textContent).toContain("41 synced files across 2 vaults"); + }); + + it("does not read usage a second time after Sync now has read it", async () => { + mocks.syncCloudVault.mockResolvedValue(uploaded); + await act(async () => buttonNamed("Sync now")!.click()); + expect(mocks.getCloudServiceAccount).toHaveBeenCalledOnce(); + expect(host.textContent).toContain("41 synced files across 2 vaults"); + + await wait(30_000); + expect(mocks.getCloudServiceAccount).toHaveBeenCalledOnce(); + }); + }); + it("refreshes Settings when follow-up sync clears the next conflict after a saved decision", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); @@ -1843,10 +2100,16 @@ describe("CloudSettings", () => { await act(async () => restore!.click()); expect(mocks.confirmApp).toHaveBeenCalledWith( - expect.objectContaining({ danger: true }), + expect.objectContaining({ + title: "Restore “Before migration”?", + danger: true, + }), ); expect(mocks.restoreCloudBackup).toHaveBeenCalledWith("backup-1"); - expect(host.textContent).toContain("Restored 8 items"); + expect(host.textContent).toContain( + "Restored 8 items and removed 2 newer items. This vault now matches the backup.", + ); + expect(host.textContent).not.toContain("cursor"); const browse = [...host.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "Browse notes", @@ -1961,6 +2224,279 @@ describe("CloudSettings", () => { expect(host.textContent).toContain("Before migration"); }); + describe("restoring a whole backup", () => { + async function restore( + label: string | null, + counts: { restored_items: number; deleted_items: number }, + sync: Partial = {}, + ): Promise { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue({ + ...serviceAccount, + features: { + ...serviceAccount.features, + backup: { active: true, limits: { max_snapshots: 30 } }, + }, + }); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + mocks.listCloudBackups.mockResolvedValue([ + { + id: "backup-1", + label, + trigger: "manual", + status: "ready", + cursor: 12, + item_count: 8, + total_bytes: 2048, + archive_bytes: 1024, + expires_at: "2026-09-09T12:00:00.000Z", + created_at: "2026-08-10T12:00:00.000Z", + }, + ]); + mocks.restoreCloudBackup.mockResolvedValue({ + restore: { + id: "restore-1", + backup_id: "backup-1", + mode: "replace", + status: "completed", + expected_cursor: 12, + start_cursor: 12, + end_cursor: 18, + ...counts, + error: null, + created_at: "2026-08-10T12:06:00.000Z", + updated_at: "2026-08-10T12:06:01.000Z", + }, + sync: { + cursor: 18, + pulled: 1, + pushed: 0, + conflicts: [], + bootstrap_conflicts: [], + local_conflicts: [], + ...sync, + }, + }); + + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + const button = [...host.querySelectorAll("button")].find( + (candidate) => + candidate.textContent?.trim() === "Restore" && !candidate.disabled, + ); + await act(async () => button!.click()); + } + + function restoreResult(): string { + return ( + host.querySelector('[data-settings-search-id="cloud-backups"] [role="status"]') + ?.textContent ?? "" + ); + } + + it("quotes the label, so a label that starts with Restore reads once", async () => { + await restore("Restore QA baseline", { + restored_items: 8, + deleted_items: 2, + }); + + expect(mocks.confirmApp).toHaveBeenCalledWith( + expect.objectContaining({ title: "Restore “Restore QA baseline”?" }), + ); + }); + + it("asks to restore this backup when it has no label", async () => { + await restore(null, { restored_items: 8, deleted_items: 2 }); + + expect(mocks.confirmApp).toHaveBeenCalledWith( + expect.objectContaining({ title: "Restore this backup?" }), + ); + }); + + it("counts one restored and one removed item in the singular", async () => { + await restore("Before migration", { + restored_items: 1, + deleted_items: 1, + }); + + expect(restoreResult()).toBe( + "Restored 1 item and removed 1 newer item. This vault now matches the backup.", + ); + }); + + it("leaves out the removed clause when nothing newer was removed", async () => { + await restore("Before migration", { + restored_items: 3, + deleted_items: 0, + }); + + expect(restoreResult()).toBe( + "Restored 3 items. This vault now matches the backup.", + ); + expect(host.textContent).not.toContain("newer item"); + }); + + it("claims no match while the sync after it left a file waiting, which This vault lists", async () => { + await restore( + "Before migration", + { restored_items: 3, deleted_items: 0 }, + { + pending_conflicts: [ + { + id: "Plan.md", + item_id: "Plan.md", + path: "Plan.md", + cloud_path: "Plan.md", + kind: "content", + can_merge: true, + has_base: true, + }, + ], + }, + ); + + expect(restoreResult()).toBe("Restored 3 items."); + expect(host.textContent).not.toContain("cursor"); + expect( + host.querySelector('[data-settings-search-id="cloud-vault"]')?.textContent, + ).toContain("Plan.md"); + }); + }); + + describe("a failed automatic backup", () => { + const occurredAt = "2026-10-04T03:00:00.000Z"; + const onDate = new Date(occurredAt).toLocaleDateString(); + + async function openWithSchedule(schedule: Record): Promise { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue({ + ...serviceAccount, + features: { + ...serviceAccount.features, + backup: { active: true, limits: { max_snapshots: 30 } }, + }, + }); + mocks.listCloudVaults.mockResolvedValue([]); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + mocks.listCloudBackups.mockResolvedValue([]); + // The shape the service's backup-schedule endpoint answers with. + mocks.getCloudBackupSchedule.mockResolvedValue({ + enabled: true, + frequency: "daily", + next_backup_at: "2026-10-05T03:00:00.000Z", + last_backup_at: "2026-10-02T03:00:00.000Z", + ...schedule, + }); + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + } + + function failureNotice(): HTMLElement | null { + return host.querySelector("[data-cloud-backup-failure]"); + } + + it("says which plan limit skipped it, and when", async () => { + await openWithSchedule({ + last_failure: { + code: "BACKUP_QUOTA_EXCEEDED", + message: + "The latest automatic backup was skipped because this vault has 30 retained backups, but its limit is 30. Delete an older manual backup or contact support to increase the limit. ZenNotes will retry daily.", + details: { limit: "max_snapshots", current: 30, allowed: 30 }, + occurred_at: occurredAt, + }, + }); + + expect(failureNotice()?.textContent).toBe( + `The automatic backup on ${onDate} was skipped. This vault already keeps 30 backups, the most your plan allows. Delete an older backup to make room.`, + ); + // Beside the switch it is about. + expect( + failureNotice()?.closest("[data-cloud-automatic-backups]")?.querySelector('[role="switch"]'), + ).not.toBeNull(); + }); + + it("words a limit this version cannot read without inventing numbers", async () => { + await openWithSchedule({ + last_failure: { + code: "BACKUP_QUOTA_EXCEEDED", + message: null, + details: { limit: "max_snapshot_widgets", current: 3, allowed: 2 }, + occurred_at: occurredAt, + }, + }); + + expect(failureNotice()?.textContent).toBe( + `The automatic backup on ${onDate} was skipped because this vault reached a backup limit on your plan.`, + ); + }); + + it("gives any other failure a short sentence with its date", async () => { + await openWithSchedule({ + last_failure: { + code: "AUTOMATIC_BACKUP_FAILED", + message: + "The latest automatic backup failed after several attempts. ZenNotes will retry automatically. Contact support if the problem continues.", + details: { message: "The automatic backup could not be created after several attempts." }, + occurred_at: occurredAt, + }, + }); + + expect(failureNotice()?.textContent).toBe( + `The automatic backup on ${onDate} failed. ZenNotes will try again.`, + ); + }); + + it("says nothing once the last one succeeded, or for a service that never reports it", async () => { + await openWithSchedule({ last_failure: null }); + expect(host.textContent).toContain("Automatic daily backups"); + expect(failureNotice()).toBeNull(); + + await act(async () => root.render(null)); + await openWithSchedule({}); + expect(host.textContent).toContain("Automatic daily backups"); + expect(failureNotice()).toBeNull(); + }); + + it("says nothing about a schedule that is turned off", async () => { + await openWithSchedule({ + enabled: false, + next_backup_at: null, + last_failure: { + code: "AUTOMATIC_BACKUP_FAILED", + message: null, + details: null, + occurred_at: occurredAt, + }, + }); + + expect(host.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false"); + expect(failureNotice()).toBeNull(); + }); + }); + describe("browsing a backup's notes", () => { // 110 journal days, then 10 launch notes that the first page never holds. const backupNotes = [ diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 68c9e761..b8aef625 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -5,6 +5,7 @@ import type { CloudBackupNoteRestoreResult, CloudBackupRestoreResult, CloudBackupSchedule, + CloudBackupScheduleFailure, CloudBackupSnapshot, CloudBackupSnapshotItem, CloudPublishedNote, @@ -17,6 +18,7 @@ import type { CloudVaultLink, } from "@zennotes/bridge-contract/cloud-sync"; import { getZenBridge, type ZenBridge } from "@zennotes/bridge-contract/bridge"; +import { cloudBackupLimitMessage } from "@zennotes/shared-domain/cloud-backup"; import { confirmApp } from "../lib/confirm-requests"; import { clearRemovedCloudVault, @@ -84,12 +86,26 @@ const VAULT_SECTION_ACTIONS: ReadonlySet = new Set([ "settings-cloud", ]); +/** How long usage waits after a sync run that moved files before it is read + * again, so a burst of runs costs one read. */ +const USAGE_READ_DELAY_MS = 3_000; + export function CloudSettings({ localVaultAvailable, localVaultName, + revealVault = false, + onVaultRevealed, }: { localVaultAvailable: boolean; localVaultName: string; + /** + * Settings was opened for This vault: Review or Set up on the Cloud status, + * Space r, the palette. Once the page has loaded, This vault comes into view + * and what in it waits on the person takes the keyboard. + */ + revealVault?: boolean; + /** The request was answered, whether or not anything moved. */ + onVaultRevealed?: () => void; }): JSX.Element { const [bridge] = useState(() => getZenBridge()); const localNotes = useStore((state) => state.notes); @@ -290,14 +306,69 @@ export function CloudSettings({ }; }, [bridge, loadStatus]); + const usageRead = useRef(null); + const usageReadWhenShown = useRef(false); + const refreshServiceAccount = useCallback(async (): Promise => { + // This read answers any that a sync run left waiting. + if (usageRead.current !== null) window.clearTimeout(usageRead.current); + usageRead.current = null; + usageReadWhenShown.current = false; + // A sign-in or sign-out that starts meanwhile loads its own account, and + // this answer belongs to the one before it. + const generation = loadGeneration.current; try { - setServiceAccount(await bridge.getCloudServiceAccount()); + const account = await bridge.getCloudServiceAccount(); + if (generation === loadGeneration.current) setServiceAccount(account); } catch { // Usage is supplementary; the next account refresh will retry it. } }, [bridge]); + // The storage card read usage when the page opened, and the page's own + // actions read it again, but most sync runs are not the page's: automatic + // ones, and another window's. A run that moved files asks for a fresh read + // once runs settle, since a burst of edits syncs every few seconds and + // needs one read, not one per run. A hidden window reads nothing; the read + // waits until it is shown again. + useEffect(() => { + const readLater = (): void => { + if (usageRead.current !== null) window.clearTimeout(usageRead.current); + usageRead.current = window.setTimeout(() => { + usageRead.current = null; + if (document.visibilityState === "hidden") { + usageReadWhenShown.current = true; + } else { + void refreshServiceAccount(); + } + }, USAGE_READ_DELAY_MS); + }; + const unsubscribe = useCloudSyncStatusStore.subscribe((next, previous) => { + const summary = next.lastSummary; + if ( + summary !== null && + summary !== previous.lastSummary && + (summary.pulled > 0 || summary.pushed > 0) + ) { + readLater(); + } + }); + const onVisibilityChange = (): void => { + if (document.visibilityState === "hidden" || !usageReadWhenShown.current) { + return; + } + usageReadWhenShown.current = false; + readLater(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + unsubscribe(); + document.removeEventListener("visibilitychange", onVisibilityChange); + if (usageRead.current !== null) window.clearTimeout(usageRead.current); + usageRead.current = null; + }; + }, [refreshServiceAccount]); + const backupIncluded = serviceAccount?.features.backup.active === true; const publishIncluded = serviceAccount?.features.publish.active === true; const connectedBaseUrl = @@ -622,7 +693,11 @@ export function CloudSettings({ const restoreBackup = async (backup: CloudBackupSnapshot): Promise => { const confirmed = await confirmApp({ - title: `Restore ${backup.label || "this backup"}?`, + // Quoted: a label is the user's own words, and "Restore QA baseline" + // read as "Restore Restore QA baseline?". + title: backup.label + ? `Restore “${backup.label}”?` + : "Restore this backup?", description: "ZenNotes will replace the linked cloud vault with this snapshot, then sync the restored contents to this device. Changes made after the backup may be removed.", confirmLabel: "Restore backup", @@ -660,12 +735,31 @@ export function CloudSettings({ }); }; + // This vault is final once the account and the link have both been read. + // Before the link arrives it can show the form for an unlinked vault, which + // is not what the person came to see. + const vaultSettled = + status !== null && + (status.state !== "connected" || + (serviceAccount !== null && !loadingDetails)); + const page = useRef(null); + const vaultRevealed = useRef(false); + useEffect(() => { + if (!revealVault || !vaultSettled || vaultRevealed.current) return; + vaultRevealed.current = true; + onVaultRevealed?.(); + const vault = page.current?.querySelector( + '[data-settings-search-id="cloud-vault"]', + ); + if (vault) revealCloudVault(vault); + }, [onVaultRevealed, revealVault, vaultSettled]); + if (status === null) { return ; } return ( -
+
{error && (

)} {syncFailed && !syncing && ( -
+
{syncError ?? "Sync failed. Please try again."}
)} @@ -1758,6 +1858,12 @@ function CloudBackupPanel({ ); } + // Turning automatic backups off clears the service's record, so a failure + // only speaks for a schedule that is still on. + const automaticFailure = + schedule?.enabled && schedule.last_failure + ? automaticBackupFailureMessage(schedule.last_failure) + : null; const maxSnapshots = numericLimit(limits, "max_snapshots"); const maxSnapshotBytes = numericLimit(limits, "max_snapshot_bytes"); const retentionDays = numericLimit(limits, "retention_days"); @@ -1801,34 +1907,48 @@ function CloudBackupPanel({ {restoreResult && }
-
-
-
- Automatic daily backups +
+
+
+
+ Automatic daily backups +
+

+ Creates a backup each day when this vault changed. +

-

- Creates a backup each day when this vault changed. -

-
- + > + + +
+ {automaticFailure && ( +

+ {automaticFailure} +

+ )}
@@ -2181,9 +2301,8 @@ function CloudRestoreResult({ role="status" className="rounded-xl border border-accent/25 bg-accent/5 px-4 py-3 text-sm text-ink-700" > - Restored {result.restore.restored_items} items and removed{" "} - {result.restore.deleted_items} newer items. This vault is synced to - cursor {result.sync?.cursor ?? result.restore.end_cursor}. + {restoredItemsSummary(result.restore)} + {restoredVaultMatches(result) && " This vault now matches the backup."}
); } @@ -2202,6 +2321,45 @@ function CloudRestoreResult({ ); } +function restoredItemsSummary( + restore: CloudBackupRestoreResult["restore"], +): string { + const restored = `Restored ${pluralize(restore.restored_items, "item")}`; + return restore.deleted_items > 0 + ? `${restored} and removed ${pluralize(restore.deleted_items, "newer item")}.` + : `${restored}.`; +} + +/** + * The sync that follows a restore brings this vault in line with the backup, + * unless it left a file waiting on the person, which This vault lists. + */ +function restoredVaultMatches(result: CloudBackupRestoreResult): boolean { + return result.sync !== null && cloudSyncAttentionMessage(result.sync) === null; +} + +/** + * Why the newest automatic backup is missing, from what the service recorded. + * A plan limit is worded from its numbers exactly as a refused manual backup + * is, so one limit never reads two ways. + */ +function automaticBackupFailureMessage( + failure: CloudBackupScheduleFailure, +): string { + const date = failure.occurred_at ? new Date(failure.occurred_at) : null; + const backup = + date !== null && Number.isFinite(date.getTime()) + ? `The automatic backup on ${date.toLocaleDateString()}` + : "The latest automatic backup"; + if (failure.code !== "BACKUP_QUOTA_EXCEEDED") { + return `${backup} failed. ZenNotes will try again.`; + } + const limit = cloudBackupLimitMessage(failure); + return limit === null + ? `${backup} was skipped because this vault reached a backup limit on your plan.` + : `${backup} was skipped. ${limit}`; +} + function formatBackupDate(value: string): string { const date = new Date(value); return Number.isFinite(date.getTime()) ? date.toLocaleString() : value; @@ -2328,7 +2486,9 @@ function CloudSettingsConflictCard({
Vault settings differ from the cloud
@@ -2407,10 +2567,12 @@ function CloudSyncSummary({ return (
0 ? "" : undefined} + tabIndex={-1} className={ attention - ? "rounded-xl border border-warning/35 bg-warning/10 px-4 py-3 text-sm text-ink-700" - : "rounded-xl border border-accent/25 bg-accent/5 px-4 py-3 text-sm text-ink-700" + ? "rounded-xl border border-warning/35 bg-warning/10 px-4 py-3 text-sm text-ink-700 outline-none" + : "rounded-xl border border-accent/25 bg-accent/5 px-4 py-3 text-sm text-ink-700 outline-none" } > {showStatus && ( @@ -2547,10 +2709,33 @@ function CloudSyncSummary({ } /** - * Why this vault stopped syncing when nobody here unlinked it. Review on the - * status row opens the Cloud page at its top, and on a phone This vault sits - * a long scroll below the account and plan, so the notice brings itself into - * view. + * Bring This vault into view and hand the keyboard to what in it waits on the + * person (a vault that went away, the settings question, a failed run, the + * files that need attention), or to the section itself when nothing does. + * The section's top comes first so the vault it is about is on screen too. + * Only while the dialog still holds the focus it opened with: someone who + * moved on while the page loaded keeps their place. + */ +function revealCloudVault(vault: HTMLElement): void { + const holder = document.activeElement; + if ( + holder !== null && + holder !== document.body && + holder !== vault.closest('[role="dialog"]') + ) { + return; + } + const waiting = vault.querySelector("[data-cloud-attention]"); + vault.scrollIntoView?.({ block: "start" }); + waiting?.scrollIntoView?.({ block: "nearest" }); + (waiting ?? vault).focus({ preventScroll: true }); +} + +/** + * Why this vault stopped syncing when nobody here unlinked it. It can appear + * while the page is open (a sync finds the vault gone), and on a phone This + * vault sits a long scroll below the account and plan, so the notice brings + * itself into view. */ function CloudVaultRemovedNotice({ removal, @@ -2570,7 +2755,9 @@ function CloudVaultRemovedNotice({ ref={ref} role="status" data-cloud-vault-removed="" - className="flex flex-col gap-3 rounded-2xl border border-warning/35 bg-warning/10 px-4 py-3 sm:flex-row sm:items-start sm:justify-between" + data-cloud-attention="" + tabIndex={-1} + className="flex flex-col gap-3 rounded-2xl border border-warning/35 bg-warning/10 px-4 py-3 outline-none sm:flex-row sm:items-start sm:justify-between" >
diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index d6d8d1f0..3fbdbe52 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -1358,7 +1358,7 @@ function NoteCell({ field, value, editing, onStartEdit, onEndEdit, onCommit }: C title={note ? `Open ${note.title}` : `No note matches "${target}"`} onClick={(e) => { e.stopPropagation() - followLinkTarget(target) + followLinkTarget(target, { kind: 'wikilink' }) }} className={[ 'cursor-pointer rounded-full px-2 py-0.5 text-2xs font-medium ring-1', diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 9bbbc375..2c38c052 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -47,8 +47,8 @@ import { resolveAssetVaultRelativePath, } from "../lib/local-assets"; import { - externalLinkUrl, - extractLinkAtCursor, + externalUrlForLink, + linkRangeAtCursor, resolveInternalNoteHref, } from "../lib/internal-links"; import { @@ -345,7 +345,7 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { } } -// `extractLinkAtCursor` lives in ../lib/internal-links so the editor, the +// `linkRangeAtCursor` lives in ../lib/internal-links so the editor, the // preview, and the Cmd/Ctrl-click handler can all share it. /** @@ -866,12 +866,15 @@ function registerVimCommands(): void { Vim.defineAction("zenCreateNoteFromLink", (cm: ReturnType) => { const view = (cm as unknown as { cm6?: EditorView }).cm6; if (!view) return; - const target = extractLinkAtCursor( + const link = linkRangeAtCursor( view.state.doc.toString(), view.state.selection.main.head, ); - if (!target) return; - followLinkTarget(target, { createWithoutAsking: true }); + if (!link) return; + followLinkTarget(link.target, { + createWithoutAsking: true, + kind: link.kind, + }); }); Vim.defineAction("goToDefinition", (cm: ReturnType) => { @@ -879,11 +882,13 @@ function registerVimCommands(): void { if (!view) return; const pos = view.state.selection.main.head; const doc = view.state.doc.toString(); - const target = extractLinkAtCursor(doc, pos); - if (!target) return; + const link = linkRangeAtCursor(doc, pos); + if (!link) return; + const target = link.target; if (followApplicationLink(target)) return; - const external = externalLinkUrl(target); + // A wikilink names a note: `[[2024.01.15]]` is not a web address. + const external = externalUrlForLink(target, link.kind); if (external) { window.open(external, "_blank"); return; diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 37a6c201..ea2a2716 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -31,7 +31,6 @@ import { type DecorationSet, EditorView, WidgetType, - drawSelection, highlightActiveLine, highlightActiveLineGutter, keymap, @@ -339,6 +338,7 @@ import { } from '../lib/keymaps' import { isTabStripOverflowing } from '../lib/tab-strip-overflow' import { editorTabSize } from '../lib/editor-tab-size' +import { cursorDrawSelection } from '../lib/cm-cursor-blink' const MODE_OPTIONS: Array<{ mode: PaneMode @@ -909,6 +909,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const vault = useStore((s) => s.vault) const refreshNotes = useStore((s) => s.refreshNotes) const refreshAssets = useStore((s) => s.refreshAssets) + const indexImportedAssets = useStore((s) => s.indexImportedAssets) const loading = useStore((s) => s.loadingNote && isActive) const setActivePane = useStore((s) => s.setActivePane) @@ -2016,9 +2017,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { initialBody ) ), - drawSelectionCompartment.of( - drawSelection({ cursorBlinkRate: s0.cursorBlink ? 1200 : 0 }) - ), + drawSelectionCompartment.of(cursorDrawSelection(s0.cursorBlink)), tabSizeCompartment.of([ editorTabSize(s0.editorTabSize), listIndentWidth(s0.editorTabSize), @@ -2106,7 +2105,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if ( link && pointerOverRange(view, link.from, link.to, event.clientX, event.clientY) && - followLinkTarget(link.target, { createWithoutAsking: true }) + followLinkTarget(link.target, { createWithoutAsking: true, kind: link.kind }) ) { // Following the link ends its status-bar hover; a tap // never sends the mouseleave that would (#820). @@ -2684,13 +2683,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const view = viewRef.current const comp = drawSelectionCompartmentRef.current if (!view || !comp) return - // 0 disables blinking for both the drawn caret and the Vim block cursor - // (both read cursorBlinkRate from the drawSelection config). (#160) - view.dispatch({ - effects: comp.reconfigure( - drawSelection({ cursorBlinkRate: cursorBlink ? 1200 : 0 }) - ) - }) + view.dispatch({ effects: comp.reconfigure(cursorDrawSelection(cursorBlink)) }) }, [cursorBlink]) useEffect(() => { const view = viewRef.current @@ -3011,6 +3004,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const before = insertAt > 0 ? doc.sliceString(insertAt - 1, insertAt) : '' const after = insertAt < doc.length ? doc.sliceString(insertAt, insertAt + 1) : '' const insert = formatImportedAssetsForInsertion(imported, before, after) + // The files are on disk, but the listing that would carry them lands + // after the embeds; until then each embed would say its file is not on + // this device. + indexImportedAssets(imported) view.dispatch({ changes: { from: insertAt, to: insertAt, insert }, selection: { anchor: insertAt + insert.length } @@ -3019,7 +3016,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { setFocusedPanel('editor') view.focus() }, - [refreshNotes, setFocusedPanel] + [indexImportedAssets, refreshNotes, setFocusedPanel] ) const insertExistingVaultAssets = useCallback( @@ -4685,7 +4682,7 @@ function copyableLinkAtPointer(view: EditorView, x: number, y: number): Copyable if (pos == null) return null const range = linkRangeAtCursor(view.state.doc.toString(), pos) if (!range || !pointerOverRange(view, range.from, range.to, x, y)) return null - return copyableLink(range.target) + return copyableLink(range.target, range.kind) } function buildEditorContextItems( diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index 73f2871d..1163bc8d 100644 --- a/packages/app-core/src/components/ExternalFileApp.tsx +++ b/packages/app-core/src/components/ExternalFileApp.tsx @@ -12,7 +12,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Annotation, Compartment, EditorState, type Transaction } from '@codemirror/state' -import { EditorView, drawSelection, highlightActiveLine, keymap } from '@codemirror/view' +import { EditorView, highlightActiveLine, keymap } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' @@ -32,11 +32,13 @@ import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, InboxIcon } from './icons' import { applyTheme, + floatingCursorDrawSelection, lineNumberExtension, loadFloatingPrefs, paperHighlight } from './FloatingNoteApp' import { editorTabSize } from '../lib/editor-tab-size' +import { useCloseWindowShortcut } from '../lib/close-shortcut' import { linkRangeAtCursor } from '../lib/internal-links' import { pointerOverRange } from '../lib/cm-pointer-range' import { @@ -165,7 +167,7 @@ export function ExternalFileApp(): JSX.Element { new Compartment().of(prefs.vimMode ? vim() : []), vimVisualHighlightExtension, history(), - drawSelection(), + floatingCursorDrawSelection(), editorTabSize(prefs.editorTabSize), highlightActiveLine(), prefs.wordWrap ? EditorView.lineWrapping : [], @@ -249,18 +251,8 @@ export function ExternalFileApp(): JSX.Element { return () => window.removeEventListener('beforeunload', flush) }, [persist]) - // Cmd/Ctrl+W closes the standalone window. - useEffect(() => { - const handler = (event: KeyboardEvent): void => { - const mod = event.metaKey || event.ctrlKey - if (!mod || event.altKey) return - if (event.key.toLowerCase() !== 'w') return - event.preventDefault() - window.zen.windowClose() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, []) + // The close shortcut closes the standalone window, like its close button. + useCloseWindowShortcut() const currentBody = useCallback((): string => { return viewRef.current?.state.doc.toString() ?? bodyRef.current ?? content?.body ?? '' diff --git a/packages/app-core/src/components/FloatingNoteApp-close.test.ts b/packages/app-core/src/components/FloatingNoteApp-close.test.ts new file mode 100644 index 00000000..7afce33b --- /dev/null +++ b/packages/app-core/src/components/FloatingNoteApp-close.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom + +import { act, createElement, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { EditorView } from '@codemirror/view' +import { getCM } from '@replit/codemirror-vim' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ExternalFileApp } from './ExternalFileApp' +import { FloatingNoteApp } from './FloatingNoteApp' + +const PREFS_KEY = 'zen:prefs:v2' + +const windows: Array<[string, () => ReactElement]> = [ + ['a floating note window', () => createElement(FloatingNoteApp, { notePath: 'Inbox/Note.md' })], + ['a standalone file window', () => createElement(ExternalFileApp)] +] + +describe.each(windows)('Close shortcut in %s (#893)', (_, element) => { + let host: HTMLDivElement + let root: Root + let platform: NodeJS.Platform + const windowClose = vi.fn() + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + localStorage.clear() + platform = 'darwin' + // jsdom has no layout; Vim/CodeMirror still request caret rectangles. + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [] + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect() + }) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + }) + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + platformSync: () => platform, + readNote: vi.fn(async () => ({ path: 'Inbox/Note.md', title: 'Note', body: 'Body\n' })), + writeNote: vi.fn(async () => undefined), + onVaultChange: vi.fn(() => vi.fn()), + readExternalFile: vi.fn(async () => ({ path: '/tmp/Note.md', name: 'Note.md', body: 'Body\n' })), + writeExternalFile: vi.fn(async () => undefined), + windowClose + } + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + localStorage.clear() + }) + + function savePrefs(prefs: Record): void { + localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) + } + + async function mount(): Promise { + await act(async () => root.render(element())) + const view = EditorView.findFromDOM(host.querySelector('.cm-editor')!)! + act(() => view.focus()) + return view + } + + async function press(target: EventTarget, init: KeyboardEventInit): Promise { + await act(async () => { + target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })) + }) + } + + const cmdW: KeyboardEventInit = { key: 'w', code: 'KeyW', keyCode: 87, metaKey: true } + const shiftCmdW: KeyboardEventInit = { key: 'W', code: 'KeyW', keyCode: 87, metaKey: true, shiftKey: true } + const ctrlW: KeyboardEventInit = { key: 'w', code: 'KeyW', keyCode: 87, ctrlKey: true } + + it('closes the window on Cmd+W from the editor, in Vim normal and insert mode', async () => { + const view = await mount() + await press(view.contentDOM, cmdW) + expect(windowClose).toHaveBeenCalledOnce() + + await press(view.contentDOM, { key: 'i', code: 'KeyI', keyCode: 73 }) + expect(getCM(view)?.state.vim?.insertMode).toBe(true) + await press(view.contentDOM, cmdW) + expect(windowClose).toHaveBeenCalledTimes(2) + }) + + it('matches by key position on a non-Latin layout', async () => { + const view = await mount() + await press(view.contentDOM, { key: 'ц', code: 'KeyW', keyCode: 87, metaKey: true }) + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('follows a rebind of Close active tab', async () => { + savePrefs({ keymapOverrides: { 'global.closeActiveTab': 'Shift+Mod+W' } }) + const view = await mount() + await press(view.contentDOM, cmdW) + expect(windowClose).not.toHaveBeenCalled() + await press(view.contentDOM, shiftCmdW) + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('does nothing once Close active tab is unbound', async () => { + savePrefs({ keymapOverrides: { 'global.closeActiveTab': '' } }) + const view = await mount() + await press(view.contentDOM, cmdW) + expect(windowClose).not.toHaveBeenCalled() + }) + + it('picks up a binding changed in the main window while it is open', async () => { + const view = await mount() + savePrefs({ keymapOverrides: { 'global.closeActiveTab': 'Shift+Mod+W' } }) + await act(async () => { + window.dispatchEvent(new StorageEvent('storage', { key: PREFS_KEY })) + }) + await press(view.contentDOM, cmdW) + expect(windowClose).not.toHaveBeenCalled() + await press(view.contentDOM, shiftCmdW) + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('leaves Ctrl+W alone on macOS, where the shortcut is Cmd+W', async () => { + savePrefs({ vimMode: false }) + const view = await mount() + await press(view.contentDOM, ctrlW) + expect(windowClose).not.toHaveBeenCalled() + }) + + it('keeps the key while a dialog is open over the note', async () => { + const view = await mount() + const dialog = document.createElement('div') + dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + await press(view.contentDOM, cmdW) + dialog.remove() + expect(windowClose).not.toHaveBeenCalled() + }) + + describe('on Linux and Windows (Mod is Ctrl)', () => { + beforeEach(() => { + platform = 'linux' + }) + + it('closes the window on Ctrl+W outside the editor', async () => { + await mount() + await press(document.body, ctrlW) + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('leaves Ctrl+W to Vim inside the editor', async () => { + const view = await mount() + await press(view.contentDOM, ctrlW) + expect(windowClose).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/app-core/src/components/FloatingNoteApp.tsx b/packages/app-core/src/components/FloatingNoteApp.tsx index c5b532f5..c957b477 100644 --- a/packages/app-core/src/components/FloatingNoteApp.tsx +++ b/packages/app-core/src/components/FloatingNoteApp.tsx @@ -19,7 +19,6 @@ import { } from '@codemirror/state' import { EditorView, - drawSelection, highlightActiveLine, highlightActiveLineGutter, keymap, @@ -58,6 +57,8 @@ import { type ThemeMode } from '../lib/themes' import { editorTabSize, normalizeEditorTabSize } from '../lib/editor-tab-size' +import { storedCursorDrawSelection } from '../lib/cm-cursor-blink' +import { useCloseWindowShortcut } from '../lib/close-shortcut' const PREFS_KEY = 'zen:prefs:v2' const SAVE_DEBOUNCE_MS = 350 @@ -113,6 +114,7 @@ export interface FloatingPrefs { showHeadingLevelLabels: boolean lineNumberMode: LineNumberMode wordWrap: boolean + cursorBlink: boolean interfaceFont: string | null textFont: string | null monoFont: string | null @@ -134,6 +136,7 @@ export function loadFloatingPrefs(): FloatingPrefs { showHeadingLevelLabels: false, lineNumberMode: 'off', wordWrap: true, + cursorBlink: true, interfaceFont: null, textFont: null, monoFont: null @@ -154,6 +157,7 @@ export function loadFloatingPrefs(): FloatingPrefs { vimWrappedLineMotions: parsed.vimWrappedLineMotions === 'logical' ? 'logical' : 'display', vimBlockImeInNormalMode: parsed.vimBlockImeInNormalMode !== false, + cursorBlink: parsed.cursorBlink !== false, themeFamily: (parsed.themeFamily as ThemeFamily) ?? fallback.themeFamily, themeMode: (parsed.themeMode as ThemeMode) ?? fallback.themeMode, lineNumberMode, @@ -178,6 +182,12 @@ export function lineNumberExtension(mode: LineNumberMode): Extension { ] } +/** drawSelection for the floating and external-file windows: the Blinking + * cursor preference at open, and again whenever Settings saves it. */ +export function floatingCursorDrawSelection(): Extension { + return storedCursorDrawSelection(PREFS_KEY, () => loadFloatingPrefs().cursorBlink) +} + export function applyTheme(prefs: FloatingPrefs): void { const html = document.documentElement const mql = window.matchMedia('(prefers-color-scheme: dark)') @@ -338,7 +348,7 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element vimImeGuard(() => prefs.vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, history(), - drawSelection(), + floatingCursorDrawSelection(), editorTabSize(prefs.editorTabSize), highlightActiveLine(), prefs.wordWrap ? EditorView.lineWrapping : [], @@ -417,20 +427,9 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element return () => window.removeEventListener('beforeunload', flush) }, [persist]) - // Floating windows have no tab strip, so browser-style window close - // shortcuts should close the OS window itself rather than trying to - // mimic the main app's "close active tab" behavior. - useEffect(() => { - const handler = (event: KeyboardEvent): void => { - const mod = event.metaKey || event.ctrlKey - if (!mod || event.altKey) return - if (event.key.toLowerCase() !== 'w') return - event.preventDefault() - window.zen.windowClose() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, []) + // No tab strip here: the close shortcut closes the window itself, through + // the same path as the close button, so the pending save flushes on unload. + useCloseWindowShortcut() // Vim ex commands scoped to the floating window. The main-window // `registerVimCommands` never runs here (each Electron window has its diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index cc7a37a8..1fabbe96 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -74,6 +74,10 @@ function resolveShortcutKeys( if (action === 'Toggle word wrap') return shortcut(overrides, 'global.toggleWordWrap') } + if (sectionId === 'quick-capture-window') { + if (action === 'Hide without saving') return shortcut(overrides, 'global.closeActiveTab') + } + if (sectionId === 'panel-motion') { if (action === 'Move focus') { return alternatives([ diff --git a/packages/app-core/src/components/HintOverlay.test.ts b/packages/app-core/src/components/HintOverlay.test.ts new file mode 100644 index 00000000..30cd5231 --- /dev/null +++ b/packages/app-core/src/components/HintOverlay.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { wikilinkRenderExtension } from '../lib/cm-wikilink-render' +import { registerNoteEditor } from '../lib/note-editor-context' +import { HintOverlay } from './HintOverlay' + +const nav = vi.hoisted(() => ({ openWikilinkTarget: vi.fn(async () => true) })) + +vi.mock('../lib/wikilink-navigation', async (importOriginal) => ({ + ...(await importOriginal()), + openWikilinkTarget: nav.openWikilinkTarget +})) +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// The note from the report, shown in the Edit view. +const NOTE = '# Test\n\nhttps://example.net\n\n[[ZenNotes]]\n\n[GitHub](https://github.com)' + +const zenNotes = { + path: 'ZenNotes.md', + title: 'ZenNotes', + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' +} as NoteMeta + +// jsdom has no layout. Lay the editor out as a grid instead: line N starts at +// top 100 + 24 * (N - 1), every character is 8px wide from left 40, and the +// editor's scroller is a 800x620 box at (20, 80). CodeMirror measures text +// through Range.getClientRects, so that is the one primitive to provide; +// characters a decoration replaces are never measured, as in a browser. +function rect(left: number, top: number, width: number, height: number): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}) + } as DOMRect +} + +const SCROLLER = rect(20, 80, 800, 620) + +let layoutView: EditorView | null = null + +function charRects(this: Range): DOMRect[] { + const view = layoutView + if (!view || !view.contentDOM.contains(this.startContainer)) return [] + let pos: number + try { + pos = view.posAtDOM(this.startContainer, this.startOffset) + } catch { + return [] + } + const line = view.state.doc.lineAt(pos) + return [rect(40 + (pos - line.from) * 8, 100 + (line.number - 1) * 24, 8, 20)] +} + +function mountNoteEditor(doc: string, paneId: string): 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 }), wikilinkRenderExtension] + }) + }) + registerNoteEditor(view, () => 'Test.md', paneId) + view.scrollDOM.getBoundingClientRect = () => SCROLLER + layoutView = view + return view +} + +const initialStore = useStore.getState() + +describe('HintOverlay over the Edit view (#894)', () => { + let view: EditorView + let host: HTMLDivElement + let root: Root + const openSpy = vi.fn(() => null) + + beforeEach(() => { + nav.openWikilinkTarget.mockClear() + openSpy.mockClear() + vi.spyOn(window, 'open').mockImplementation(openSpy) + ;(Range.prototype as unknown as { getClientRects: unknown }).getClientRects = charRects + ;(Range.prototype as unknown as { getBoundingClientRect: unknown }).getBoundingClientRect = + function (this: Range) { + return charRects.call(this)[0] ?? rect(0, 0, 0, 0) + } + useStore.setState({ + notes: [zenNotes], + selectedPath: 'Test.md', + activePaneId: 'pane-1', + paneLayout: { + kind: 'leaf', + id: 'pane-1', + tabs: ['Test.md'], + pinnedTabs: [], + activeTab: 'Test.md' + } + }) + view = mountNoteEditor(NOTE, 'pane-1') + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + for (const control of document.querySelectorAll('[data-test-control]')) control.remove() + view.dom.parentElement?.remove() + view.destroy() + layoutView = null + delete (Range.prototype as unknown as { getClientRects?: unknown }).getClientRects + delete (Range.prototype as unknown as { getBoundingClientRect?: unknown }).getBoundingClientRect + useStore.setState(initialStore, true) + vi.restoreAllMocks() + }) + + async function showHints(): Promise<{ + onActivate: ReturnType + onCancel: ReturnType + }> { + const onActivate = vi.fn() + const onCancel = vi.fn() + await act(async () => { + root.render(createElement(HintOverlay, { onActivate, onCancel })) + }) + return { onActivate, onCancel } + } + + async function press(key: string): Promise { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) + // A unique match fires after a short beat so the label can be seen. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 80)) + }) + } + + function hints(): Array<{ label: string; left: string; top: string }> { + return Array.from(document.querySelectorAll('.vim-hint')).map((el) => ({ + label: el.textContent ?? '', + left: el.style.left, + top: el.style.top + })) + } + + it('labels each link the editor shows, at its first visible character', async () => { + const { onCancel } = await showHints() + + expect(onCancel).not.toHaveBeenCalled() + expect(hints()).toEqual([ + // https://example.net, line 3 from its first character + { label: 'a', left: '44px', top: '152px' }, + // [[ZenNotes]], line 5: the brackets are hidden, so the label sits on the Z + { label: 's', left: '60px', top: '200px' }, + // [GitHub](https://github.com), line 7; its URL is part of it, not a hint of its own + { label: 'd', left: '44px', top: '248px' } + ]) + }) + + it('follows a bare URL out to the browser', async () => { + const { onActivate } = await showHints() + await press('a') + + expect(openSpy).toHaveBeenCalledWith('https://example.net', '_blank') + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('opens the note a wikilink names, like clicking it', async () => { + const { onActivate } = await showHints() + await press('s') + + expect(nav.openWikilinkTarget).toHaveBeenCalledWith('ZenNotes.md', 'ZenNotes') + expect(openSpy).not.toHaveBeenCalled() + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('follows a Markdown link to its URL', async () => { + await showHints() + await press('d') + + expect(openSpy).toHaveBeenCalledWith('https://github.com', '_blank') + }) + + it('hands out labels in document order, the links among the page’s controls', async () => { + const control = (name: string, top: number): HTMLButtonElement => { + const button = document.createElement('button') + button.textContent = name + button.dataset.testControl = '' + button.getBoundingClientRect = () => rect(0, top, 100, 20) + return button + } + const sidebarRow = control('Sidebar row', 0) + const statusButton = control('Status bar', 740) + document.body.prepend(sidebarRow) + document.body.append(statusButton) + const clicked = vi.fn() + sidebarRow.addEventListener('click', clicked) + + const { onActivate } = await showHints() + + expect(hints().map((hint) => hint.label)).toEqual(['a', 's', 'd', 'f', 'g']) + expect(hints()[0]).toEqual({ label: 'a', left: '4px', top: '4px' }) + expect(hints()[4]).toEqual({ label: 'g', left: '4px', top: '744px' }) + + // A control still activates as before: clicked, focused, handed back. + await press('a') + expect(clicked).toHaveBeenCalledTimes(1) + expect(document.activeElement).toBe(sidebarRow) + expect(onActivate).toHaveBeenCalledWith(sidebarRow) + expect(openSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/HintOverlay.tsx b/packages/app-core/src/components/HintOverlay.tsx index f4f2d89d..d20dd185 100644 --- a/packages/app-core/src/components/HintOverlay.tsx +++ b/packages/app-core/src/components/HintOverlay.tsx @@ -1,13 +1,25 @@ import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' +import { + followEditorLinkHint, + visibleEditorLinkHints, + type HintRect +} from '../lib/editor-link-hints' import { generateHintLabels } from '../lib/vim-nav' +interface HintCandidate { + rect: HintRect + /** Labels are handed out in document order of this node. */ + anchor: Node + /** Follows the target; returns the element it clicked, when it was one. */ + activate: () => HTMLElement | undefined +} + interface HintTarget { - element: HTMLElement label: string - rect: DOMRect left: number top: number + activate: () => HTMLElement | undefined } function getVisibleInteractiveElements(): HTMLElement[] { @@ -48,6 +60,38 @@ function getVisibleInteractiveElements(): HTMLElement[] { return visible } +function documentOrder(a: Node, b: Node): number { + if (a === b) return 0 + return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 +} + +function getHintCandidates(): HintCandidate[] { + const candidates: HintCandidate[] = getVisibleInteractiveElements().map((element) => ({ + rect: element.getBoundingClientRect(), + anchor: element, + activate: () => { + element.click() + element.focus() + return element + } + })) + // The Edit view draws its links as text, not elements (#894). Each joins the + // page's targets where it is drawn, so labels run through a note top to + // bottom as they do over Preview's links; the sort is stable, so links that + // share a text node keep their order. + for (const link of visibleEditorLinkHints()) { + candidates.push({ + rect: link.rect, + anchor: link.anchor, + activate: () => { + followEditorLinkHint(link) + return undefined + } + }) + } + return candidates.sort((a, b) => documentOrder(a.anchor, b.anchor)) +} + function overlaps( a: { left: number; top: number; width: number; height: number }, b: { left: number; top: number; width: number; height: number } @@ -65,7 +109,7 @@ function clamp(value: number, min: number, max: number): number { } function getHintPlacement( - rect: DOMRect, + rect: HintRect, label: string, placed: Array<{ left: number; top: number; width: number; height: number }> ): { left: number; top: number } { @@ -118,23 +162,21 @@ export function HintOverlay({ const [buffer, setBuffer] = useState('') const targets = useMemo(() => { - const elements = getVisibleInteractiveElements() - const labels = generateHintLabels(elements.length) + const candidates = getHintCandidates() + const labels = generateHintLabels(candidates.length) const placed: Array<{ left: number; top: number; width: number; height: number }> = [] - return elements.map((element, i) => { + return candidates.map((candidate, i) => { const label = labels[i] - const rect = element.getBoundingClientRect() - const position = getHintPlacement(rect, label, placed) + const position = getHintPlacement(candidate.rect, label, placed) placed.push({ ...position, width: Math.max(18, label.length * 8 + 10), height: 18 }) return { - element, label, - rect, + activate: candidate.activate, ...position } }) @@ -152,9 +194,7 @@ export function HintOverlay({ const target = matching[0] // Small delay so the user sees the match before it fires const t = setTimeout(() => { - target.element.click() - target.element.focus() - onActivate(target.element) + onActivate(target.activate()) }, 50) return () => clearTimeout(t) } diff --git a/packages/app-core/src/components/NoteHoverPreview.tsx b/packages/app-core/src/components/NoteHoverPreview.tsx index 45cabeba..a32f906d 100644 --- a/packages/app-core/src/components/NoteHoverPreview.tsx +++ b/packages/app-core/src/components/NoteHoverPreview.tsx @@ -33,6 +33,7 @@ export function NoteHoverPreview({ const activeNote = useStore((s) => s.activeNote) const vault = useStore((s) => s.vault) const assetFiles = useStore((s) => s.assetFiles) + const assetFilesListed = useStore((s) => s.assetFilesListed) const customCodeLanguagesRevision = useStore((s) => s.customCodeLanguagesRevision) const focusedPanel = useStore((s) => s.focusedPanel) const setFocusedPanel = useStore((s) => s.setFocusedPanel) @@ -90,7 +91,7 @@ export function NoteHoverPreview({ void openNoteInTab(assetTabPath(path)) } }) - }, [assetFilesKey, content?.path, html, openNoteInTab, vault?.root]) + }, [assetFilesKey, assetFilesListed, content?.path, html, openNoteInTab, vault?.root]) useEffect(() => { const root = articleRef.current diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index f7d5c6cf..68041df6 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -19,7 +19,6 @@ import { } from '@codemirror/state' import { EditorView, - drawSelection, highlightActiveLine, highlightActiveLineGutter, keymap, @@ -64,6 +63,7 @@ import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, PanelLeftIcon, PinIcon } from './icons' import { editorTabSize } from '../lib/editor-tab-size' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' +import { cursorDrawSelection } from '../lib/cm-cursor-blink' const PINNED_REF_PANE_ID = 'pinned-ref' export const pinnedRefPaneId = PINNED_REF_PANE_ID @@ -188,6 +188,7 @@ export function PinnedReferencePane(): JSX.Element | null { const showHeadingLevelLabels = useStore((s) => s.showHeadingLevelLabels) const lineNumberMode = useStore((s) => s.lineNumberMode) const editorTabSizeValue = useStore((s) => s.editorTabSize) + const cursorBlink = useStore((s) => s.cursorBlink) const listIndentGuidesOn = useStore((s) => s.listIndentGuides) const editorFontSize = useStore((s) => s.editorFontSize) const editorLineHeight = useStore((s) => s.editorLineHeight) @@ -202,6 +203,7 @@ export function PinnedReferencePane(): JSX.Element | null { const lineNumbersCompartmentRef = useRef(null) const headingCompartmentRef = useRef(null) const tabSizeCompartmentRef = useRef(null) + const drawSelectionCompartmentRef = useRef(null) const [resizing, setResizing] = useState(false) @@ -221,12 +223,14 @@ export function PinnedReferencePane(): JSX.Element | null { const lineNumbersCompartment = new Compartment() const headingCompartment = new Compartment() const tabSizeCompartment = new Compartment() + const drawSelectionCompartment = new Compartment() vimCompartmentRef.current = vimCompartment keymapCompartmentRef.current = keymapCompartment livePreviewCompartmentRef.current = livePreviewCompartment lineNumbersCompartmentRef.current = lineNumbersCompartment headingCompartmentRef.current = headingCompartment tabSizeCompartmentRef.current = tabSizeCompartment + drawSelectionCompartmentRef.current = drawSelectionCompartment const s0 = useStore.getState() const initialPath = s0.pinnedRefPath const initialContent = initialPath ? s0.noteContents[initialPath] ?? null : null @@ -239,7 +243,7 @@ export function PinnedReferencePane(): JSX.Element | null { vimCompartment.of(s0.vimMode ? vim() : []), vimVisualHighlightExtension, history(), - drawSelection(), + drawSelectionCompartment.of(cursorDrawSelection(s0.cursorBlink)), tabSizeCompartment.of([ editorTabSize(s0.editorTabSize), listIndentWidth(s0.editorTabSize), @@ -373,6 +377,12 @@ export function PinnedReferencePane(): JSX.Element | null { ]) }) }, [editorTabSizeValue, listIndentGuidesOn]) + useEffect(() => { + const view = viewRef.current + const comp = drawSelectionCompartmentRef.current + if (!view || !comp) return + view.dispatch({ effects: comp.reconfigure(cursorDrawSelection(cursorBlink)) }) + }, [cursorBlink]) /* -------- Re-measure on font changes -------- */ useEffect(() => { diff --git a/packages/app-core/src/components/Preview-cloud-notice.test.ts b/packages/app-core/src/components/Preview-cloud-notice.test.ts index a488c475..c85040e2 100644 --- a/packages/app-core/src/components/Preview-cloud-notice.test.ts +++ b/packages/app-core/src/components/Preview-cloud-notice.test.ts @@ -110,53 +110,74 @@ function noticeHosts(host: HTMLElement): string[] { ); } +/** Renders `body` in the reading view over a vault that holds `assetPaths`. */ +function mountPreview( + body: string, + assetPaths: string[], + options: { listed?: boolean; onRequestEdit?: () => void } = {}, +): { host: HTMLElement; unmount: () => void } { + if (!globalThis.CSS?.escape) { + vi.stubGlobal("CSS", { escape: (value: string) => value.replace(/[^\w-]/g, "\\$&") }); + } + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); + Object.defineProperty(window, "zen", { + configurable: true, + value: { + getAppInfo: () => ({ runtime: "web" }), + readNote: async () => ({ ...note, body }), + resolveVaultAssetUrl: (_root: string, rel: string) => `zen-asset://vault/${rel}`, + resolveLocalAssetUrl: (_root: string, _note: string, href: string) => + `zen-asset://note/${href}`, + }, + }); + useStore.setState({ + notes: [note], + vault: { root: "/vault", name: "Vault" } as never, + assetFiles: assetPaths.map((path) => ({ path })) as never, + assetFilesListed: options.listed ?? false, + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + createElement(Preview, { + markdown: body, + notePath, + onRequestEdit: options.onRequestEdit ?? null, + }), + ), + ); + return { + host, + unmount: () => { + act(() => root.unmount()); + host.remove(); + }, + }; +} + describe("Preview: embeds Cloud keeps on this device", () => { let host: HTMLElement; let unmount: () => void; beforeEach(async () => { clearCloudSyncStatus(); - if (!globalThis.CSS?.escape) { - vi.stubGlobal("CSS", { escape: (value: string) => value.replace(/[^\w-]/g, "\\$&") }); - } - Object.defineProperty(window, "matchMedia", { - configurable: true, - value: vi.fn().mockReturnValue({ - matches: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }), - }); - Object.defineProperty(window, "zen", { - configurable: true, - value: { - getAppInfo: () => ({ runtime: "web" }), - readNote: async () => ({ ...note, body: markdown }), - resolveVaultAssetUrl: (_root: string, rel: string) => `zen-asset://vault/${rel}`, - resolveLocalAssetUrl: (_root: string, _note: string, href: string) => - `zen-asset://note/${href}`, - }, - }); - useStore.setState({ - notes: [note], - vault: { root: "/vault", name: "Vault" } as never, - assetFiles: [ - "assets/clip.mp4", - "assets/song.mp3", - "assets/pic.png", - "assets/doc.pdf", - "assets/archive.zip", - "assets/other.mp4", - ].map((path) => ({ path })) as never, - }); - host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => root.render(createElement(Preview, { markdown, notePath }))); - unmount = () => { - act(() => root.unmount()); - host.remove(); - }; + ({ host, unmount } = mountPreview(markdown, [ + "assets/clip.mp4", + "assets/song.mp3", + "assets/pic.png", + "assets/doc.pdf", + "assets/archive.zip", + "assets/other.mp4", + ])); await waitFor(() => embed(host, "assets/clip.mp4") !== null, "the media"); }); @@ -251,3 +272,131 @@ describe("Preview: embeds Cloud keeps on this device", () => { expect(embed(host, "assets/clip.mp4")!.querySelector("video")).toBe(video); }); }); + +// A file over Cloud's per-file limit stays on the device that has it. Every +// other device gets the note, its embed, and no file behind the embed. +describe("Preview: embeds whose file is not on this device", () => { + const body = [ + "# Media", + "", + "![[assets/clip.mp4]]", + "", + "![[assets/gone.mp4]]", + "", + "![[assets/gone.mp3]]", + "", + "![[assets/gone.png]]", + "", + "![[assets/gone.pdf]]", + "", + "![](assets/gone%20too.mp4)", + "", + "[The spec](assets/spec.pdf)", + "", + "![](assets/scan.pdf)", + "", + "![[assets/gone.zip]]", + ].join("\n"); + + let mounted: { host: HTMLElement; unmount: () => void } | null = null; + + afterEach(() => { + mounted?.unmount(); + mounted = null; + useStore.setState({ notes: [], vault: null, assetFiles: [], assetFilesListed: false }); + delete (window as unknown as { zen?: unknown }).zen; + }); + + function missing(host: HTMLElement): Array<[string, string, string]> { + return [...host.querySelectorAll("[data-local-asset-missing]")].map((card) => [ + card.dataset.localAssetMissing ?? "", + card.dataset.localAssetHref ?? "", + card.querySelector('[role="note"]')?.textContent ?? "", + ]); + } + + it("says so in place of the player, picture or document, and plays the file that is here", async () => { + mounted = mountPreview(body, ["assets/clip.mp4"], { listed: true }); + const { host } = mounted; + await waitFor(() => missing(host).length > 0, "the notices"); + + expect(missing(host)).toEqual([ + ["video", "assets/gone.mp4", "gone.mp4 isn't on this device."], + ["audio", "assets/gone.mp3", "gone.mp3 isn't on this device."], + ["image", "assets/gone.png", "gone.png isn't on this device."], + ["pdf", "assets/gone.pdf", "gone.pdf isn't on this device."], + ["video", "assets/gone%20too.mp4", "gone too.mp4 isn't on this device."], + ["pdf", "assets/spec.pdf", "spec.pdf isn't on this device."], + ["pdf", "assets/scan.pdf", "scan.pdf isn't on this device."], + ]); + for (const card of host.querySelectorAll("[data-local-asset-missing]")) { + expect(card.querySelector("video, audio, img, iframe, a, button")).toBeNull(); + expect(card.querySelector('[role="note"]')?.classList.contains("text-warning")).toBe(true); + } + expect(embed(host, "assets/clip.mp4")?.querySelector("video")?.getAttribute("src")).toBe( + "zen-asset://vault/assets/clip.mp4", + ); + // The listing cannot vouch for every file a chip names, so chips stay. + expect(embed(host, "assets/gone.zip")?.classList.contains("local-file-attachment")).toBe(true); + }); + + it("keeps its block's source line, so a double-click opens that line in the editor", async () => { + const onRequestEdit = vi.fn(); + mounted = mountPreview(body, ["assets/clip.mp4"], { listed: true, onRequestEdit }); + const { host } = mounted; + await waitFor(() => missing(host).length > 0, "the notices"); + + const card = embed(host, "assets/gone.mp4")!; + expect(card.dataset.sourceLine).toBe("5"); + card + .querySelector('[role="note"]')! + .dispatchEvent(new MouseEvent("dblclick", { bubbles: true, button: 0 })); + expect(onRequestEdit).toHaveBeenCalledWith(expect.objectContaining({ sourceLine: 5 })); + }); + + it("says so in a vault whose listing holds no files at all, once that listing lands", async () => { + mounted = mountPreview("![[assets/gone.mp4]]\n\n![[assets/gone.png]]", [], { + listed: false, + }); + const { host } = mounted; + await settle(); + await settle(); + // Not listed yet: no file can be called missing on an empty list. + expect(missing(host)).toEqual([]); + + act(() => { + useStore.setState({ assetFiles: [], assetFilesListed: true }); + }); + await waitFor(() => missing(host).length === 2, "two notices"); + expect(host.querySelector("video, img")).toBeNull(); + }); + + it("gives way to the player once the file arrives", async () => { + mounted = mountPreview("![[assets/gone.mp4]]", ["assets/clip.mp4"], { listed: true }); + const { host } = mounted; + await waitFor(() => missing(host).length === 1, "the notice"); + + act(() => { + useStore.setState({ + assetFiles: [{ path: "assets/clip.mp4" }, { path: "assets/gone.mp4" }] as never, + }); + }); + await waitFor(() => missing(host).length === 0, "the notice gone"); + expect(embed(host, "assets/gone.mp4")?.querySelector("video")?.getAttribute("src")).toBe( + "zen-asset://vault/assets/gone.mp4", + ); + }); + + it("trusts only a listing read from the vault: a list seeded by hand keeps its players", async () => { + // The share viewer seeds the store with the files its page carries and + // serves the rest from the guessed URL. + mounted = mountPreview("![[assets/gone.mp4]]", ["assets/clip.mp4"], { listed: false }); + const { host } = mounted; + await waitFor(() => embed(host, "assets/gone.mp4") !== null, "the player"); + + expect(missing(host)).toEqual([]); + expect(embed(host, "assets/gone.mp4")?.querySelector("video")?.getAttribute("src")).toBe( + "zen-asset://note/assets/gone.mp4", + ); + }); +}); diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index d493c331..1bdb6ea9 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -214,6 +214,9 @@ export const Preview = memo(function Preview({ [folders, vaultSettings], ); const assetFiles = useStore((s) => s.assetFiles); + // An empty listing landing leaves `assetFilesKey` as it was, and is still + // what lets an embed in a vault with no files say it is not on this device. + const assetFilesListed = useStore((s) => s.assetFilesListed); const customCodeLanguagesRevision = useStore((s) => s.customCodeLanguagesRevision); const refreshAssets = useStore((s) => s.refreshAssets); const renameAsset = useStore((s) => s.renameAsset); @@ -518,6 +521,7 @@ export const Preview = memo(function Preview({ // the note is created at once at the suggested path (#768). followLinkTarget(anchor.dataset.wikilink, { createWithoutAsking: e.metaKey || e.ctrlKey, + kind: "wikilink", }); } return; @@ -986,6 +990,7 @@ export const Preview = memo(function Preview({ }; }, [ assetFilesKey, + assetFilesListed, diagramTheme.key, diagramTheme.mode, databaseTargets, diff --git a/packages/app-core/src/components/QuickCaptureApp.test.ts b/packages/app-core/src/components/QuickCaptureApp.test.ts index dd438ef0..ed18c4a3 100644 --- a/packages/app-core/src/components/QuickCaptureApp.test.ts +++ b/packages/app-core/src/components/QuickCaptureApp.test.ts @@ -203,3 +203,115 @@ describe('Quick Capture Escape handling (#765)', () => { expect(windowClose).toHaveBeenCalledOnce() }) }) + +describe('Quick Capture close shortcut (#893)', () => { + let host: HTMLDivElement + let root: Root + let platform: NodeJS.Platform + const windowClose = vi.fn() + const createNote = vi.fn() + const writeNote = vi.fn() + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + localStorage.clear() + platform = 'darwin' + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [] + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect() + }) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + }) + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + listNotes: vi.fn(async () => []), + onVaultChange: vi.fn(() => vi.fn()), + getQuickCapturePinned: vi.fn(async () => false), + platformSync: () => platform, + windowClose, + createNote, + writeNote + } + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + localStorage.clear() + }) + + async function mount(): Promise { + await act(async () => root.render(createElement(QuickCaptureApp))) + const view = EditorView.findFromDOM(host.querySelector('.cm-editor')!)! + act(() => { + view.dispatch({ changes: { from: 0, insert: 'Draft\nStill thinking' } }) + view.focus() + }) + return view + } + + async function press(target: EventTarget, init: KeyboardEventInit): Promise { + await act(async () => { + target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })) + }) + } + + const cmdW: KeyboardEventInit = { key: 'w', code: 'KeyW', keyCode: 87, metaKey: true } + + it('hides the window on Cmd+W like its close button, keeping the draft', async () => { + const view = await mount() + await press(view.contentDOM, cmdW) + + expect(windowClose).toHaveBeenCalledOnce() + // The close button hides without saving; the draft waits in the buffer. + expect(createNote).not.toHaveBeenCalled() + expect(writeNote).not.toHaveBeenCalled() + expect(view.state.doc.toString()).toBe('Draft\nStill thinking') + }) + + it('keeps Cmd+W while the note picker is open', async () => { + await mount() + await press(window, { key: 'p', code: 'KeyP', metaKey: true }) + const input = host.querySelector('input')! + expect(input.placeholder).toContain('Search notes') + + await press(input, cmdW) + + expect(windowClose).not.toHaveBeenCalled() + expect(host.querySelector('input')).not.toBeNull() + }) + + it('follows a rebind of Close active tab', async () => { + localStorage.setItem( + 'zen:prefs:v2', + JSON.stringify({ keymapOverrides: { 'global.closeActiveTab': 'Shift+Mod+W' } }) + ) + const view = await mount() + await press(view.contentDOM, cmdW) + expect(windowClose).not.toHaveBeenCalled() + + await press(view.contentDOM, { key: 'W', code: 'KeyW', keyCode: 87, metaKey: true, shiftKey: true }) + expect(windowClose).toHaveBeenCalledOnce() + }) + + it('hides on Ctrl+W on Linux and Windows', async () => { + platform = 'linux' + localStorage.setItem('zen:prefs:v2', JSON.stringify({ vimMode: false })) + const view = await mount() + await press(view.contentDOM, { key: 'w', code: 'KeyW', keyCode: 87, ctrlKey: true }) + expect(windowClose).toHaveBeenCalledOnce() + expect(writeNote).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 99ab7afb..711c5e0d 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -23,6 +23,9 @@ * ⌘⇧P / Ctrl+Shift+P — open the command palette. * Esc — cancel editor selection/mode or overlay, else hide window. * + * ⌘W / Ctrl+W hides the window without saving, like its close button. It is + * the Close active tab binding, so a rebind or an unbind applies here too. + * * Vim ex commands (when vim mode is on): * :w — save without closing. * :q — hide the window without saving. @@ -34,7 +37,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Compartment, EditorState, Prec } from '@codemirror/state' import { EditorView, - drawSelection, highlightActiveLine, keymap, placeholder @@ -85,6 +87,8 @@ import { isImeComposing } from '../lib/ime' import { PinIcon } from './icons' import { headingFolding } from '../lib/cm-heading-fold' import { editorTabSize, normalizeEditorTabSize } from '../lib/editor-tab-size' +import { storedCursorDrawSelection } from '../lib/cm-cursor-blink' +import { useCloseWindowShortcut } from '../lib/close-shortcut' const PREFS_KEY = 'zen:prefs:v2' @@ -100,6 +104,7 @@ interface QuickCapturePrefs { editorLineHeight: number editorTabSize: number showHeadingLevelLabels: boolean + cursorBlink: boolean interfaceFont: string | null textFont: string | null monoFont: string | null @@ -118,6 +123,7 @@ function loadPrefs(): QuickCapturePrefs { editorLineHeight: 1.6, editorTabSize: 4, showHeadingLevelLabels: false, + cursorBlink: true, interfaceFont: null, textFont: null, monoFont: null @@ -132,6 +138,7 @@ function loadPrefs(): QuickCapturePrefs { vimWrappedLineMotions: parsed.vimWrappedLineMotions === 'logical' ? 'logical' : 'display', vimBlockImeInNormalMode: parsed.vimBlockImeInNormalMode !== false, + cursorBlink: parsed.cursorBlink !== false, themeFamily: (parsed.themeFamily as ThemeFamily) ?? fallback.themeFamily, themeMode: (parsed.themeMode as ThemeMode) ?? fallback.themeMode, editorTabSize: normalizeEditorTabSize(parsed.editorTabSize) @@ -475,7 +482,7 @@ export function QuickCaptureApp(): JSX.Element { ]) ), history(), - drawSelection(), + storedCursorDrawSelection(PREFS_KEY, () => loadPrefs().cursorBlink), editorTabSize(prefs.editorTabSize), highlightActiveLine(), EditorView.lineWrapping, @@ -565,6 +572,11 @@ export function QuickCaptureApp(): JSX.Element { useEffect(() => { overlayRef.current = overlay }, [overlay]) + // The close shortcut hides the window through the close button's path: no + // save, the draft stays in this renderer for the next show. The picker and + // the command palette keep it, as palettes do in the main window. + const overlayOpen = useCallback(() => overlayRef.current !== 'none', []) + useCloseWindowShortcut(overlayOpen) const submitAndCloseRef = useRef(submitAndClose) useEffect(() => { submitAndCloseRef.current = submitAndClose diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 9c03abc0..1675422c 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -8,6 +8,8 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SettingsModal } from "./SettingsModal"; import { requestSettingsTarget } from "../lib/settings-navigation"; +import { isDialogOrMenuOpen } from "../lib/close-shortcut"; +import { clearCloudSyncStatus, useCloudSyncStatusStore } from "../lib/cloud-auto-sync"; import { getSettingsSearchResults, type SettingsSearchCategory, @@ -22,6 +24,9 @@ const cloudMocks = vi.hoisted(() => ({ .fn() .mockResolvedValue({ state: "disconnected", account: null }), onCloudAccountChange: vi.fn(() => vi.fn()), + getCloudServiceAccount: vi.fn(), + listCloudVaults: vi.fn(), + getCloudVaultLink: vi.fn(), })); const mocks = vi.hoisted(() => { @@ -43,6 +48,10 @@ const mocks = vi.hoisted(() => { hiddenWorkflowPresets: [], hideBuiltinTemplates: false, interfaceFont: null, + // The Tasks pane: "due date" finds its Kanban card order row first. + kanbanCardSort: "manual", + kanbanFolderRoot: "", + kanbanStatuses: [] as string[], keymapOverrides: {} as Record, lineNumberMode: "off", mathRenderer: "katex" as "katex" | "typst", @@ -253,6 +262,13 @@ describe("SettingsModal date note directories", () => { expect(host.querySelector('[role="dialog"]')?.hasAttribute("data-settings-target")).toBe(false); }); + it("is the window the close shortcut closes, not a dialog in front of it (#893)", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const panel = host.querySelector('[aria-modal="true"]'); + expect(panel?.hasAttribute("data-settings-dialog")).toBe(true); + expect(isDialogOrMenuOpen()).toBe(false); + }); + it("opens application link settings and saves normalized prefixes", async () => { requestSettingsTarget("external-links"); await act(async () => root.render(createElement(SettingsModal))); @@ -436,6 +452,8 @@ describe("SettingsModal date note directories", () => { ["open in preview", "Default view mode", "default-view-mode"], ["event triggers", "Event triggers", "workflow-event-triggers"], ["due date", "Tasks are due on the note's date", "daily-notes-tasks-due-on-date"], + ["due date", "Kanban card order", "kanban-card-sort"], + ["card order", "Kanban card order", "kanban-card-sort"], ["rollover", "Roll over unfinished tasks to today", "daily-notes-rollover"], ["tutorial", "Guided tutorial", "workflow-tutorial"], ["recipe gallery", "Built-in recipes", "workflow-hidden-recipes"], @@ -620,6 +638,65 @@ describe("SettingsModal date note directories", () => { expect(host.textContent).toContain("Keep your vault available everywhere"); expect(host.textContent).toContain("Connect ZenNotes Cloud"); }); + + it("opens a Cloud request on This vault and hands what waits there the keyboard", async () => { + const account = { + base_url: "https://zennotes.org", + user: { name: "Ada", email: "ada@example.com" }, + device: { id: "device-1", name: "Ada’s Mac", platform: "desktop" }, + connected_at: "2026-08-10T12:00:00.000Z", + }; + cloudMocks.getCloudAccountStatus.mockResolvedValueOnce({ state: "connected", account }); + cloudMocks.getCloudServiceAccount.mockResolvedValueOnce({ + user: account.user, + device: { ...account.device, app_version: "2.62.0" }, + features: { + sync: { active: true, limits: null }, + backup: { active: false, limits: null }, + publish: { active: false, limits: null }, + }, + }); + cloudMocks.listCloudVaults.mockResolvedValueOnce([]); + cloudMocks.getCloudVaultLink.mockResolvedValueOnce({ + base_url: account.base_url, + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + useCloudSyncStatusStore.setState({ + phase: "attention", + vaultName: "Cloud Notes", + lastSummary: { + cursor: 7, + pulled: 0, + pushed: 0, + conflicts: [], + bootstrap_conflicts: [], + local_conflicts: [ + { + code: "LOCAL_EDIT_CONFLICT", + path: "inbox/Plan.md", + conflict_copy_path: "inbox/Plan (cloud conflict).md", + }, + ], + }, + }); + + try { + requestSettingsTarget("cloud"); + await act(async () => root.render(createElement(SettingsModal))); + + const vault = host.querySelector('[data-settings-search-id="cloud-vault"]'); + const waiting = vault?.querySelector("[data-cloud-attention]"); + expect(waiting?.textContent).toContain("inbox/Plan.md"); + // The page opens on the files Review was about, not on the account at + // its top, and the keyboard is already there. + expect(document.activeElement).toBe(waiting); + expect(vi.mocked(Element.prototype.scrollIntoView).mock.contexts).toContain(vault); + } finally { + clearCloudSyncStatus(); + } + }); async function openKeymapRow(title: string): 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 859cc7f2..d832503c 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -698,6 +698,8 @@ export function SettingsModal(): JSX.Element { const completedTaskStyle = useStore((s) => s.completedTaskStyle); const setCompletedTaskStyle = useStore((s) => s.setCompletedTaskStyle); const showArchivedTasks = useStore((s) => s.showArchivedTasks); + const kanbanCardSort = useStore((s) => s.kanbanCardSort); + const setKanbanCardSort = useStore((s) => s.setKanbanCardSort); const setShowArchivedTasks = useStore((s) => s.setShowArchivedTasks); const mathRenderer = useStore((s) => s.mathRenderer); const mathFontScale = useStore((s) => s.mathFontScale); @@ -1366,15 +1368,28 @@ export function SettingsModal(): JSX.Element { const ref = useRef(null); const navSearchRef = useRef(null); + const [initialSettingsTarget] = useState(consumeSettingsTarget); + // A Cloud surface (Review or Set up on the Cloud status, Space r, the + // palette) sends the person to This vault, which the Cloud page draws only + // once its account and link have loaded. This vault takes the keyboard + // then, once: coming back to the page later opens it at its top like any + // other page. + const opensOnCloudVault = + supportsCloudSync && initialSettingsTarget === "cloud"; + const [cloudVaultReveal, setCloudVaultReveal] = useState(opensOnCloudVault); + 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 // stayed on the editor underneath and typing edited the note behind the // open window. Opening lands on the settings search, the first thing a // keyboard user reaches for. On a touch device a focused input would raise - // the on-screen keyboard over the panel, so the panel takes focus instead. - useDialogFocus(ref, isTouchPrimaryDevice() ? ref : navSearchRef); + // the on-screen keyboard over the panel, and opened for This vault the + // search is not what was asked for, so the panel holds focus instead. + useDialogFocus( + ref, + isTouchPrimaryDevice() || opensOnCloudVault ? ref : navSearchRef, + ); const settingsSearchHighlightTimerRef = useRef(null); - const [initialSettingsTarget] = useState(consumeSettingsTarget); const [activeCategory, setActiveCategory] = useState( () => initialSettingsTarget === "external-links" ? "editor" : initialSettingsTarget ?? "appearance", ); @@ -3413,6 +3428,22 @@ export function SettingsModal(): JSX.Element { "directory", ], }, + { + id: "kanban-card-sort", + title: "Kanban card order", + description: + "Order the cards in each Kanban column by hand or by due date.", + keywords: [ + "kanban", + "sort", + "order", + "due", + "date", + "deadline", + "cards", + "manual", + ], + }, { id: "show-archived-tasks", title: "Show tasks from archived notes", @@ -3459,6 +3490,22 @@ export function SettingsModal(): JSX.Element { > +
+ setKanbanCardSort(next)} + /> +
), @@ -5765,6 +5814,9 @@ export function SettingsModal(): JSX.Element { role="dialog" aria-modal="true" aria-label="Settings" + // Tells Settings apart from the dialogs that can open over it: the + // close shortcut closes Settings only when nothing sits on top. + data-settings-dialog="" tabIndex={-1} // The page another surface asked for (Review on the status bar). // The phone shells page this dialog list-first and read it to open diff --git a/packages/app-core/src/components/TasksKanban.test.ts b/packages/app-core/src/components/TasksKanban.test.ts index 5e75cd4b..624960e9 100644 --- a/packages/app-core/src/components/TasksKanban.test.ts +++ b/packages/app-core/src/components/TasksKanban.test.ts @@ -3,6 +3,7 @@ import type { VaultTask } from '@shared/tasks' import { applyColumnOrder, arrangeColumns, + compareCardsByDue, completeStatusOrder, cursorAfterCardMove, dropMutationsFor, @@ -16,6 +17,7 @@ import { type Column, folderColumns, noteLocationOf, + orderCards, FOLDER_OTHER_LABEL } from './TasksKanban' @@ -498,3 +500,49 @@ describe('forwarded and cancelled records stay off every board (#786)', () => { ]) }) }) + +// #889: a column can follow its cards' due dates instead of the order they +// were dragged into, on every board, custom status boards included. +describe('card order by due date (#889)', () => { + const dated = (content: string, due: string | undefined, sourcePath: string, taskIndex = 0): VaultTask => + card({ content, due, sourcePath, taskIndex }) + const texts = (columns: Column[], i: number): string[] => columns[i].tasks.map((t) => t.content) + + const late = dated('Late', '2026-10-20', 'inbox/a.md') + const soon = dated('Soon', '2026-10-06', 'inbox/b.md') + const undated = dated('Someday', undefined, 'inbox/c.md') + const sameDayFirst = dated('Same day, first line', '2026-10-06', 'inbox/d.md', 0) + const sameDaySecond = dated('Same day, second line', '2026-10-06', 'inbox/d.md', 3) + + it('puts the earliest due date first and cards without one last', () => { + const sorted = [undated, late, soon].sort(compareCardsByDue).map((t) => t.content) + expect(sorted).toEqual(['Soon', 'Late', 'Someday']) + }) + + it('breaks a same-day tie by note, then by the task\'s place in it', () => { + const sorted = [sameDaySecond, soon, sameDayFirst].sort(compareCardsByDue).map((t) => t.content) + expect(sorted).toEqual(['Soon', 'Same day, first line', 'Same day, second line']) + }) + + // The reporter's case: a custom status board whose column was hand-arranged + // once keeps that arrangement forever, however the due dates change. + const arranged = new Map([ + ['field:status:review', [taskIdentityKey(undated), taskIdentityKey(late), taskIdentityKey(soon)]] + ]) + const board = (): Column[] => [colWith('review', [soon, late, undated]), colWith('done', [late])] + + it('by due date, ignores the dragged arrangement of a custom status column', () => { + expect(texts(orderCards('field:status', board(), 'due', arranged), 0)).toEqual(['Soon', 'Late', 'Someday']) + }) + + it('manually, replays the dragged arrangement, which ordering by date left in place', () => { + expect(texts(orderCards('field:status', board(), 'manual', arranged), 0)).toEqual(['Someday', 'Late', 'Soon']) + }) + + it('sorts every column without touching the built ones', () => { + const built = [colWith('review', [undated, late, soon])] + const out = orderCards('field:status', built, 'due', new Map()) + expect(texts(out, 0)).toEqual(['Soon', 'Late', 'Someday']) + expect(texts(built, 0)).toEqual(['Someday', 'Late', 'Soon']) + }) +}) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 492c57f2..337217dc 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -38,8 +38,15 @@ import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/syste import { promptApp } from '../lib/prompt-requests' import type { VaultTask } from '@shared/tasks' import { groupTasks, isOverdue as isTaskOverdue, toIsoDateLocal } from '@shared/tasks' -import { normalizeKanbanFolderRoot, useStore, type KanbanGroupBy, type TaskMutation } from '../store' +import { + normalizeKanbanFolderRoot, + useStore, + type KanbanCardSort, + type KanbanGroupBy, + type TaskMutation +} from '../store' import { filterTasks } from '../lib/tasks-filter' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems } from '../lib/task-context-menu' import { ArrowUpRightIcon, PencilIcon } from './icons' @@ -94,6 +101,18 @@ export interface Column { /** Column id of the Status board's started-work bucket. */ export const IN_PROGRESS_COLUMN_ID = 'in-progress' +/** Due date first (cards without one last), then the note and the task's place + * in it, so cards on the same day keep a stable, file-like order. Columns + * are built in this order, and with cards ordered by due date (#889) every + * column stays in it. */ +export function compareCardsByDue(a: VaultTask, b: VaultTask): number { + const ad = a.due ?? '9999-12-31' + const bd = b.due ?? '9999-12-31' + 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 statusColumns(tasks: VaultTask[], today: Date): Column[] { const groups = groupTasks(tasks, today) // `[/]` is an open task everywhere else: it keeps its place in Today, on the @@ -171,17 +190,10 @@ export function priorityColumns(tasks: VaultTask[]): Column[] { else none.push(task) } // Within each column, surface overdue/today first, then by due date. - const sortByDue = (a: VaultTask, b: VaultTask): number => { - const ad = a.due ?? '9999-12-31' - const bd = b.due ?? '9999-12-31' - 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 - } - high.sort(sortByDue) - med.sort(sortByDue) - low.sort(sortByDue) - none.sort(sortByDue) + high.sort(compareCardsByDue) + med.sort(compareCardsByDue) + low.sort(compareCardsByDue) + none.sort(compareCardsByDue) return [ { id: 'high', label: 'High', tasks: high }, { id: 'med', label: 'Medium', tasks: med }, @@ -289,13 +301,6 @@ export function folderColumns( other.push(task) } - const sortByDue = (a: VaultTask, b: VaultTask): number => { - const ad = a.due ?? '9999-12-31' - const bd = b.due ?? '9999-12-31' - 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 - } const folderRank = (folder: NoteFolder): number => FOLDER_ORDER.indexOf(folder) const columns: Column[] = [...byId.entries()] .sort(([, a], [, b]) => { @@ -305,9 +310,13 @@ export function folderColumns( if (!a.dir !== !b.dir) return a.dir ? 1 : -1 return a.dir.localeCompare(b.dir, undefined, { sensitivity: 'base', numeric: true }) }) - .map(([id, entry]) => ({ id, label: entry.label, tasks: entry.tasks.sort(sortByDue) })) + .map(([id, entry]) => ({ id, label: entry.label, tasks: entry.tasks.sort(compareCardsByDue) })) if (other.length > 0) { - columns.push({ id: NO_VALUE_COLUMN_ID, label: FOLDER_OTHER_LABEL, tasks: other.sort(sortByDue) }) + columns.push({ + id: NO_VALUE_COLUMN_ID, + label: FOLDER_OTHER_LABEL, + tasks: other.sort(compareCardsByDue) + }) } return columns } @@ -360,23 +369,15 @@ function fieldColumns(tasks: VaultTask[], fieldKey: string, order: string[]): Co } } - const sortByDue = (a: VaultTask, b: VaultTask): number => { - const ad = a.due ?? '9999-12-31' - const bd = b.due ?? '9999-12-31' - 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 - } - const columns: Column[] = ordered.map((id) => ({ id, label: prettifyFieldValue(id), - tasks: (byValue.get(id) ?? []).sort(sortByDue) + tasks: (byValue.get(id) ?? []).sort(compareCardsByDue) })) columns.push({ id: NO_VALUE_COLUMN_ID, label: `No ${fieldKey}`, - tasks: noValue.sort(sortByDue) + tasks: noValue.sort(compareCardsByDue) }) return columns } @@ -519,6 +520,24 @@ export function applyColumnOrder( }) } +/** The cards inside each column: in the order they were dragged into, or by + * due date (#889). Ordering by due date leaves the dragged arrangement saved, + * so choosing Manual again brings it back. */ +export function orderCards( + groupBy: KanbanGroupBy, + columns: Column[], + cardSort: KanbanCardSort, + orderMap: Map +): Column[] { + if (cardSort === 'due') { + return columns.map((column) => ({ + ...column, + tasks: [...column.tasks].sort(compareCardsByDue) + })) + } + return applyColumnOrder(groupBy, columns, orderMap) +} + /** Reorder built columns by the user's saved arrangement (`kanbanColumnOrder`). * Columns not in the saved order keep their built position after the saved * ones (stable), so newly-discovered values still appear. The No-value bucket @@ -641,6 +660,8 @@ const POINTER_DRAG_THRESHOLD = 5 export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: Props): JSX.Element { const groupBy = useStore((s) => s.kanbanGroupBy) + const cardSort = useStore((s) => s.kanbanCardSort) + const setCardSort = useStore((s) => s.setKanbanCardSort) const setGroupBy = useStore((s) => s.setKanbanGroupBy) const kanbanColumnTitles = useStore((s) => s.kanbanColumnTitles) const setKanbanColumnTitle = useStore((s) => s.setKanbanColumnTitle) @@ -773,7 +794,7 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: folderLayout ) const savedOrder = kanbanColumnOrder[groupBy] ?? [] - const orderedColumns = applyColumnOrder( + const orderedColumns = orderCards( groupBy, arrangeColumns( built, @@ -784,6 +805,7 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: ) : savedOrder ), + cardSort, columnOrderRef.current ) return orderedColumns.map((column) => ({ @@ -792,6 +814,7 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: })) }, [ + cardSort, columnOrderVersion, groupBy, displayTasks, @@ -1141,7 +1164,10 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: const placeTaskInColumnOrder = useCallback( (task: VaultTask, targetColumnId: string, targetIndex: number | null) => { - if (targetIndex == null) return + // Ordered by due date, a card lands in its date slot; writing the board's + // current (date) order here would overwrite the arrangement Manual + // brings back. (#889) + if (targetIndex == null || useStore.getState().kanbanCardSort === 'due') return const movingKey = taskIdentityKey(task) // The drop index counts the VISIBLE cards (the DOM the user aimed at), @@ -1358,11 +1384,36 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: const cards = Array.from( target.element.querySelectorAll('[data-kanban-task-id]') ).filter((card) => card.dataset.kanbanTaskId !== drag.task.id) - const insertionIndex = cards.findIndex((card) => { - const rect = card.getBoundingClientRect() - return clientY < rect.top + rect.height / 2 - }) - const boundedIndex = insertionIndex === -1 ? cards.length : insertionIndex + let boundedIndex: number + if (useStore.getState().kanbanCardSort === 'due') { + // Ordered by due date there is no place to choose: within its own + // column the card stays put, and in another it lands in its date + // slot, which the line shows (a Today or Upcoming drop can change the + // date first). (#889) + if (target.id === drag.sourceColumnId) { + hideDropIndicator() + return null + } + const dueMutation = mutations.find( + (mutation): mutation is Extract => + mutation.kind === 'set-due' + ) + const landing = { + ...drag.task, + due: dueMutation ? (dueMutation.due ?? undefined) : drag.task.due + } + const others = ( + columnsRef.current.find((column) => column.id === target.id)?.tasks ?? [] + ).filter((task) => task.id !== drag.task.id) + const slot = others.findIndex((task) => compareCardsByDue(landing, task) < 0) + boundedIndex = slot === -1 ? others.length : slot + } else { + const insertionIndex = cards.findIndex((card) => { + const rect = card.getBoundingClientRect() + return clientY < rect.top + rect.height / 2 + }) + boundedIndex = insertionIndex === -1 ? cards.length : insertionIndex + } let y: number if (boundedIndex < cards.length) { @@ -1577,6 +1628,24 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: : 0 const focusedTask = focusedColumn?.tasks[safeCardIdx] + // Switching the order keeps the cursor on its card, wherever the new order + // puts it: the rebuild is flushed first, the way a move is. (#889) + const chooseCardSort = useCallback( + (next: KanbanCardSort): void => { + if (useStore.getState().kanbanCardSort === next) return + flushSync(() => setCardSort(next)) + if (!focusedColumn || !focusedTask) return + const cursor = cursorAfterCardMove( + columnsRef.current, + focusedColumn.id, + taskIdentityKey(focusedTask) + ) + setColIdx(cursor.colIdx) + setCardIdx(cursor.cardIdx) + }, + [focusedColumn, focusedTask, setCardSort] + ) + useEffect(() => { cardRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [safeColIdx, safeCardIdx, focusedTask?.id]) @@ -1600,6 +1669,11 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: // beat VimNav's global handler (which otherwise hijacks h/j/k/l). useEffect(() => { const handler = (e: KeyboardEvent): void => { + // A menu or dialog owns the keyboard while it is open. The card's own + // right-click menu filters as you type, and without this a filter like + // "prio" reached the board first: its `i` marked the card in progress. + // Settings and the palettes are dialogs too. + if (isOverlayOrDialogOpen()) return // While the Vim hint overlay is open it owns the keyboard; yield to it. (#151) if (document.querySelector('[data-vim-hint-overlay]')) return const active = document.activeElement as HTMLElement | null @@ -1674,6 +1748,10 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: moveFocusedCard(1) } return + case 's': + consume() + chooseCardSort(useStore.getState().kanbanCardSort === 'due' ? 'manual' : 'due') + return case '<': consume() moveFocusedColumn(-1) @@ -1730,6 +1808,7 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: window.addEventListener('keydown', handler, true) return () => window.removeEventListener('keydown', handler, true) }, [ + chooseCardSort, clearGroupByPending, columns.length, cycleGroupBy, @@ -1774,12 +1853,27 @@ export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: {kanbanFolderRoot ? `inside ${kanbanFolderRoot}` : 'each note’s folder'} )} + Order +
{vimMode ? dndEnabled - ? 'Drag or Shift+H·L move card · drag header or reorder columns · h/l · j/k · g group-by · x · Enter · right-click actions' - : 'Drag header or reorder columns · h/l column · j/k card · g group-by · x · Enter · right-click actions' + ? 'Drag or Shift+H·L move card · drag header or reorder columns · h/l · j/k · g group-by · s order · x · Enter · right-click actions' + : 'Drag header or reorder columns · h/l column · j/k card · g group-by · s order · x · Enter · right-click actions' : dndEnabled ? 'Drag to move a card · drag a header to reorder columns · ←/→ · ↑/↓ · Space · Enter · right-click actions' : 'Drag a header to reorder columns · ←/→ column · ↑/↓ card · Space · Enter · right-click actions'} diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index 61f7e967..b51d6109 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { isTasksViewActive, useStore, type TasksViewMode } from '../store' +import { isTasksViewActive, useStore, type KanbanCardSort, 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' @@ -12,7 +12,7 @@ import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/ import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems } from '../lib/task-context-menu' import { isImeComposing } from '../lib/ime' -import { isAppOverlayOpen } from '../lib/overlay-open' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' import { promptApp } from '../lib/prompt-requests' import { useToastStore } from '../lib/toast' import { findSavedTaskFilterName, savedTaskFilterNameForQuery } from '../lib/saved-task-filters' @@ -451,6 +451,26 @@ export function TasksView(): JSX.Element { ) return } + // `:order due` sorts every Kanban column by due date, `:order manual` + // brings back the dragged arrangement, and bare `:order` switches + // between the two, like `s` on the board. (#889) + if (head === 'order') { + const wanted = arg.toLowerCase() + if (wanted && wanted !== 'due' && wanted !== 'manual') { + toast('Usage: :order due | manual') + return + } + const store = useStore.getState() + const next: KanbanCardSort = wanted + ? (wanted as KanbanCardSort) + : store.kanbanCardSort === 'due' + ? 'manual' + : 'due' + store.setKanbanCardSort(next) + setViewMode('kanban') + toast(next === 'due' ? 'Kanban cards ordered by due date' : 'Kanban cards in your own order') + return + } if (head === 'delfilter' || head === 'df') { if (!arg) { toast('Usage: :delfilter ') @@ -563,9 +583,10 @@ export function TasksView(): JSX.Element { useEffect(() => { if (!isActivePanel) return const handler = (e: KeyboardEvent): void => { - // A modal/menu owns the keyboard while open — don't fire list shortcuts - // through it. (songgenqing report) - if (isAppOverlayOpen()) return + // A modal or menu owns the keyboard while open, so list shortcuts never + // fire through it (songgenqing report). Settings counts: `1`/`2`/`3` + // switched the view behind it, and `a` opened the new-task prompt. + if (isOverlayOrDialogOpen()) return // While the Vim hint overlay is open it owns the keyboard; don't let // task navigation (or Esc closing the view) steal its keys. (#151) if (document.querySelector('[data-vim-hint-overlay]')) return @@ -1026,7 +1047,7 @@ export function TasksView(): JSX.Element { ? 'h/j/k/l day · [ ] month · Tab pick · x toggle · i start · c cancel · F saved filters · drag to move · right-click actions · :q' : '←/→/↑/↓ day · Tab pick · Space toggle · drag to move · right-click actions' : vimMode - ? 'h/l column · j/k card · x toggle · i start · c cancel · Enter open · F saved filters · right-click actions · :q close' + ? 'h/l column · j/k card · x toggle · i start · c cancel · Enter open · s order · F saved filters · right-click actions · :q close' : '←/→ column · ↑/↓ card · Space toggle · Enter open · right-click actions'}
)} diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index 69f4e7d5..5a5ff8d1 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -7,7 +7,7 @@ */ import { useCallback, useMemo, useRef, useState } from 'react' import { Compartment, EditorState, type Transaction } from '@codemirror/state' -import { EditorView, drawSelection, highlightActiveLine, keymap, tooltips } from '@codemirror/view' +import { EditorView, highlightActiveLine, keymap, tooltips } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' @@ -27,6 +27,7 @@ import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { headingFolding } from '../lib/cm-heading-fold' import { editorTabSize } from '../lib/editor-tab-size' +import { cursorDrawSelection } from '../lib/cm-cursor-blink' import { templateVariableSource, TEMPLATE_VARIABLES } from '../lib/cm-template-variables' import { templateSlashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' @@ -122,7 +123,7 @@ export function TemplateEditorModal({ new Compartment().of(vimModeRef.current ? vim() : []), vimVisualHighlightExtension, history(), - drawSelection(), + cursorDrawSelection(useStore.getState().cursorBlink), editorTabSize(useStore.getState().editorTabSize), highlightActiveLine(), EditorView.lineWrapping, diff --git a/packages/app-core/src/components/editor-cursor-blink.test.ts b/packages/app-core/src/components/editor-cursor-blink.test.ts new file mode 100644 index 00000000..5eee20d1 --- /dev/null +++ b/packages/app-core/src/components/editor-cursor-blink.test.ts @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +// +// Guards #892: Blinking cursor reached only the main editor. The floating +// note, Quick Capture and external-file windows build their own CodeMirror +// from the stored prefs blob, and they, the reference pane and the template +// editor all built a bare drawSelection(), which blinks at CodeMirror's +// default whatever the preference says. + +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { act, createElement, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteContent } from '@shared/ipc' +import { useStore } from '../store' +import { ExternalFileApp } from './ExternalFileApp' +import { FloatingNoteApp } from './FloatingNoteApp' +import { PinnedReferencePane } from './PinnedReferencePane' +import { QuickCaptureApp } from './QuickCaptureApp' +import { TemplateEditorModal } from './TemplateEditorModal' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const PREFS_KEY = 'zen:prefs:v2' + +const note: NoteContent = { + path: 'inbox/Note.md', + title: 'Note', + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 10, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: 'alpha beta', + body: 'alpha beta' +} + +/** How the mounted editor's cursors blink. CodeMirror's caret layer and the + * Vim block cursor layer both copy drawSelection's cursorBlinkRate into an + * inline animation-duration, and 0ms never blinks. `block` is null with Vim + * off. */ +function cursorBlink(): { caret: string | null; block: string | null } { + const editor = document.querySelector('.cm-editor') + if (!editor) throw new Error('no editor mounted') + const duration = (selector: string): string | null => + editor.querySelector(selector)?.style.animationDuration ?? null + return { caret: duration('.cm-layer.cm-cursorLayer'), block: duration('.cm-vimCursorLayer') } +} + +const SOLID = { caret: '0ms', block: '0ms' } +const BLINKING = { caret: '1200ms', block: '1200ms' } + +let host: HTMLDivElement +let root: Root +let originalState: ReturnType + +beforeEach(() => { + localStorage.clear() + originalState = useStore.getState() + // jsdom has no layout; CodeMirror and Vim still measure the caret. + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [] + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect() + }) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + }) + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + readNote: vi.fn(async () => note), + readExternalFile: vi.fn(async () => ({ + path: '/outside/Loose.md', + name: 'Loose.md', + body: 'alpha beta' + })), + listNotes: vi.fn(async () => []), + onVaultChange: vi.fn(() => vi.fn()), + getQuickCapturePinned: vi.fn(async () => false), + platformSync: () => 'linux', + writeNote: vi.fn(async () => undefined), + writeExternalFile: vi.fn(async () => undefined), + windowClose: vi.fn() + } + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + localStorage.clear() +}) + +async function render(element: ReactElement): Promise { + await act(async () => root.render(element)) +} + +describe.each([ + ['floating note window', () => createElement(FloatingNoteApp, { notePath: note.path })], + ['Quick Capture window', () => createElement(QuickCaptureApp)], + ['external file window', () => createElement(ExternalFileApp)] +] as const)('the %s', (_, app) => { + async function open(prefs?: Record): Promise { + if (prefs) localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) + await render(app()) + } + + /** Settings saves the prefs blob in the main window; another window hears + * of it only as a `storage` event. */ + async function saveInMainWindow(prefs: Record): Promise { + const newValue = JSON.stringify(prefs) + localStorage.setItem(PREFS_KEY, newValue) + await act(async () => { + window.dispatchEvent( + new StorageEvent('storage', { key: PREFS_KEY, newValue, storageArea: localStorage }) + ) + }) + } + + it('keeps the caret and the Vim block cursor solid when blinking is off', async () => { + await open({ cursorBlink: false }) + expect(cursorBlink()).toEqual(SOLID) + }) + + it('keeps the caret solid with Vim off', async () => { + await open({ cursorBlink: false, vimMode: false }) + expect(cursorBlink()).toEqual({ caret: '0ms', block: null }) + }) + + it('still blinks by default', async () => { + await open() + expect(cursorBlink()).toEqual(BLINKING) + }) + + it('follows the preference when it changes while the window is open', async () => { + await open({ cursorBlink: true }) + await saveInMainWindow({ cursorBlink: false }) + expect(cursorBlink()).toEqual(SOLID) + await saveInMainWindow({ cursorBlink: true }) + expect(cursorBlink()).toEqual(BLINKING) + }) +}) + +describe('main-window editors', () => { + it('keeps the reference pane cursor solid and follows the preference', async () => { + useStore.setState({ + vault: { root: '/vault', name: 'Vault' }, + selectedPath: note.path, + pinnedRefPath: note.path, + pinnedRefKind: 'note', + pinnedRefVisible: true, + pinnedRefMode: 'edit', + noteRefs: {}, + noteContents: { [note.path]: note }, + noteDirty: {}, + vimMode: true, + cursorBlink: false + }) + await render(createElement(PinnedReferencePane)) + expect(cursorBlink()).toEqual(SOLID) + + await act(async () => useStore.setState({ cursorBlink: true })) + expect(cursorBlink()).toEqual(BLINKING) + }) + + it('keeps the template editor cursor solid', async () => { + useStore.setState({ vimMode: true, cursorBlink: false }) + await render(createElement(TemplateEditorModal, { onClose: vi.fn() })) + expect(cursorBlink()).toEqual(SOLID) + }) + + it('builds drawSelection only through the cursor blink helper', () => { + // A bare drawSelection() blinks no matter the preference, which is how + // five editors came to ignore it. + const src = join(dirname(fileURLToPath(import.meta.url)), '..') + const sources = readdirSync(src, { recursive: true, encoding: 'utf8' }).filter( + (file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file) + ) + // A scan that stopped finding the sources would pass on nothing. + expect(sources.length).toBeGreaterThan(100) + const bare = sources.filter( + (file) => + file !== join('lib', 'cm-cursor-blink.ts') && + /\bdrawSelection\(/.test(readFileSync(join(src, file), 'utf8')) + ) + expect(bare).toEqual([]) + }) +}) diff --git a/packages/app-core/src/editor.test.ts b/packages/app-core/src/editor.test.ts index 1498fdde..3a071468 100644 --- a/packages/app-core/src/editor.test.ts +++ b/packages/app-core/src/editor.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorSelection, EditorState, type TransactionSpec } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ImportedAsset } from '@bridge-contract/ipc' @@ -55,6 +55,23 @@ describe('public editor attachment insertion', () => { expect(s.view.hasFocus).toBe(true) }) + it('lists the saved files before their embeds go in, so none reads as not on this device', async () => { + const s = await setup() + Object.assign(window.zen, { listAssets: vi.fn(async () => []), hasAssetsDir: vi.fn(async () => true) }) + s.useStore.setState({ assetFiles: [], assetFilesListed: true }) + const listedAtInsert: string[][] = [] + const dispatch = s.view.dispatch.bind(s.view) + vi.spyOn(s.view, 'dispatch').mockImplementation((...specs: TransactionSpec[]) => { + listedAtInsert.push(s.useStore.getState().assetFiles.map((listed) => listed.path)) + dispatch(...specs) + }) + + await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file(), file('two.pdf')]) + + expect(listedAtInsert).toEqual([['assets/one.pdf', 'assets/two.pdf']]) + expect(s.view.state.doc.toString()).toContain('![[assets/two.pdf]]') + }) + it('preserves selected text for file attachment and replaces it for image paste', async () => { const s = await setup('before selected after', 7, 15) await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file()]) diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index 8c83b05d..4eb07cfe 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -225,6 +225,9 @@ async function importAndInsert( const before = from > 0 ? document.sliceString(from - 1, from) : '' const after = document.sliceString(to, to + 1) const insert = formatImportedAssetsForInsertion(assets, before, after) + // Listed before the embeds land, or each would say its file is not on + // this device until the host's next listing. + useStore.getState().indexImportedAssets(assets) view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } }) view.focus() return { status: 'inserted', assets } diff --git a/packages/app-core/src/lib/browse-actions.ts b/packages/app-core/src/lib/browse-actions.ts index c268d8bb..15544176 100644 --- a/packages/app-core/src/lib/browse-actions.ts +++ b/packages/app-core/src/lib/browse-actions.ts @@ -68,6 +68,18 @@ function start(host: BrowseActionHost, directory: string, allowRoot = false) { return { isCurrent, targetExists } } +/** + * Finish a folder rename, move or delete. The native shells that call these + * actions have no file watcher, so nothing else reports the files the folder + * carried: without a re-read, Browse keeps listing them under the old + * directory until the next foreground resync. + */ +function settleFolderFiles(isCurrent: () => boolean): BrowseActionResult { + if (!isCurrent()) return 'stale' + void useStore.getState().refreshAssets() + return 'completed' +} + function validateName(value: string): string | null { const name = value.trim() if (!name || name === '.' || name === '..' || /[\\/\u0000-\u001f]/.test(name)) @@ -131,7 +143,7 @@ export async function requestRenameBrowseFolder( await useStore .getState() .renameFolder('inbox', directory, parent ? `${parent}/${name}` : name, isCurrent) - return isCurrent() ? 'completed' : 'stale' + return settleFolderFiles(isCurrent) } finally { pending = false } @@ -171,7 +183,7 @@ export async function requestMoveBrowseDirectory( await useStore .getState() .renameFolder('inbox', directory, parent ? `${parent}/${leaf}` : leaf, isCurrent) - return isCurrent() ? 'completed' : 'stale' + return settleFolderFiles(isCurrent) } finally { pending = false } @@ -199,7 +211,7 @@ export async function requestDeleteBrowseDirectory( if (!confirmed) return 'cancelled' if (!isCurrent() || !targetExists()) return 'stale' await useStore.getState().deleteFolder('inbox', directory, isCurrent) - return isCurrent() ? 'completed' : 'stale' + return settleFolderFiles(isCurrent) } finally { pending = false } diff --git a/packages/app-core/src/lib/close-shortcut.test.ts b/packages/app-core/src/lib/close-shortcut.test.ts new file mode 100644 index 00000000..371dbb16 --- /dev/null +++ b/packages/app-core/src/lib/close-shortcut.test.ts @@ -0,0 +1,211 @@ +// @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 { Modal } from '../components/ui/Modal' +import { + readSavedKeymapOverrides, + resolveCloseShortcut, + type CloseShortcutState +} from './close-shortcut' +import { UNBOUND_BINDING } from './keymaps' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { platformSync: () => platform } + }) +} + +function keydown(init: KeyboardEventInit): KeyboardEvent { + return new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) +} + +const cmdW = (): KeyboardEvent => keydown({ key: 'w', code: 'KeyW', metaKey: true }) +const ctrlW = (): KeyboardEvent => keydown({ key: 'w', code: 'KeyW', ctrlKey: true }) + +function state(overrides: Partial = {}): CloseShortcutState { + return { + keymapOverrides: {}, + vimMode: true, + selectedPath: 'Inbox/Note.md', + settingsOpen: false, + searchOpen: false, + vaultTextSearchOpen: false, + commandPaletteOpen: false, + bufferPaletteOpen: false, + templatePaletteOpen: false, + embedDrawingPaletteOpen: false, + outlinePaletteOpen: false, + ...overrides + } +} + +/** The Settings panel as SettingsModal draws it (pinned by SettingsModal.test). */ +function showSettingsPanel(): void { + const panel = document.createElement('div') + panel.setAttribute('role', 'dialog') + panel.setAttribute('aria-modal', 'true') + panel.setAttribute('data-settings-dialog', '') + document.body.append(panel) +} + +const roots: Root[] = [] + +/** A real dialog from the shared Modal shell, the way every dialog and palette opens. */ +function showModalDialog(): void { + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + roots.push(root) + act(() => { + root.render( + createElement(Modal, { + onClose: vi.fn(), + children: createElement('button', null, 'Cancel') + }) + ) + }) +} + +function showContextMenu(): void { + const menu = document.createElement('div') + menu.setAttribute('data-ctx-menu', '') + document.body.append(menu) +} + +beforeEach(() => setPlatform('darwin')) + +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()) + document.body.replaceChildren() +}) + +describe('Close active tab in the main window (#893)', () => { + it('closes the active tab when nothing is in front of it', () => { + expect(resolveCloseShortcut(cmdW(), state())).toBe('close-tab') + }) + + it('closes the window once no tab is left (#192)', () => { + expect(resolveCloseShortcut(cmdW(), state({ selectedPath: null }))).toBe('close-window') + }) + + it('closes Settings instead of the tab behind it', () => { + showSettingsPanel() + expect(resolveCloseShortcut(cmdW(), state({ settingsOpen: true }))).toBe('close-settings') + }) + + it('closes Settings instead of the window when no tab is open', () => { + showSettingsPanel() + expect( + resolveCloseShortcut(cmdW(), state({ settingsOpen: true, selectedPath: null })) + ).toBe('close-settings') + }) + + it('leaves Settings open while a dialog sits on top of it', () => { + showSettingsPanel() + showModalDialog() + expect(resolveCloseShortcut(cmdW(), state({ settingsOpen: true }))).toBe('keep') + }) + + it.each([ + 'searchOpen', + 'vaultTextSearchOpen', + 'commandPaletteOpen', + 'bufferPaletteOpen', + 'templatePaletteOpen', + 'embedDrawingPaletteOpen', + 'outlinePaletteOpen' + ] as const)('never reaches through an open palette (%s)', (flag) => { + expect(resolveCloseShortcut(cmdW(), state({ [flag]: true }))).toBe('keep') + expect(resolveCloseShortcut(cmdW(), state({ [flag]: true, selectedPath: null }))).toBe('keep') + }) + + it('never reaches through an open dialog to the tab or the window behind it', () => { + showModalDialog() + expect(resolveCloseShortcut(cmdW(), state())).toBe('keep') + expect(resolveCloseShortcut(cmdW(), state({ selectedPath: null }))).toBe('keep') + }) + + it('never reaches through an open context menu', () => { + showContextMenu() + expect(resolveCloseShortcut(cmdW(), state())).toBe('keep') + }) + + it('follows a rebind, Settings included', () => { + showSettingsPanel() + const keymapOverrides = { 'global.closeActiveTab': 'Shift+Mod+W' } + expect(resolveCloseShortcut(cmdW(), state({ keymapOverrides, settingsOpen: true }))).toBeNull() + expect( + resolveCloseShortcut( + keydown({ key: 'W', code: 'KeyW', metaKey: true, shiftKey: true }), + state({ keymapOverrides, settingsOpen: true }) + ) + ).toBe('close-settings') + }) + + it('does nothing anywhere once the action is unbound', () => { + const keymapOverrides = { 'global.closeActiveTab': UNBOUND_BINDING } + expect(resolveCloseShortcut(cmdW(), state({ keymapOverrides }))).toBeNull() + showSettingsPanel() + expect(resolveCloseShortcut(cmdW(), state({ keymapOverrides, settingsOpen: true }))).toBeNull() + }) + + it('leaves Ctrl+W alone on macOS, where the shortcut is Cmd+W', () => { + showSettingsPanel() + expect(resolveCloseShortcut(ctrlW(), state({ settingsOpen: true }))).toBeNull() + }) + + it('matches by key position on a non-Latin layout', () => { + showSettingsPanel() + const cyrillicCmdW = keydown({ key: 'ц', code: 'KeyW', metaKey: true }) + expect(resolveCloseShortcut(cyrillicCmdW, state({ settingsOpen: true }))).toBe('close-settings') + }) + + describe('on Linux and Windows (Mod is Ctrl)', () => { + beforeEach(() => setPlatform('linux')) + + it('keeps Ctrl+W as the Vim pane prefix while a tab is open', () => { + expect(resolveCloseShortcut(ctrlW(), state())).toBe('vim') + }) + + it('closes the tab with Vim mode off', () => { + expect(resolveCloseShortcut(ctrlW(), state({ vimMode: false }))).toBe('close-tab') + }) + + it('closes the window with Vim mode on once no tab is left (#192)', () => { + expect(resolveCloseShortcut(ctrlW(), state({ selectedPath: null }))).toBe('close-window') + }) + + it('closes Settings even with Vim mode on, where the pane prefix has nothing to act on', () => { + showSettingsPanel() + expect(resolveCloseShortcut(ctrlW(), state({ settingsOpen: true }))).toBe('close-settings') + }) + + it('never closes the window behind a palette', () => { + expect( + resolveCloseShortcut(ctrlW(), state({ searchOpen: true, selectedPath: null })) + ).toBe('keep') + }) + }) +}) + +describe('readSavedKeymapOverrides', () => { + afterEach(() => localStorage.clear()) + + it('reads what the main window saved, an unbind included', () => { + localStorage.setItem( + 'zen:prefs:v2', + JSON.stringify({ keymapOverrides: { 'global.closeActiveTab': UNBOUND_BINDING } }) + ) + expect(readSavedKeymapOverrides()).toEqual({ 'global.closeActiveTab': UNBOUND_BINDING }) + }) + + it('falls back to the default bindings when the saved prefs are unreadable', () => { + localStorage.setItem('zen:prefs:v2', '{not json') + expect(readSavedKeymapOverrides()).toEqual({}) + }) +}) diff --git a/packages/app-core/src/lib/close-shortcut.ts b/packages/app-core/src/lib/close-shortcut.ts new file mode 100644 index 00000000..2fb0f410 --- /dev/null +++ b/packages/app-core/src/lib/close-shortcut.ts @@ -0,0 +1,153 @@ +import { useEffect, useRef } from 'react' +import { + matchesSequenceToken, + matchesShortcut, + normalizeKeymapOverrides, + type KeymapOverrides +} from './keymaps' +import { isAppOverlayOpen } from './overlay-open' + +const PREFS_KEY = 'zen:prefs:v2' + +/** + * What the Close active tab shortcut (Mod+W unless rebound) does in the main + * window at the moment it is pressed. + * + * It closes the frontmost window, the way Cmd+W does everywhere on the Mac. + * Settings counts as one, because every other Mac app gives Settings its own + * window that Cmd+W closes (#893). A dialog, palette or menu is not a window: + * while one is open the shortcut is spent on nothing, so it can never close + * the tab hidden behind it, or the whole window once no tab is left. Escape + * stays the way out of those. + */ +export type CloseShortcutAction = + | 'close-settings' + | 'keep' + | 'vim' + | 'close-tab' + | 'close-window' + +export interface CloseShortcutState { + keymapOverrides: KeymapOverrides + vimMode: boolean + selectedPath: string | null + settingsOpen: boolean + searchOpen: boolean + vaultTextSearchOpen: boolean + commandPaletteOpen: boolean + bufferPaletteOpen: boolean + templatePaletteOpen: boolean + embedDrawingPaletteOpen: boolean + outlinePaletteOpen: boolean +} + +/** + * True while a dialog or menu other than Settings is open. The shared Modal + * shell marks every panel it draws aria-modal, which covers the dialogs and + * palettes that carry no data-* hook of their own (folder pickers, Publish, + * the workflow step picker); context menus carry data-ctx-menu. Settings draws + * its own panel and tells itself apart with data-settings-dialog. + */ +export function isDialogOrMenuOpen(): boolean { + if (typeof document === 'undefined') return false + return ( + isAppOverlayOpen() || + document.querySelector('[aria-modal="true"]:not([data-settings-dialog])') !== null + ) +} + +function isPaletteOpen(state: CloseShortcutState): boolean { + // The store flags, not only the DOM: a palette is lazily loaded, and a key + // pressed before its chunk mounts must not land on the tab behind it. + return ( + state.searchOpen || + state.vaultTextSearchOpen || + state.commandPaletteOpen || + state.bufferPaletteOpen || + state.templatePaletteOpen || + state.embedDrawingPaletteOpen || + state.outlinePaletteOpen + ) +} + +export function resolveCloseShortcut( + event: KeyboardEvent, + state: CloseShortcutState +): CloseShortcutAction | null { + const overrides = state.keymapOverrides + if (!matchesShortcut(event, overrides, 'global.closeActiveTab')) return null + if (isPaletteOpen(state) || isDialogOrMenuOpen()) return 'keep' + // Ahead of the Vim guard below: with Settings in front, the pane prefix has + // no pane to act on, so Ctrl+W closes Settings on Linux and Windows too. + if (state.settingsOpen) return 'close-settings' + // On Linux/Windows `Mod+W` (close tab) resolves to Ctrl+W, which is also + // the vim pane-focus prefix (`hjkl`) and insert-mode word delete. + // When vim mode is on AND a tab is open, reserve Ctrl+W for vim (close + // tabs via :q / :bd / the palette). With no tab open the prefix has + // nothing to act on, so fall through and close the window. On macOS + // close-tab is Cmd+W, so the vim guard never matches there. + const hasActiveTab = !!state.selectedPath + if (state.vimMode && hasActiveTab && matchesSequenceToken(event, overrides, 'vim.panePrefix')) { + return 'vim' + } + // No tab left to close: close the window, matching native Cmd+W (macOS) / + // Ctrl+W behavior even with vim mode on (#192). + return hasActiveTab ? 'close-tab' : 'close-window' +} + +/** + * The keymap overrides the main window last saved. A floating note, Quick + * Capture and a standalone file window never hydrate the store, but they share + * its localStorage, so a rebind or an unbind made in Settings reaches them + * through the same prefs blob. + */ +export function readSavedKeymapOverrides(): KeymapOverrides { + try { + const raw = localStorage.getItem(PREFS_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as { keymapOverrides?: unknown } | null + return normalizeKeymapOverrides(parsed?.keymapOverrides) + } catch { + return {} + } +} + +/** + * Close a window that has no tabs (a floating note, Quick Capture, a + * standalone file) on the Close active tab shortcut, through the same path as + * its close button. Its one note is its only tab, so closing that tab closes + * the window, as the main window does once no tab is left (#192). The binding + * is read the way the main window reads it, by key position on non-Latin + * layouts and Cmd on macOS but Ctrl elsewhere, and a rebind or an unbind + * applies here too (#893). A dialog or menu open in the window, or whatever + * `isBlocked` reports, keeps the key, as it does in the main window. + */ +export function useCloseWindowShortcut(isBlocked?: () => boolean): void { + const isBlockedRef = useRef(isBlocked) + useEffect(() => { + isBlockedRef.current = isBlocked + }, [isBlocked]) + + useEffect(() => { + let overrides = readSavedKeymapOverrides() + // Quick Capture is hidden, never closed, so it lives for the whole + // session: a binding changed meanwhile must reach it without a restart. + const onStorage = (event: StorageEvent): void => { + if (event.key === null || event.key === PREFS_KEY) { + overrides = readSavedKeymapOverrides() + } + } + const onKeyDown = (event: KeyboardEvent): void => { + if (!matchesShortcut(event, overrides, 'global.closeActiveTab')) return + event.preventDefault() + if (isDialogOrMenuOpen() || isBlockedRef.current?.()) return + window.zen.windowClose() + } + window.addEventListener('storage', onStorage) + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('storage', onStorage) + window.removeEventListener('keydown', onKeyDown) + } + }, []) +} diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 06532f32..e77793d6 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -15,6 +15,7 @@ import { type CloudAutoSyncControllerOptions, type CloudAutoSyncReason, } from "@zennotes/shared-domain/cloud-auto-sync"; +import { formatCloudBytes } from "@zennotes/shared-domain/cloud-bytes"; import { cloudSyncPathKey, shouldSyncVaultPath, @@ -1163,15 +1164,6 @@ export function cloudSyncAttentionItems( return items; } -export function formatCloudBytes(bytes: number): string { - if (bytes < 1_000) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"]; - let value = bytes / 1_000; - let unit = units[0]; - for (const candidate of units.slice(1)) { - if (value < 1_000) break; - value /= 1_000; - unit = candidate; - } - return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`; -} +// The hosts word a refused backup's size before it crosses IPC as text, so +// the formatter lives in shared-domain; this keeps the app-core import path. +export { formatCloudBytes } from "@zennotes/shared-domain/cloud-bytes"; diff --git a/packages/app-core/src/lib/cm-cursor-blink.ts b/packages/app-core/src/lib/cm-cursor-blink.ts new file mode 100644 index 00000000..f565ed9a --- /dev/null +++ b/packages/app-core/src/lib/cm-cursor-blink.ts @@ -0,0 +1,50 @@ +import { Compartment, type Extension } from '@codemirror/state' +import { ViewPlugin, drawSelection } from '@codemirror/view' + +// CodeMirror's own default, so a blinking cursor looks as it always has. +const BLINK_RATE_MS = 1200 + +/** + * drawSelection set from the Blinking cursor preference. The drawn caret and + * the Vim block cursor both take their rate from this config, and 0 holds them + * solid. Every editor builds its drawSelection here: a bare drawSelection() + * blinks whatever the user chose, which is how the floating, Quick Capture and + * external-file windows came to ignore it (#160, #892). + */ +export function cursorDrawSelection(blink: boolean): Extension { + return drawSelection({ cursorBlinkRate: blink ? BLINK_RATE_MS : 0 }) +} + +/** + * cursorDrawSelection for a window that seeds its editor from the stored prefs + * blob instead of the store (floating note, Quick Capture, external file). + * Settings saves the blob in the main window, and another window learns of + * that only through the `storage` event, so the change lands here while the + * window is open. Quick Capture is hidden rather than closed: reading the blob + * only at mount would hold its first value all session. + */ +export function storedCursorDrawSelection( + prefsKey: string, + readBlink: () => boolean +): Extension { + const compartment = new Compartment() + const initial = readBlink() + return [ + compartment.of(cursorDrawSelection(initial)), + ViewPlugin.define((view) => { + let blink = initial + const onStorage = (event: StorageEvent): void => { + // A null key is another window clearing localStorage. + if (event.key !== null && event.key !== prefsKey) return + const next = readBlink() + // Every preference saves the whole blob, so most of these events are + // about another one. + if (next === blink) return + blink = next + view.dispatch({ effects: compartment.reconfigure(cursorDrawSelection(blink)) }) + } + window.addEventListener('storage', onStorage) + return { destroy: () => window.removeEventListener('storage', onStorage) } + }) + ] +} diff --git a/packages/app-core/src/lib/cm-live-preview-media.test.ts b/packages/app-core/src/lib/cm-live-preview-media.test.ts index 63fc88aa..a75afe7e 100644 --- a/packages/app-core/src/lib/cm-live-preview-media.test.ts +++ b/packages/app-core/src/lib/cm-live-preview-media.test.ts @@ -17,6 +17,7 @@ const store = vi.hoisted(() => ({ state: { activeNote: null as { path: string } | null, assetFiles: [] as Array<{ path: string }>, + assetFilesListed: false, excalidrawPreviewVersion: 0, noteRefs: {}, notes: [], @@ -89,6 +90,19 @@ function notices(view: EditorView): string[] { return [...view.dom.querySelectorAll('[data-cloud-sync-notice]')].map((el) => el.textContent ?? '') } +/** A drawn picture. CodeMirror's own `img.cm-widgetBuffer` cursor helpers sit + * beside every widget and are not one. */ +const PICTURE = 'img:not(.cm-widgetBuffer)' + +/** Each embed drawn as "not on this device": its kind, its href, its line. */ +function missing(view: EditorView): Array<[string, string, string]> { + return [...view.dom.querySelectorAll('[data-local-asset-missing]')].map((card) => [ + card.dataset.localAssetMissing ?? '', + card.dataset.localAssetHref ?? '', + card.querySelector('[role="note"]')?.textContent ?? '' + ]) +} + beforeEach(() => { ;(window as unknown as { zen: unknown }).zen = { resolveVaultAssetUrl: (_root: string, rel: string) => `zen-asset://vault/${rel}`, @@ -103,6 +117,8 @@ beforeEach(() => { { path: 'assets/doc.pdf' }, { path: 'assets/archive.zip' } ] + // The app has read the vault's files, as it has by the time a note is open. + store.state.assetFilesListed = true clearCloudSyncStatus() }) @@ -174,18 +190,15 @@ describe('live preview: audio and video embeds', () => { expect(view.dom.querySelector('.local-file-attachment')).toBeNull() }) - it('draws a missing file\'s player from the fallback URL, as a missing image keeps its frame', () => { - const doc = 'Top\n\n![[assets/gone.mp4]]\n\nEnd' - const view = mountEditor(doc, 0) - expect(view.dom.querySelector('video')?.getAttribute('src')).toBe('zen-asset://note/assets/gone.mp4') - }) - it('leaves the line as source until the asset list arrives', () => { store.state.assetFiles = [] - const doc = 'Top\n\n![[assets/clip.mp4]]\n\nEnd' + store.state.assetFilesListed = false + const doc = 'Top\n\n![[assets/clip.mp4]]\n\n![[assets/gone.mp4]]\n\nEnd' const view = mountEditor(doc, 0) expect(view.dom.querySelector('video')).toBeNull() + expect(missing(view)).toEqual([]) expect(view.dom.textContent).toContain('![[assets/clip.mp4]]') + expect(view.dom.textContent).toContain('![[assets/gone.mp4]]') }) it('puts the caret on the embed line from its action', () => { @@ -201,6 +214,114 @@ describe('live preview: audio and video embeds', () => { }) }) +describe('live preview: an embed whose file is not on this device', () => { + // A file over Cloud's per-file limit never leaves the device that has it. + // Every other device gets the note and its embed, and no file to play. + it('says so where the player would be, instead of drawing a player with nothing to play', () => { + const doc = 'Top\n\n![[assets/gone.mp4]]\n\nEnd' + const view = mountEditor(doc, 0) + + expect(view.dom.querySelector('video')).toBeNull() + expect(missing(view)).toEqual([['video', 'assets/gone.mp4', "gone.mp4 isn't on this device."]]) + const card = view.dom.querySelector('[data-local-asset-missing]')! + expect(card.querySelector('[role="note"]')?.classList.contains('text-warning')).toBe(true) + // Laid out as the player was: the source hides off the cursor line. + expect(view.dom.textContent).not.toContain('![[assets/gone.mp4]]') + expect(lineOf(card).classList.contains('cm-image-embed-line')).toBe(true) + }) + + it('says so for audio, pictures and PDFs, in each spelling those embeds take', () => { + const doc = [ + 'Top', + '', + '![[assets/gone.mp3]]', + '', + '![](assets/gone%20clip.mp4)', + '', + '![[assets/gone.png]]', + '', + '![A chart](assets/chart.png)', + '', + '![[assets/gone.pdf]]', + '', + '[The spec](assets/spec.pdf)', + '', + '![](assets/scan.pdf)', + '', + 'End' + ].join('\n') + const view = mountEditor(doc, 0) + + expect(view.dom.querySelector(`audio, video, ${PICTURE}, iframe, .local-file-attachment`)).toBeNull() + expect(view.dom.querySelector('.local-asset-pinned-ref')).toBeNull() + expect(missing(view)).toEqual([ + ['audio', 'assets/gone.mp3', "gone.mp3 isn't on this device."], + ['video', 'assets/gone%20clip.mp4', "gone clip.mp4 isn't on this device."], + ['image', 'assets/gone.png', "gone.png isn't on this device."], + ['image', 'assets/chart.png', "chart.png isn't on this device."], + ['pdf', 'assets/gone.pdf', "gone.pdf isn't on this device."], + ['pdf', 'assets/spec.pdf', "spec.pdf isn't on this device."], + ['pdf', 'assets/scan.pdf', "scan.pdf isn't on this device."] + ]) + }) + + it('keeps the chip of any other attachment: the vault listing leaves out files a chip can still name', () => { + const doc = 'Top\n\n![[assets/gone.zip]]\n\nEnd' + const view = mountEditor(doc, 0) + expect(missing(view)).toEqual([]) + expect(view.dom.querySelector('.local-file-attachment')?.textContent).toContain('gone.zip') + }) + + it('says so in a vault whose listing holds no files at all', () => { + store.state.assetFiles = [] + const doc = 'Top\n\n![[assets/gone.mp4]]\n\n![[assets/gone.png]]\n\nEnd' + const view = mountEditor(doc, 0) + expect(view.dom.querySelector(`video, ${PICTURE}`)).toBeNull() + expect(missing(view).map(([kind, href]) => [kind, href])).toEqual([ + ['video', 'assets/gone.mp4'], + ['image', 'assets/gone.png'] + ]) + }) + + it('trusts only a listing read from the vault: a list seeded by hand keeps the player', () => { + // The share viewer and PDF export hand the store a list of their own and + // serve the files the list leaves out from the guessed URL. + store.state.assetFilesListed = false + const doc = 'Top\n\n![[assets/gone.mp4]]\n\nEnd' + const view = mountEditor(doc, 0) + expect(missing(view)).toEqual([]) + expect(view.dom.querySelector('video')?.getAttribute('src')).toBe('zen-asset://note/assets/gone.mp4') + }) + + it('shows the source above the notice on the cursor line, and the player once the file arrives', () => { + const doc = 'Top\n\n![[assets/gone.mp4]]\n\nEnd' + const view = mountEditor(doc, 0) + + view.dispatch({ selection: { anchor: doc.indexOf('![[') + 3 } }) + expect(view.dom.textContent).toContain('![[assets/gone.mp4]]') + expect(missing(view)).toHaveLength(1) + expect(lineOf(view.dom.querySelector('[data-local-asset-missing]')!).classList.contains('cm-image-embed-line')).toBe(false) + + store.state.assetFiles = [...store.state.assetFiles, { path: 'assets/gone.mp4' }] + view.dispatch({ selection: { anchor: doc.length } }) + + expect(missing(view)).toEqual([]) + expect(view.dom.querySelector('video')?.getAttribute('src')).toBe('zen-asset://vault/assets/gone.mp4') + }) + + it("puts the caret on the embed line from the notice's action", () => { + const doc = 'Above\n\n![[assets/gone.mp4]]\n\nBelow' + const view = mountEditor(doc, 0) + const edit = view.dom.querySelector('[data-local-asset-missing] .local-asset-embed-edit') + expect(edit?.textContent).toBe('') + + edit!.click() + + expect(view.state.selection.main.head).toBe(doc.indexOf('![[')) + expect(view.dom.textContent).toContain('![[assets/gone.mp4]]') + }) +}) + describe('live preview: the too-large-for-Cloud notice', () => { it('says so under the oversized video and under nothing else', () => { useCloudSyncStatusStore.setState({ lastSummary: run(tooLarge('assets/clip.mp4')) }) diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index 167703f9..75b49667 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -12,12 +12,15 @@ import { useStore } from '../store' import { buildAttachmentChip, buildEmbed, + buildMissingAssetNotice, classifyLocalAssetHref, hrefFragment, + isMissingAssetKind, localAssetLabel, resolveAssetVaultRelativePath, - resolveLocalAssetUrl, - setCloudSyncNotice + resolveLocalAsset, + setCloudSyncNotice, + type MissingAssetKind } from './local-assets' import { useCloudSyncStatusStore } from './cloud-auto-sync' import { oversizedCloudFileNotice, oversizedCloudFiles } from './cloud-oversized-files' @@ -106,6 +109,9 @@ type ParsedImage = { alt: string href: string resolvedUrl: string + /** No file in the vault's listing answers the href: the line says the + * picture is not on this device instead of framing nothing. */ + missing: boolean /** Asset mtime, part of the image cache key so an edited file reloads. (#472) */ version: number /** Obsidian-style `|600` / `|600x400` size hint from the label. (#570) */ @@ -117,6 +123,8 @@ type ParsedPdf = { label: string href: string resolvedUrl: string + /** As on `ParsedImage`, for the document. */ + missing: boolean } function decodeURIComponentSafe(value: string | undefined): string { @@ -269,13 +277,14 @@ function parseStandaloneLocalImage(lineText: string): ParsedImage | null { if (fromMarkdown) { const href = (fromMarkdown[2] ?? fromMarkdown[3] ?? '').trim() if (classifyLocalAssetHref(href) !== 'image') return null - const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) - if (!resolvedUrl) return null + const asset = resolveLocalAsset(state.vault?.root, state.activeNote?.path, href) + if (!asset) return null const { alt, size } = splitEmbedLabel(fromMarkdown[1], 'markdown') return { alt, href, - resolvedUrl, + resolvedUrl: asset.url, + missing: asset.missing, version: assetVersionFor(href), width: size?.width, height: size?.height @@ -286,13 +295,14 @@ function parseStandaloneLocalImage(lineText: string): ParsedImage | null { if (!fromEmbed) return null const href = (fromEmbed[1] ?? '').trim() if (classifyLocalAssetHref(href) !== 'image') return null - const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) - if (!resolvedUrl) return null + const asset = resolveLocalAsset(state.vault?.root, state.activeNote?.path, href) + if (!asset) return null const { alt, size } = splitEmbedLabel(fromEmbed[2], 'wikilink') return { alt, href, - resolvedUrl, + resolvedUrl: asset.url, + missing: asset.missing, version: assetVersionFor(href), width: size?.width, height: size?.height @@ -305,12 +315,13 @@ function parseStandaloneLocalPdf(lineText: string): ParsedPdf | null { if (fromMarkdown) { const href = (fromMarkdown[2] ?? fromMarkdown[3] ?? '').trim() if (classifyLocalAssetHref(href) !== 'pdf') return null - const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) - if (!resolvedUrl) return null + const asset = resolveLocalAsset(state.vault?.root, state.activeNote?.path, href) + if (!asset) return null return { label: (fromMarkdown[1] ?? '').trim(), href, - resolvedUrl + resolvedUrl: asset.url, + missing: asset.missing } } @@ -318,12 +329,13 @@ function parseStandaloneLocalPdf(lineText: string): ParsedPdf | null { if (!fromEmbed) return null const href = (fromEmbed[1] ?? '').trim() if (classifyLocalAssetHref(href) !== 'pdf') return null - const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) - if (!resolvedUrl) return null + const asset = resolveLocalAsset(state.vault?.root, state.activeNote?.path, href) + if (!asset) return null return { label: (fromEmbed[2] ?? '').trim(), href, - resolvedUrl + resolvedUrl: asset.url, + missing: asset.missing } } @@ -333,13 +345,15 @@ type ParsedMedia = { resolvedUrl: string /** Vault-relative path, null when no file in the vault answers the href. */ assetPath: string | null + /** As on `ParsedImage`, for the player. */ + missing: boolean } // A standalone audio or video embed, in either spelling: `![[clip.mp4]]` or -// `![](clip.mp4)`, the same two the image widget reads. A missing file still -// gets its player, from the resolver's fallback URL, as a missing image still -// gets its frame; before the asset list arrives there is no URL to trust and -// the line stays source until the list lands and the plugin redraws. +// `![](clip.mp4)`, the same two the image widget reads. A file the vault's +// listing does not have is drawn as the line saying so, as a missing picture +// or PDF is; before the asset list arrives there is no URL to trust and the +// line stays source until the list lands and the plugin redraws. function parseStandaloneLocalMedia(lineText: string, notePath: string | null): ParsedMedia | null { const embed = lineText.match(STANDALONE_OBSIDIAN_EMBED_RE) const md = embed ? null : lineText.match(STANDALONE_IMAGE_RE) @@ -347,16 +361,18 @@ function parseStandaloneLocalMedia(lineText: string, notePath: string | null): P if (!href) return null const kind = classifyLocalAssetHref(href) if (kind !== 'audio' && kind !== 'video') return null - const root = useStore.getState().vault?.root - const resolvedUrl = resolveLocalAssetUrl(root, notePath, href) - if (!resolvedUrl) return null - return { kind, href, resolvedUrl, assetPath: resolveAssetVaultRelativePath(root, notePath, href) } + const asset = resolveLocalAsset(useStore.getState().vault?.root, notePath, href) + if (!asset) return null + return { kind, href, resolvedUrl: asset.url, assetPath: asset.path, missing: asset.missing } } type ParsedAttachment = { href: string resolvedUrl: string name: string + /** Set when the file is not on this device and its kind says so (a PDF in + * image syntax); any other attachment keeps its chip. */ + missingKind: MissingAssetKind | null } // A standalone non-previewable attachment embed. Two forms: @@ -388,10 +404,11 @@ function parseStandaloneLocalAttachment(lineText: string): ParsedAttachment | nu return null } const state = useStore.getState() - const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) - if (!resolvedUrl) return null + const asset = resolveLocalAsset(state.vault?.root, state.activeNote?.path, href) + if (!asset) return null const name = alt || decodeURIComponentSafe(href.split('/').filter(Boolean).pop()) || 'Attachment' - return { href, resolvedUrl, name } + const missingKind = asset.missing && isMissingAssetKind(kind) ? kind : null + return { href, resolvedUrl: asset.url, name, missingKind } } class LocalImageWidget extends WidgetType { @@ -1048,6 +1065,34 @@ class LocalMediaWidget extends WidgetType { } } +/** An image, PDF, audio or video line whose file is not on this device, drawn + * as the reading view's card that says so. When the file arrives the line + * parses as its own kind again and that widget replaces this one. */ +class MissingAssetWidget extends WidgetType { + constructor( + private readonly kind: MissingAssetKind, + private readonly href: string + ) { + super() + } + + eq(other: MissingAssetWidget): boolean { + return other.kind === this.kind && other.href === this.href + } + + toDOM(view: EditorView): HTMLElement { + const figure = buildMissingAssetNotice(this.kind, this.href, { + onEdit: () => editEmbedLine(view, figure) + }) + figure.classList.add('cm-local-missing-embed') + return figure + } + + ignoreEvent(): boolean { + return true + } +} + /** Puts the caret at the start of the line holding `dom`, revealing the * embed's source. */ function editEmbedLine(view: EditorView, dom: HTMLElement): void { @@ -1302,19 +1347,21 @@ function computeDecorations(view: EditorView): DecorationSet { to: line.to, deco: Decoration.widget({ side: 1, - widget: new LocalImageWidget( - notePath, - line.from, - line.to, - line.text, - parsedImage.alt, - parsedImage.href, - parsedImage.resolvedUrl, - parsedImage.version, - parsedImage.width, - parsedImage.height, - cloudNotice(parsedImage.href) - ) + widget: parsedImage.missing + ? new MissingAssetWidget('image', parsedImage.href) + : new LocalImageWidget( + notePath, + line.from, + line.to, + line.text, + parsedImage.alt, + parsedImage.href, + parsedImage.resolvedUrl, + parsedImage.version, + parsedImage.width, + parsedImage.height, + cloudNotice(parsedImage.href) + ) }) }) if (!lineActive) { @@ -1342,13 +1389,15 @@ function computeDecorations(view: EditorView): DecorationSet { to: line.to, deco: Decoration.widget({ side: 1, - widget: new LocalMediaWidget( - parsedMedia.kind, - parsedMedia.href, - parsedMedia.resolvedUrl, - parsedMedia.assetPath, - oversizedCloudFileNotice(oversized, parsedMedia.assetPath) - ) + widget: parsedMedia.missing + ? new MissingAssetWidget(parsedMedia.kind, parsedMedia.href) + : new LocalMediaWidget( + parsedMedia.kind, + parsedMedia.href, + parsedMedia.resolvedUrl, + parsedMedia.assetPath, + oversizedCloudFileNotice(oversized, parsedMedia.assetPath) + ) }) }) if (!lineActive) { @@ -1429,17 +1478,19 @@ function computeDecorations(view: EditorView): DecorationSet { to: line.from, deco: Decoration.widget({ side: 1, - widget: new LocalPdfWidget( - notePath, - line.from, - line.to, - parsedPdf.label, - parsedPdf.href, - parsedPdf.resolvedUrl, - compact, - isPinned, - cloudNotice(parsedPdf.href) - ) + widget: parsedPdf.missing + ? new MissingAssetWidget('pdf', parsedPdf.href) + : new LocalPdfWidget( + notePath, + line.from, + line.to, + parsedPdf.label, + parsedPdf.href, + parsedPdf.resolvedUrl, + compact, + isPinned, + cloudNotice(parsedPdf.href) + ) }) }) pending.push({ @@ -1456,12 +1507,14 @@ function computeDecorations(view: EditorView): DecorationSet { to: line.to, deco: Decoration.widget({ side: 1, - widget: new AttachmentChipWidget( - parsedAttachment.href, - parsedAttachment.resolvedUrl, - parsedAttachment.name, - cloudNotice(parsedAttachment.href) - ) + widget: parsedAttachment.missingKind + ? new MissingAssetWidget(parsedAttachment.missingKind, parsedAttachment.href) + : new AttachmentChipWidget( + parsedAttachment.href, + parsedAttachment.resolvedUrl, + parsedAttachment.name, + cloudNotice(parsedAttachment.href) + ) }) }) pending.push({ @@ -1754,6 +1807,9 @@ export const livePreviewPlugin = ViewPlugin.fromClass( // the wrong URL on the very first decoration pass and stays // broken until you re-trigger a recompute by editing. state.assetFiles !== prev.assetFiles || + // An empty listing landing is what lets an embed in a vault with + // no files say its file is not on this device. + state.assetFilesListed !== prev.assetFilesListed || // Notes list arriving late lets Excalidraw embed targets resolve // to a vault-relative path; and a bumped version means a drawing // was edited elsewhere and the cached preview must refresh. diff --git a/packages/app-core/src/lib/cm-table.test.ts b/packages/app-core/src/lib/cm-table.test.ts index b22e05ce..0adbd6e7 100644 --- a/packages/app-core/src/lib/cm-table.test.ts +++ b/packages/app-core/src/lib/cm-table.test.ts @@ -708,6 +708,34 @@ describe('table cell link following (#445)', () => { view.destroy() }) + it('follows a dotted [[2024.01.15]] in a cell to its note, not to a web address', () => { + const open = vi.fn() + vi.stubGlobal('open', open) + for (const vimMode of [false, true]) { + const selectNote = setup(vimMode) + useStore.setState({ + notes: [{ path: '2024.01.15.md', title: '2024.01.15', folder: 'inbox' }] + } as never) + const view = mount('| A | B |\n| --- | --- |\n| x | [[2024.01.15]] |') + if (vimMode) { + const cell = [...view.dom.querySelectorAll('.cm-table-cell')].find( + (c) => c.dataset.raw === '[[2024.01.15]]' + ) + cell?.focus() + cell?.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true })) + cell?.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true })) + } else { + view.dom + .querySelector('.cm-table-cell a.wikilink') + ?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + } + expect(selectNote, `vim ${vimMode}`).toHaveBeenCalledWith('2024.01.15.md') + view.destroy() + } + expect(open).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + it('follows the link under the cursor on `gd` in a cell (Vim)', () => { const selectNote = setup(true) const view = mount(WIKILINK_DOC) diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index 38dc0e80..8d4a278f 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -49,14 +49,18 @@ import { undo, redo } from '@codemirror/commands' import { useStore } from '../store' import { matchesSequenceToken, matchesShortcutBinding } from './keymaps' import { followLinkTarget } from './follow-link' -import { extractLinkAtCursor } from './internal-links' - -/** The follow target for a rendered link anchor inside a table cell: a - * wikilink's name (`data-wikilink`) or a plain link's href. Returns null for - * anchors that carry neither. */ -function linkTargetFromAnchor(anchor: HTMLAnchorElement): string | null { - if (anchor.classList.contains('wikilink')) return anchor.dataset.wikilink?.trim() || null - return anchor.getAttribute('href')?.trim() || null +import { linkRangeAtCursor, type LineLinkKind } from './internal-links' + +/** The follow target for a rendered link anchor inside a table cell, with its + * kind: a wikilink's name (`data-wikilink`) or a plain link's href. Returns + * null for anchors that carry neither. */ +function linkFromAnchor(anchor: HTMLAnchorElement): { target: string; kind: LineLinkKind } | null { + if (anchor.classList.contains('wikilink')) { + const target = anchor.dataset.wikilink?.trim() + return target ? { target, kind: 'wikilink' } : null + } + const href = anchor.getAttribute('href')?.trim() + return href ? { target: href, kind: 'markdown' } : null } /** True when the editor is in Vim mode — gates the table's modal (normal / @@ -679,11 +683,11 @@ class TableWidget extends WidgetType { if (editable.dataset.rendered !== 'true') return const anchor = (event.target as HTMLElement | null)?.closest('a') if (!anchor || !editable.contains(anchor)) return - const target = linkTargetFromAnchor(anchor) - if (!target) return + const link = linkFromAnchor(anchor) + if (!link) return event.preventDefault() event.stopPropagation() - followLinkTarget(target) + followLinkTarget(link.target, { kind: link.kind }) }) editable.addEventListener('input', () => { this.dirty = true @@ -976,8 +980,8 @@ class TableWidget extends WidgetType { event.preventDefault() this.pendingG = false if (event.key === 'd') { - const target = extractLinkAtCursor(cellText, this.cursorOffset) - if (target) followLinkTarget(target) + const link = linkRangeAtCursor(cellText, this.cursorOffset) + if (link) followLinkTarget(link.target, { kind: link.kind }) } return } diff --git a/packages/app-core/src/lib/cm-wikilink-render.test.ts b/packages/app-core/src/lib/cm-wikilink-render.test.ts index d81620aa..c8363718 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.test.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.test.ts @@ -149,4 +149,17 @@ describe('wikilinkRenderExtension: unresolved links (#768)', () => { expect(offerCreateNoteFromLink).toHaveBeenCalledWith('Nope') view.destroy() }) + + it('follows on the primary button only, so a right or middle click stays in the note', () => { + const doc = 'see [[Nope]] end' + const view = mount(doc, doc.length) + const link = view.dom.querySelector('.cm-wikilink')! + + for (const button of [1, 2]) { + link.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button })) + } + expect(offerCreateNoteFromLink).not.toHaveBeenCalled() + expect(createNoteFromLinkNow).not.toHaveBeenCalled() + view.destroy() + }) }) diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts index 8bf6acd0..15ed7518 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -240,8 +240,15 @@ const wikilinkRenderPlugin = ViewPlugin.fromClass( * target carries one (`[[Doc#Heading]]`). (#196) A dead link asks before * creating the note, unless `createWithoutAsking` (a modifier click) says the * suggested path is fine as it is (#768). + * + * Hint mode follows an editor wikilink through here too (#894). The note name + * has to resolve before any href reading: `followLinkTarget` tries a URL first + * and would send `[[2024.01.15]]` to https://2024.01.15. */ -function openWikilink(target: string, options: { createWithoutAsking?: boolean } = {}): void { +export function openWikilink( + target: string, + options: { createWithoutAsking?: boolean } = {} +): void { const state = useStore.getState() const focusEditorSoon = (): void => { useStore.getState().setFocusedPanel('editor') @@ -272,9 +279,12 @@ function openWikilink(target: string, options: { createWithoutAsking?: boolean } // Click a rendered wikilink to jump. Intercept on mousedown so CodeMirror // doesn't first drop the caret into the (hidden) source. With Cmd (macOS) or // Ctrl held, a link at a note that does not exist yet creates it at once at -// the suggested path instead of asking (#768). +// the suggested path instead of asking (#768). Only the primary button +// follows: a right-click used to open the note and then show the editor's +// menu over it, and a middle click is no follow either. const wikilinkClick = EditorView.domEventHandlers({ mousedown: (event) => { + if (event.button !== 0) return false const el = (event.target as HTMLElement | null)?.closest('.cm-wikilink') const target = el?.dataset.target if (!target) return false @@ -282,9 +292,7 @@ const wikilinkClick = EditorView.domEventHandlers({ // Following the link ends its status-bar hover; a tap never sends the // mouseleave that would (#820). setHoveredLink(null) - openWikilink(target, { - createWithoutAsking: event.button === 0 && (event.metaKey || event.ctrlKey) - }) + openWikilink(target, { createWithoutAsking: event.metaKey || event.ctrlKey }) return true } }) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index e08c0d61..1b854265 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1399,6 +1399,18 @@ 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', + title: + getState().kanbanCardSort === 'due' + ? 'Kanban: Order Cards Manually' + : 'Kanban: Order Cards by Due Date', + category: 'View', + keywords: 'tasks board sort due date deadline order cards columns manual', + run: () => getState().setKanbanCardSort(getState().kanbanCardSort === 'due' ? 'manual' : 'due') + }) + // Saved Tasks filters (#731): one palette entry per name, so a filter is a // few keystrokes away from any note. Opening the view resets the filter, so // the query is applied once the open has settled. diff --git a/packages/app-core/src/lib/editor-link-hints.test.ts b/packages/app-core/src/lib/editor-link-hints.test.ts new file mode 100644 index 00000000..64f13185 --- /dev/null +++ b/packages/app-core/src/lib/editor-link-hints.test.ts @@ -0,0 +1,321 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState, type Extension } from '@codemirror/state' +import { Decoration, EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { wikilinkRenderExtension } from './cm-wikilink-render' +import { editorLinkHints, followEditorLinkHint, visibleEditorLinkHints } from './editor-link-hints' +import type { PaneLeaf } from './pane-layout' +import { registerNoteEditor } from './note-editor-context' + +const nav = vi.hoisted(() => ({ openWikilinkTarget: vi.fn(async () => true) })) +const create = vi.hoisted(() => ({ + offerCreateNoteFromLink: vi.fn(async () => undefined), + createNoteFromLinkNow: vi.fn(async () => undefined) +})) + +vi.mock('./wikilink-navigation', async (importOriginal) => ({ + ...(await importOriginal()), + openWikilinkTarget: nav.openWikilinkTarget +})) +vi.mock('./create-note-from-link', () => create) + +// jsdom has no layout, so each editor is laid out as a grid: line N of an +// editor whose origin is (x, y) starts at top y + 24 * (N - 1), and every +// character is 8px wide from x. CodeMirror measures text through +// Range.getClientRects; characters a decoration replaces are never measured, +// exactly as in a browser. +interface Box { + left: number + top: number + right: number + bottom: number +} + +function rect(left: number, top: number, width: number, height: number): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}) + } as DOMRect +} + +const layouts = new Map() + +function charRects(this: Range): DOMRect[] { + for (const [view, origin] of layouts) { + if (!view.contentDOM.contains(this.startContainer)) continue + let pos: number + try { + pos = view.posAtDOM(this.startContainer, this.startOffset) + } catch { + return [] + } + const line = view.state.doc.lineAt(pos) + return [rect(origin.x + (pos - line.from) * 8, origin.y + (line.number - 1) * 24, 8, 20)] + } + return [] +} + +function mount( + doc: string, + options: { + paneId?: string | null + path?: string + extensions?: Extension[] + origin?: { x: number; y: number } + box?: Box + } = {} +): 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 }), ...(options.extensions ?? [])] + }) + }) + forceParsing(view, doc.length, 5000) + const paneId = options.paneId === undefined ? 'pane-1' : options.paneId + if (paneId) registerNoteEditor(view, () => options.path ?? 'Test.md', paneId) + const origin = options.origin ?? { x: 40, y: 100 } + const box = options.box ?? { + left: origin.x - 20, + top: origin.y - 20, + right: origin.x + 780, + bottom: 700 + } + view.scrollDOM.getBoundingClientRect = () => + rect(box.left, box.top, box.right - box.left, box.bottom - box.top) + layouts.set(view, origin) + return view +} + +/** Hide [from, to) the way live preview hides syntax and embedded sources. */ +function hidden(from: number, to: number): Extension { + return EditorView.decorations.of(Decoration.set([Decoration.replace({}).range(from, to)])) +} + +function note(path: string): NoteMeta { + const title = path.split('/').pop()!.replace(/\.md$/, '') + return { + path, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + } as NoteMeta +} + +function leaf(id: string, path: string): PaneLeaf { + return { kind: 'leaf', id, tabs: [path], pinnedTabs: [], activeTab: path } +} + +const initialStore = useStore.getState() + +beforeEach(() => { + ;(Range.prototype as unknown as { getClientRects: unknown }).getClientRects = charRects + ;(Range.prototype as unknown as { getBoundingClientRect: unknown }).getBoundingClientRect = + function (this: Range) { + return charRects.call(this)[0] ?? rect(0, 0, 0, 0) + } +}) + +afterEach(() => { + for (const view of layouts.keys()) { + view.dom.parentElement?.remove() + view.destroy() + } + layouts.clear() + delete (Range.prototype as unknown as { getClientRects?: unknown }).getClientRects + delete (Range.prototype as unknown as { getBoundingClientRect?: unknown }).getBoundingClientRect + useStore.setState(initialStore, true) + vi.restoreAllMocks() + nav.openWikilinkTarget.mockClear() + create.offerCreateNoteFromLink.mockClear() + create.createNoteFromLinkNow.mockClear() +}) + +const targets = (view: EditorView): string[] => + editorLinkHints(view, 'pane-1').map((hint) => hint.target) + +describe('editorLinkHints (#894)', () => { + it('finds bare URLs, wikilinks with an alias or a heading, and Markdown links and images', () => { + const view = mount( + [ + '# Links', + 'https://example.net/x', + '[[Plan|the plan]] then [[Plan#Goals]]', + '![diagram](assets/d.png) and [spec]()' + ].join('\n') + ) + + const hints = editorLinkHints(view, 'pane-1') + + expect(hints.map(({ kind, target, paneId }) => ({ kind, target, paneId }))).toEqual([ + { kind: 'url', target: 'https://example.net/x', paneId: 'pane-1' }, + { kind: 'wikilink', target: 'Plan', paneId: 'pane-1' }, + { kind: 'wikilink', target: 'Plan#Goals', paneId: 'pane-1' }, + { kind: 'markdown', target: 'assets/d.png', paneId: 'pane-1' }, + { kind: 'markdown', target: 'docs/the spec.md', paneId: 'pane-1' } + ]) + // The URL's box runs from its first character to its last on that row. + expect(hints[0].rect).toEqual({ left: 40, top: 124, right: 40 + 21 * 8, bottom: 144 }) + }) + + it('leaves links in code spans and fenced code alone, as the renderer does', () => { + const view = mount( + [ + '`[[Not a link]]` and `https://code.test`', + '```text', + 'https://fenced.test [[Fenced]]', + '```', + '[[Real]]' + ].join('\n') + ) + + expect(targets(view)).toEqual(['Real']) + }) + + it('labels the first character live preview draws, not the hidden syntax', () => { + const doc = 'see [[Plan|the plan]]' + const view = mount(doc, { extensions: [wikilinkRenderExtension] }) + + const [hint] = editorLinkHints(view, 'pane-1') + + expect(hint.target).toBe('Plan') + expect(hint.rect.left).toBe(40 + doc.indexOf('the plan') * 8) + expect(hint.anchor.parentElement?.closest('.cm-wikilink')).not.toBeNull() + }) + + it('gives no hint to a link whose whole source is hidden, as behind an image embed', () => { + const image = '![diagram](assets/d.png)' + const view = mount(`${image}\n[[Plan]]`, { extensions: [hidden(0, image.length)] }) + + expect(targets(view)).toEqual(['Plan']) + }) + + it('counts only links drawn inside the visible part of the editor', () => { + // Line 4 sits at 172..192, inside the box; lines 1 and 8 are drawn (as + // CodeMirror draws past the scroller's edges) but outside it. `[[Far]]` + // starts past the box's right edge. + const view = mount( + ['[[One]]', '', '', `[[Four]]${' '.repeat(40)}[[Far]]`, '', '', '', '[[Eight]]'].join('\n'), + { box: { left: 20, top: 160, right: 300, bottom: 240 } } + ) + + expect(targets(view)).toEqual(['Four']) + }) + + it('has nothing to label in an editor that is not on screen', () => { + // A pane in Preview mode keeps its editor mounted, with display: none. + const view = mount('[[Plan]]', { box: { left: 0, top: 0, right: 0, bottom: 0 } }) + + expect(targets(view)).toEqual([]) + }) + + it('skips a blank wikilink and trims a target, as the rendered link does', () => { + const view = mount('[[ ]] and [[ Spaced ]]') + + expect(targets(view)).toEqual(['Spaced']) + }) +}) + +describe('visibleEditorLinkHints (#894)', () => { + it('reads every note editor on the page, and no other editor', () => { + mount('[[Left]]', { paneId: 'pane-1' }) + mount('[[Right]]', { paneId: 'pane-2', origin: { x: 440, y: 100 } }) + // Any other CodeMirror (a template editor, the reference pane) is no + // note editor, and its links do not follow from a pane. + mount('[[Other]]', { paneId: null, origin: { x: 40, y: 400 } }) + + expect(visibleEditorLinkHints().map((hint) => [hint.paneId, hint.target])).toEqual([ + ['pane-1', 'Left'], + ['pane-2', 'Right'] + ]) + }) +}) + +describe('followEditorLinkHint (#894)', () => { + beforeEach(() => { + useStore.setState({ + notes: [ + note('inbox/Other.md'), + note('inbox/plan.md'), + note('projects/Spec.md'), + note('projects/plan.md'), + note('daily/2024.01.15.md') + ], + paneLayout: { + kind: 'split', + id: 'root', + direction: 'row', + sizes: [50, 50], + children: [leaf('pane-1', 'inbox/Other.md'), leaf('pane-2', 'projects/Spec.md')] + }, + activePaneId: 'pane-1', + selectedPath: 'inbox/Other.md', + focusedPanel: 'sidebar', + selectNote: vi.fn(async () => undefined) + }) + }) + + function hintIn(doc: string): ReturnType[number] { + const view = mount(doc, { paneId: 'pane-2', path: 'projects/Spec.md' }) + const [hint] = editorLinkHints(view, 'pane-2') + return hint + } + + it('makes the link’s pane active first, so a relative link resolves against its note', () => { + followEditorLinkHint(hintIn('[the plan](plan.md)')) + + expect(useStore.getState().activePaneId).toBe('pane-2') + expect(useStore.getState().selectNote).toHaveBeenCalledWith('projects/plan.md') + }) + + it('opens the note a wikilink names, even when the name reads like a domain', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + + followEditorLinkHint(hintIn('[[2024.01.15]]')) + + expect(nav.openWikilinkTarget).toHaveBeenCalledWith('daily/2024.01.15.md', '2024.01.15') + expect(open).not.toHaveBeenCalled() + }) + + it('asks before creating the note a dead wikilink names, like a plain click', () => { + followEditorLinkHint(hintIn('[[Missing]]')) + + expect(create.offerCreateNoteFromLink).toHaveBeenCalledWith('Missing') + expect(create.createNoteFromLinkNow).not.toHaveBeenCalled() + }) + + it('sends a bare URL to the browser and leaves the keyboard in that editor', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const hint = hintIn('https://example.net') + + followEditorLinkHint(hint) + + expect(open).toHaveBeenCalledWith('https://example.net', '_blank') + expect(useStore.getState().focusedPanel).toBe('editor') + expect(hint.view.hasFocus).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/editor-link-hints.ts b/packages/app-core/src/lib/editor-link-hints.ts new file mode 100644 index 00000000..dc7ae8c2 --- /dev/null +++ b/packages/app-core/src/lib/editor-link-hints.ts @@ -0,0 +1,187 @@ +/** + * Hint mode's targets inside the Edit view (#894). The editor draws links as + * text and decorations, not `` elements, so the page-wide scan for + * clickable elements finds none of them; these come from the document instead. + */ +import { syntaxTree } from '@codemirror/language' +import type { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { useStore } from '../store' +import { openWikilink } from './cm-wikilink-render' +import { followLinkTarget } from './follow-link' +import { linkRangesInLine, type LineLinkKind } from './internal-links' +import { noteEditorPaneId } from './note-editor-context' + +/** A box in viewport coordinates. */ +export interface HintRect { + left: number + top: number + right: number + bottom: number +} + +export interface EditorLinkHint { + view: EditorView + paneId: string + kind: LineLinkKind + target: string + /** Document offset where the link's source starts. */ + from: number + /** The link's first drawn character, widened along its row. */ + rect: HintRect + /** The DOM node drawing that character, so the hint can take its place in + * document order among the page's other targets. */ + anchor: Node +} + +/** + * True when `pos` sits inside a code span or block. `[[...]]` and URLs there + * are literal text, drawn as code rather than as links (#248). + */ +function isInsideCode(state: EditorState, pos: number): boolean { + let node = syntaxTree(state).resolveInner(pos, 1) + while (node) { + const n = node.name + if (n === 'FencedCode' || n === 'CodeBlock' || n === 'InlineCode') return true + if (!node.parent) break + node = node.parent + } + return false +} + +/** + * The part of the screen `view` shows: its scroller, clipped to the window. + * Null when none of it shows, as for the editor a pane in Preview mode keeps + * mounted but hidden. + */ +function visibleBox(view: EditorView): HintRect | null { + const box = view.scrollDOM.getBoundingClientRect() + const left = Math.max(box.left, 0) + const top = Math.max(box.top, 0) + const right = Math.min(box.right, window.innerWidth) + const bottom = Math.min(box.bottom, window.innerHeight) + return right > left && bottom > top ? { left, top, right, bottom } : null +} + +/** + * The first character of [from, to) the editor actually draws. Live preview + * replaces a link's syntax (`[[`, `[`, `](url)`), and a standalone image or + * PDF line hides its whole source behind the embed, so a link's own start can + * draw nothing at all. + */ +function firstDrawnChar( + view: EditorView, + from: number, + to: number +): { pos: number; rect: HintRect } | null { + for (let pos = from; pos < to; pos++) { + const rect = view.coordsForChar(pos) + if (rect && rect.right > rect.left && rect.bottom > rect.top) return { pos, rect } + } + return null +} + +function domNodeAt(view: EditorView, pos: number): Node { + const { node, offset } = view.domAtPos(pos) + return node.nodeType === Node.TEXT_NODE ? node : (node.childNodes[offset] ?? node) +} + +/** `first` widened to where the link ends, when that is on the same row. */ +function rowRect(view: EditorView, first: HintRect, to: number): HintRect { + const end = view.coordsAtPos(to, -1) + const sameRow = end != null && end.top < first.bottom && end.bottom > first.top + return { + left: first.left, + top: first.top, + right: sameRow && end.right > first.right ? end.right : first.right, + bottom: first.bottom + } +} + +/** + * The links `view` shows on screen right now, in reading order: bare URLs, + * `[[wikilinks]]`, and Markdown links and images, each with the target a + * click or `gd` on it follows. A link counts only when its first drawn + * character, where the label goes, is inside the visible part of the editor: + * CodeMirror also draws lines beyond the scroller's edges, and folds and + * embeds hide others. + */ +export function editorLinkHints(view: EditorView, paneId: string): EditorLinkHint[] { + const box = visibleBox(view) + if (!box) return [] + const { state } = view + const hints: EditorLinkHint[] = [] + const seen = new Set() + for (const range of view.visibleRanges) { + let line = state.doc.lineAt(range.from) + for (;;) { + for (const link of linkRangesInLine(line.text)) { + // The rendered wikilink carries its target trimmed and leaves `[[ ]]` + // as plain text, and `gd` ignores `[x]( )`: a blank target is no link. + const target = link.target.trim() + if (!target) continue + const from = line.from + link.from + if (seen.has(from)) continue + seen.add(from) + if (isInsideCode(state, from)) continue + const to = line.from + link.to + const first = firstDrawnChar(view, from, to) + if (!first) continue + const { rect } = first + const middle = (rect.top + rect.bottom) / 2 + if (middle < box.top || middle > box.bottom) continue + if (rect.right <= box.left || rect.left >= box.right) continue + hints.push({ + view, + paneId, + kind: link.kind, + target, + from, + rect: rowRect(view, rect, to), + anchor: domNodeAt(view, first.pos) + }) + } + if (line.to >= range.to || line.number >= state.doc.lines) break + line = state.doc.line(line.number + 1) + } + } + return hints +} + +/** The on-screen links of every note editor on the page, editor by editor. */ +export function visibleEditorLinkHints(): EditorLinkHint[] { + const hints: EditorLinkHint[] = [] + for (const dom of document.querySelectorAll('.cm-editor')) { + if (dom.closest('[data-vim-hint-ignore]')) continue + const view = EditorView.findFromDOM(dom) + const paneId = view ? noteEditorPaneId(view) : null + if (!view || !paneId) continue + try { + hints.push(...editorLinkHints(view, paneId)) + } catch { + // CodeMirror refuses layout reads while a view is mid-update. That + // editor's links go unlabelled instead of taking hint mode down. + } + } + return hints +} + +/** + * Follow a labelled editor link the way a click follows it. A click lands in + * its pane first (EditorPane's capture handler runs before CodeMirror sees the + * mousedown), so a relative link resolves against that pane's note and a note + * opens there; the label does the same, and leaves the keyboard in that + * editor. A wikilink then resolves as a note name, as the rendered wikilink's + * click does; a Markdown link or a bare URL takes the shared href path behind + * the rendered Markdown link's click and every Cmd/Ctrl-click. + */ +export function followEditorLinkHint( + hint: Pick +): void { + const state = useStore.getState() + state.setActivePane(hint.paneId) + state.setFocusedPanel('editor') + hint.view.focus() + if (hint.kind === 'wikilink') openWikilink(hint.target) + else followLinkTarget(hint.target) +} diff --git a/packages/app-core/src/lib/follow-link.test.ts b/packages/app-core/src/lib/follow-link.test.ts index 0f17e8be..c4c6f997 100644 --- a/packages/app-core/src/lib/follow-link.test.ts +++ b/packages/app-core/src/lib/follow-link.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const state = vi.hoisted(() => ({ selectedPath: 'inbox/Current.md' as string | null, @@ -102,6 +102,45 @@ describe('followLinkTarget: creating without asking (#768)', () => { }) }) +// A wikilink names a note. Followed as an href, `[[2024.01.15]]` passed the +// bare-domain guess and opened https://2024.01.15 in the browser. +describe('followLinkTarget: wikilink names that look like domains', () => { + const open = vi.fn() + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('open', open) + state.notes.push({ path: 'inbox/2024.01.15.md', title: '2024.01.15', folder: 'inbox' }) + }) + afterEach(() => { + state.notes.splice(1) + vi.unstubAllGlobals() + }) + + it('opens the note [[2024.01.15]] names, not https://2024.01.15', () => { + expect(followLinkTarget('2024.01.15', { kind: 'wikilink' })).toBe(true) + + expect(open).not.toHaveBeenCalled() + expect(state.selectNote).toHaveBeenCalledWith('inbox/2024.01.15.md') + }) + + it('creates a dated note that does not exist yet instead of opening the browser', () => { + expect(followLinkTarget('2025.12.31', { kind: 'wikilink', createWithoutAsking: true })).toBe(true) + + expect(open).not.toHaveBeenCalled() + expect(createNoteFromLinkNow).toHaveBeenCalledWith('2025.12.31') + }) + + it('still opens a wikilink written with a scheme, and a bare-domain href (#201)', () => { + followLinkTarget('https://example.net', { kind: 'wikilink' }) + followLinkTarget('example.com') + + expect(open.mock.calls).toEqual([ + ['https://example.net', '_blank'], + ['https://example.com', '_blank'] + ]) + }) +}) + describe('application links (#764)', () => { const openExternalUrl = vi.fn() beforeEach(() => { diff --git a/packages/app-core/src/lib/follow-link.ts b/packages/app-core/src/lib/follow-link.ts index 781fde27..caf63543 100644 --- a/packages/app-core/src/lib/follow-link.ts +++ b/packages/app-core/src/lib/follow-link.ts @@ -2,7 +2,7 @@ import { followApplicationLink } from './application-link-open' import { useStore } from '../store' import { createNoteFromLinkNow, offerCreateNoteFromLink } from './create-note-from-link' import { externalFileLink, openExternalFileLink } from './external-file-link' -import { externalLinkUrl, resolveInternalNoteHref } from './internal-links' +import { externalUrlForLink, resolveInternalNoteHref, type LineLinkKind } from './internal-links' import { openWikilinkAttachment } from './open-wikilink-attachment' import { resolveWikilinkPath } from './wikilinks' import { @@ -24,14 +24,19 @@ import { * With `createWithoutAsking`, a dead link creates its note at the suggested * path right away instead of asking first: the modifier-click and `gD` fast * path (#768). + * + * `kind` says how the target was written; an href unless told otherwise. A + * caller holding a `[[wikilink]]` passes 'wikilink', so a note name that looks + * like a domain (`[[2024.01.15]]`) opens its note instead of the browser. */ export interface FollowLinkOptions { createWithoutAsking?: boolean + kind?: LineLinkKind } export function followLinkTarget(target: string, options: FollowLinkOptions = {}): boolean { if (followApplicationLink(target)) return true - const external = externalLinkUrl(target) + const external = externalUrlForLink(target, options.kind ?? 'markdown') if (external) { window.open(external, '_blank') return true diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 93be71d5..511f0b78 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -166,6 +166,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Install the `zn` CLI from Settings → CLI, then use the Raycast Extension section on the same page to install ZenNotes for Raycast locally. Raycast can search notes, filter by folder or tag, open a note in the app, open it in a floating window, archive or unarchive, move to Trash, reveal in Finder, copy the note path, and copy a wikilink.' }, + { + title: 'Preview a note in Finder with Quick Look', + body: + 'On macOS, select a Markdown file in Finder and press Space: Quick Look shows it the way ZenNotes renders it, in your theme and fonts, with callouts, tasks, tables, code, math, Mermaid diagrams and the images the note embeds. Press Open in ZenNotes to open it in the app: a note inside a vault you use opens there, any other file in its own window. Once you click into the preview, Enter does the same, and with Vim mode on `o` opens it too while `j` / `k`, Ctrl+D / Ctrl+U, `gg` and `G` move through the note. The preview is read-only and never goes online: links to websites open in your browser, wikilinks and tags stay plain text, and pictures on the web are not loaded. The preview comes with the app and turns on after you open ZenNotes once; to use another app\'s preview, turn ZenNotes off in System Settings → General → Login Items & Extensions → Quick Look.' + }, { title: 'Check for updates and install them', body: @@ -297,7 +302,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The Tasks Kanban board, custom statuses, and any field', body: - 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between Upcoming and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date, and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' + 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between Upcoming and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date, and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' }, { title: 'Filter the Tasks views to one project', @@ -518,7 +523,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { 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: '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 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: '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.' }, { keys: 'Alt+1 … Alt+9', action: 'Go to tab 1 through 9', detail: 'Jump straight to a tab by position, browser-style (Ctrl+1 … Ctrl+9 on macOS, where Option types characters and the ⌘ digits are taken). Tab numbers count across panes in the same order gt cycles; rebindable under Settings → Keymaps. Vim users get the same jump as {count}gt. Heads-up for macOS with multiple Spaces: Mission Control claims Ctrl+digit for Switch to Desktop, so rebind here or free the key under System Settings → Keyboard Shortcuts.' }, { 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.' }, @@ -543,6 +548,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+N', action: 'New note', detail: 'Save the current note, then open a fresh blank capture without hiding the window — for jotting several in a row.' }, { keys: 'Mod+P', action: 'Open a note', detail: 'Search the vault and load an existing note into the window to edit it in place.' }, { keys: 'Shift+Mod+P', action: 'Command palette', detail: 'Run a capture command: save, save without hiding, start a new note, or open another note.' }, + { keys: 'Mod+W', action: 'Hide without saving', detail: 'Hide the window like its close button. Nothing is saved, and the draft is still there the next time you open the window. This is your Close active tab key, so it follows a rebind.' }, { keys: 'Esc', action: 'Dismiss', detail: 'Close an open overlay; otherwise save and hide the window.' } ] }, @@ -782,6 +788,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Save the current Tasks filter under a name', detail: 'Keeps the query in the Tasks filter box as a saved filter called `` (bare `:savefilter`, or `:sf`, asks for the name). It shows up as a chip under the Tasks header, in the command palette as “Tasks: name”, and in config.toml under `[saved_filters]`.' }, + { + command: ':order due | manual', + summary: 'Order Kanban cards by due date or by hand', + detail: 'In the Tasks view, `:order due` sorts the cards in every Kanban column by due date, earliest first and cards without one last; `:order manual` brings back the order you dragged them into, which ordering by date never overwrites. Bare `:order` switches between the two, like `s` on the board in Vim mode. Same as `kanban_card_sort` under `[view]` in config.toml.' + }, { command: ':delfilter ', summary: 'Delete a saved Tasks filter', @@ -1182,7 +1193,8 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ title: 'Tasks', items: [ { label: 'Show tasks from archived notes', detail: 'Off by default: archiving a note retires its tasks from the Tasks list, calendar, and Kanban. Turn this on to keep them visible everywhere, Archive column included. The raw key is `show_archived_tasks` in `config.toml`.' }, - { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' } + { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' }, + { label: 'Kanban card order', detail: 'Manual keeps the cards in the order you drag them into; Due date sorts every Kanban column by due date, cards without one last, and keeps your manual order for when you switch back. The board\'s Order menu sets the same thing, and the raw key is `kanban_card_sort` (`manual` or `due`) under `[view]` in `config.toml`.' } ] }, { diff --git a/packages/app-core/src/lib/internal-links.test.ts b/packages/app-core/src/lib/internal-links.test.ts index b3f4101b..d418fcd7 100644 --- a/packages/app-core/src/lib/internal-links.test.ts +++ b/packages/app-core/src/lib/internal-links.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' import { externalLinkUrl, - extractLinkAtCursor, + externalUrlForLink, linkRangeAtCursor, + linkRangesInLine, markdownLinkAt, resolveInternalNoteHref } from './internal-links' @@ -92,31 +93,15 @@ describe('resolveInternalNoteHref', () => { }) }) -describe('extractLinkAtCursor', () => { - it('pulls a Markdown link url under the cursor', () => { - const doc = 'see [the plan](Work/Projects/plan.md) here' - expect(extractLinkAtCursor(doc, doc.indexOf('plan.md'))).toBe('Work/Projects/plan.md') - }) - - it('pulls a wikilink target under the cursor', () => { - const doc = 'see [[Another Note]] here' - expect(extractLinkAtCursor(doc, doc.indexOf('Another'))).toBe('Another Note') - }) - +describe('linkRangeAtCursor', () => { it('unwraps an angle-bracketed url with spaces', () => { - const doc = '[x]()' - expect(extractLinkAtCursor(doc, 2)).toBe('a b.md') - }) - - it('returns null when not inside a link', () => { - expect(extractLinkAtCursor('just text', 3)).toBeNull() + expect(linkRangeAtCursor('[x]()', 2)?.target).toBe('a b.md') }) -}) -describe('linkRangeAtCursor', () => { it('returns the target and source range of a Markdown link', () => { const doc = 'see [the plan](Work/Projects/plan.md) here' expect(linkRangeAtCursor(doc, doc.indexOf('plan.md'))).toEqual({ + kind: 'markdown', target: 'Work/Projects/plan.md', from: 4, to: 4 + '[the plan](Work/Projects/plan.md)'.length @@ -127,6 +112,7 @@ describe('linkRangeAtCursor', () => { const doc = 'first line\n2. courses on [[Another Note]] end' const at = linkRangeAtCursor(doc, doc.indexOf('Another')) expect(at).toEqual({ + kind: 'wikilink', target: 'Another Note', from: doc.indexOf('[[Another'), to: doc.indexOf(']]') + 2 @@ -136,6 +122,7 @@ describe('linkRangeAtCursor', () => { it('covers a bare url up to its last character, exclusive of line end', () => { const doc = 'ends with https://x.test/a' expect(linkRangeAtCursor(doc, doc.indexOf('https'))).toEqual({ + kind: 'url', target: 'https://x.test/a', from: doc.indexOf('https'), to: doc.length @@ -175,6 +162,36 @@ describe('externalLinkUrl', () => { }) }) +// A wikilink names a note. Read as an href, `[[2024.01.15]]` passed the +// bare-domain guess and `gd`, Cmd-click on its source, `gy` and the table +// cells sent it to https://2024.01.15. +describe('externalUrlForLink', () => { + it('never reads a wikilink name as a bare domain', () => { + for (const name of ['2024.01.15', 'v1.2', 'notes.v2', 'google.com', '2024.01.15#Agenda']) { + expect(externalUrlForLink(name, 'wikilink'), name).toBeNull() + } + }) + + it('still sends a wikilink written with a scheme to the browser', () => { + expect(externalUrlForLink('https://example.net', 'wikilink')).toBe('https://example.net') + expect(externalUrlForLink(' mailto:a@b.com ', 'wikilink')).toBe('mailto:a@b.com') + }) + + it('reads a Markdown href and a bare URL as externalLinkUrl does (#201)', () => { + expect(externalUrlForLink('google.com', 'markdown')).toBe('https://google.com') + expect(externalUrlForLink('https://x.test/a', 'url')).toBe('https://x.test/a') + expect(externalUrlForLink('Note.md', 'markdown')).toBeNull() + }) + + it('pairs with the kind linkRangeAtCursor reports', () => { + const doc = 'see [[example.com]] and [site](example.com)' + const wikilink = linkRangeAtCursor(doc, doc.indexOf('example'))! + const markdown = linkRangeAtCursor(doc, doc.indexOf('[site]'))! + expect(externalUrlForLink(wikilink.target, wikilink.kind)).toBeNull() + expect(externalUrlForLink(markdown.target, markdown.kind)).toBe('https://example.com') + }) +}) + describe('markdownLinkAt', () => { it.each(['[EE]', '[ordinary text]', '[label][missing]', '[missing][]'])( 'does not treat undefined bracket text as a click or keyboard link: %s', @@ -205,3 +222,49 @@ describe('markdownLinkAt with a title', () => { expect(markdownLinkAt('[x]( "t")', 2)?.href).toBe('a b.pdf') }) }) + +// #894: hint mode labels every link in the Edit view, so it needs them all at +// once, with the targets a click or `gd` on each would follow. +describe('linkRangesInLine', () => { + it('lists each link shape in reading order, with its kind, target and span', () => { + const line = + 'see https://example.net and [[Plan#Goals|the plan]], [GitHub](https://github.com) ' + + 'and ![diagram](assets/d.png) or [a b]()' + const links = linkRangesInLine(line) + + expect(links.map(({ kind, target }) => ({ kind, target }))).toEqual([ + { kind: 'url', target: 'https://example.net' }, + { kind: 'wikilink', target: 'Plan#Goals' }, + { kind: 'markdown', target: 'https://github.com' }, + { kind: 'markdown', target: 'assets/d.png' }, + { kind: 'markdown', target: 'my file.md' } + ]) + expect(line.slice(links[1].from, links[1].to)).toBe('[[Plan#Goals|the plan]]') + expect(line.slice(links[2].from, links[2].to)).toBe('[GitHub](https://github.com)') + }) + + it('does not list the URL inside a Markdown link or a wikilink as a link of its own', () => { + expect(linkRangesInLine('[GitHub](https://github.com)')).toHaveLength(1) + expect(linkRangesInLine('[[https://example.net]]')).toEqual([ + { kind: 'wikilink', target: 'https://example.net', from: 0, to: 23 } + ]) + }) + + it('agrees with linkRangeAtCursor on every link it lists', () => { + const line = 'a [[Note|alias]] b [t]( "title") c ![i](img.png "t") d http://x.test/a)' + for (const link of linkRangesInLine(line)) { + for (let col = link.from; col < link.to; col++) { + expect(linkRangeAtCursor(line, col), `col ${col}`).toEqual({ + kind: link.kind, + target: link.target, + from: link.from, + to: link.to + }) + } + } + }) + + it('finds nothing in bracket text that is not a link', () => { + expect(linkRangesInLine('Ask [EE] or [label][missing] for details.')).toEqual([]) + }) +}) diff --git a/packages/app-core/src/lib/internal-links.ts b/packages/app-core/src/lib/internal-links.ts index a1493f2e..099fe363 100644 --- a/packages/app-core/src/lib/internal-links.ts +++ b/packages/app-core/src/lib/internal-links.ts @@ -118,6 +118,8 @@ function unwrapMdUrl(url: string): string { return trimmed } +const EXPLICIT_EXTERNAL_RE = /^(https?:|mailto:|tel:)/i + const LOCAL_FILE_EXT_RE = /\.(md|markdown|txt|png|apng|avif|gif|jpe?g|svg|webp|pdf|mp3|m4a|aac|flac|ogg|wav|mp4|m4v|mov|ogv|webm|canvas|excalidraw)$/i @@ -131,7 +133,7 @@ const LOCAL_FILE_EXT_RE = export function externalLinkUrl(href: string): string | null { const h = href.trim() if (!h) return null - if (/^(https?:|mailto:|tel:)/i.test(h)) return h + if (EXPLICIT_EXTERNAL_RE.test(h)) return h if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(h)) return null // another scheme — not ours if (h.startsWith('#') || h.startsWith('/') || h.startsWith('.') || h.startsWith('//')) return null // Bare domain heuristic: `host.tld` (one or more labels) optionally followed @@ -143,57 +145,93 @@ export function externalLinkUrl(href: string): string | null { return `https://${h}` } +/** Which way a link is followed: a wikilink resolves a note name first, while + * a Markdown link or a bare URL is an href. */ +export type LineLinkKind = 'wikilink' | 'markdown' | 'url' + /** - * The link at a document offset — a `[[wikilink]]` name, a Markdown link's - * URL, or a bare URL — with its full source range in `doc` offsets. The range - * lets pointer-driven callers confirm the mouse actually sits on the link's - * rendered glyphs: a position alone cannot tell "on the link" from "clamped to - * the link", which is how blank space beside a line once hovered and followed - * a line-ending link (#587). Returns null when the offset isn't inside a link. + * The address a link of this kind opens in the browser, or null. A wikilink + * names a note, so only an explicit scheme (`[[https://…]]`) sends one there: + * read as an href, `[[2024.01.15]]` passed the bare-domain guess and opened + * https://2024.01.15. */ -export function linkRangeAtCursor( - doc: string, - pos: number -): { target: string; from: number; to: number } | null { +export function externalUrlForLink(target: string, kind: LineLinkKind): string | null { + if (kind !== 'wikilink') return externalLinkUrl(target) + const name = target.trim() + return EXPLICIT_EXTERNAL_RE.test(name) ? name : null +} + +export interface LineLink { + kind: LineLinkKind + target: string + /** Span of the whole link source, in offsets into the text searched: the + * line for `linkRangesInLine`, the document for `linkRangeAtCursor`. */ + from: number + to: number +} + +// The link shapes the editor follows, in the order they claim a position, so +// `linkRangeAtCursor` and `linkRangesInLine` can never disagree about a target. +// Angle-bracketed URLs can contain `)`, so they match ahead of the plain form. +const LINK_SHAPES: ReadonlyArray<{ + kind: LineLinkKind + re: RegExp + target: (m: RegExpMatchArray) => string +}> = [ + { kind: 'wikilink', re: /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g, target: (m) => m[1] }, + { kind: 'markdown', re: /\[([^\]]*)\]\(<([^>]+)>\)/g, target: (m) => m[2] }, + { kind: 'markdown', re: /\[([^\]]*)\]\(([^)]+)\)/g, target: (m) => unwrapMdUrl(m[2]) }, + { kind: 'url', re: /https?:\/\/[^\s)>\]]+/g, target: (m) => m[0] } +] + +/** + * The link at a document offset (a `[[wikilink]]` name, a Markdown link's + * URL, or a bare URL), with its kind and its full source range in `doc` + * offsets. The range lets pointer-driven callers confirm the mouse actually + * sits on the link's rendered glyphs: a position alone cannot tell "on the + * link" from "clamped to the link", which is how blank space beside a line + * once hovered and followed a line-ending link (#587). The kind is what tells + * a note name from an href when the link is followed. Returns null when the + * offset isn't inside a link. + */ +export function linkRangeAtCursor(doc: string, pos: number): LineLink | null { const lineStart = doc.lastIndexOf('\n', pos - 1) + 1 const lineEnd = doc.indexOf('\n', pos) const line = doc.slice(lineStart, lineEnd === -1 ? undefined : lineEnd) const col = pos - lineStart - const hit = (m: RegExpExecArray, target: string) => - col >= m.index && col < m.index + m[0].length - ? { target, from: lineStart + m.index, to: lineStart + m.index + m[0].length } - : null - const wikiRe = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g - let m: RegExpExecArray | null - while ((m = wikiRe.exec(line)) !== null) { - const found = hit(m, m[1]) - if (found) return found - } - // Angle-bracketed URLs can contain `)` so match them specifically first. - const mdAngleRe = /\[([^\]]*)\]\(<([^>]+)>\)/g - while ((m = mdAngleRe.exec(line)) !== null) { - const found = hit(m, m[2]) - if (found) return found - } - const mdRe = /\[([^\]]*)\]\(([^)]+)\)/g - while ((m = mdRe.exec(line)) !== null) { - const found = hit(m, unwrapMdUrl(m[2])) - if (found) return found - } - const urlRe = /https?:\/\/[^\s)>\]]+/g - while ((m = urlRe.exec(line)) !== null) { - const found = hit(m, m[0]) - if (found) return found + for (const shape of LINK_SHAPES) { + for (const m of line.matchAll(shape.re)) { + const start = m.index ?? 0 + if (col >= start && col < start + m[0].length) { + return { + kind: shape.kind, + target: shape.target(m), + from: lineStart + start, + to: lineStart + start + m[0].length + } + } + } } return null } /** - * The link target at a document offset — a `[[wikilink]]` name, a Markdown - * link's URL, or a bare URL. Returns null when the offset isn't inside a link. + * Every link in one line of source, in reading order, with the same targets + * `linkRangeAtCursor` reports for a position inside each. A link inside + * another (the URL of `[label](https://…)`) belongs to the outer one and is + * not listed on its own. */ -export function extractLinkAtCursor(doc: string, pos: number): string | null { - return linkRangeAtCursor(doc, pos)?.target ?? null +export function linkRangesInLine(line: string): LineLink[] { + const links: LineLink[] = [] + for (const shape of LINK_SHAPES) { + for (const m of line.matchAll(shape.re)) { + const from = m.index ?? 0 + const to = from + m[0].length + if (links.some((link) => from < link.to && to > link.from)) continue + links.push({ kind: shape.kind, target: shape.target(m), from, to }) + } + } + return links.sort((a, b) => a.from - b.from) } /** diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 9fa1a403..5ec9da21 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -396,7 +396,7 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ group: "global", title: "Close active tab", description: - "Close the current note or virtual tab. With Vim mode on and Mod resolving to Ctrl, Ctrl+W stays Vim's pane prefix while a tab is open; close the tab with :q or :bd, or rebind this.", + "Close the current note or virtual tab. With Settings open it closes Settings, and in a floating window or the quick capture window it closes that window; while a dialog, palette or menu is open it does nothing. With Vim mode on and Mod resolving to Ctrl, Ctrl+W stays Vim's pane prefix while a tab is open; close the tab with :q or :bd, or rebind this.", defaultBinding: "Mod+W", }, { diff --git a/packages/app-core/src/lib/link-copy.test.ts b/packages/app-core/src/lib/link-copy.test.ts index e79905cd..99a5464f 100644 --- a/packages/app-core/src/lib/link-copy.test.ts +++ b/packages/app-core/src/lib/link-copy.test.ts @@ -34,6 +34,14 @@ describe('copyableLink', () => { expect(copyableLink('tel:+1-555-0100')).toMatchObject({ kind: 'phone', value: '+1-555-0100' }) }) + it('reads a wikilink as a note name, so a dotted one is not a web link', () => { + expect(copyableLink('2024.01.15', 'wikilink')).toBeNull() + expect(copyableLink('https://example.net', 'wikilink')).toMatchObject({ + kind: 'url', + value: 'https://example.net' + }) + }) + it('is null for notes, wikilinks, anchors, and local files', () => { expect(copyableLink('Meeting notes')).toBeNull() expect(copyableLink('folder/Note.md')).toBeNull() @@ -111,4 +119,13 @@ describe('copyLinkAtCursor', () => { expect(written).toEqual([]) view.destroy() }) + + it('does not copy a wikilink whose name looks like a domain', () => { + const written: string[] = [] + zenWindow.zen = { clipboardWriteText: (value) => written.push(value) } + const view = editor('go to [[2024.01.15]] now', 10) + expect(copyLinkAtCursor(view)).toBe(false) + expect(written).toEqual([]) + view.destroy() + }) }) diff --git a/packages/app-core/src/lib/link-copy.ts b/packages/app-core/src/lib/link-copy.ts index be4f3704..26463298 100644 --- a/packages/app-core/src/lib/link-copy.ts +++ b/packages/app-core/src/lib/link-copy.ts @@ -9,7 +9,7 @@ import type { EditorView } from '@codemirror/view' import type { ContextMenuItem } from '../components/ContextMenu' import { writeClipboardText } from './clipboard-text' -import { externalLinkUrl, linkRangeAtCursor } from './internal-links' +import { externalUrlForLink, linkRangeAtCursor, type LineLinkKind } from './internal-links' import { useToastStore } from './toast' export type CopyableLinkKind = 'url' | 'email' | 'phone' @@ -31,9 +31,10 @@ function decodeSafe(value: string): string { } /** The copyable form of a link target (`https://…`, `mailto:…`, `tel:…`, or a - * bare domain), or null when the target is not an outside link. */ -export function copyableLink(target: string): CopyableLink | null { - const url = externalLinkUrl(target) + * bare domain in an href), or null when the target is not an outside link. A + * wikilink is a note name, so `[[2024.01.15]]` is not a web link. */ +export function copyableLink(target: string, kind: LineLinkKind = 'markdown'): CopyableLink | null { + const url = externalUrlForLink(target, kind) if (!url) return null if (/^mailto:/i.test(url)) { const address = decodeSafe(url.slice('mailto:'.length).split('?')[0] ?? '').trim() @@ -95,7 +96,7 @@ export function linkMenuItems(link: CopyableLink): ContextMenuItem[] { /** The copyable link at a document offset, or null. */ export function copyableLinkAt(view: EditorView, pos: number): CopyableLink | null { const range = linkRangeAtCursor(view.state.doc.toString(), pos) - return range ? copyableLink(range.target) : null + return range ? copyableLink(range.target, range.kind) : null } /** Keyboard path (`gy`, Copy Link Under Cursor): copy the link the caret is diff --git a/packages/app-core/src/lib/local-assets-missing.test.ts b/packages/app-core/src/lib/local-assets-missing.test.ts new file mode 100644 index 00000000..3acd52d9 --- /dev/null +++ b/packages/app-core/src/lib/local-assets-missing.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// A file over Cloud's per-file limit never leaves the device that has it, so +// every other device gets the note and its `![[clip.mp4]]` with no file behind +// it. The vault's file listing is what tells such an embed apart from one whose +// file is merely not listed yet. + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + resolveLocalAssetUrl: vi.fn((_r: string, _n: string, href: string) => `zen-asset://note/${href}`), + resolveVaultAssetUrl: vi.fn((_r: string, rel: string) => `zen-asset://vault/${rel}`) + } + }) +} + +async function load() { + vi.resetModules() + localStorage.clear() + installZen() + const { useStore } = await import('../store') + const { resolveLocalAsset, enhanceLocalAssetNodes } = await import('./local-assets') + return { useStore, resolveLocalAsset, enhanceLocalAssetNodes } +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +const NOTE = 'inbox/Note.md' + +describe('resolveLocalAsset', () => { + it('names the listed file an href answers to, and its URL', async () => { + const { useStore, resolveLocalAsset } = await load() + useStore.setState({ assetFiles: [{ path: 'assets/clip.mp4' }] as never, assetFilesListed: true }) + expect(resolveLocalAsset('/v', NOTE, 'assets/clip.mp4')).toEqual({ + url: 'zen-asset://vault/assets/clip.mp4', + path: 'assets/clip.mp4', + missing: false + }) + }) + + it('calls the file missing when the listing read from the vault has nothing that answers', async () => { + const { useStore, resolveLocalAsset } = await load() + useStore.setState({ assetFiles: [{ path: 'assets/clip.mp4' }] as never, assetFilesListed: true }) + expect(resolveLocalAsset('/v', NOTE, 'assets/gone.mp4')).toEqual({ + url: 'zen-asset://note/assets/gone.mp4', + path: null, + missing: true + }) + }) + + it('calls the file missing in a vault whose listing holds no files at all', async () => { + const { useStore, resolveLocalAsset } = await load() + useStore.setState({ assetFiles: [], assetFilesListed: true }) + expect(resolveLocalAsset('/v', NOTE, 'assets/gone.mp4')?.missing).toBe(true) + }) + + it('does not call a file missing when the href cannot choose between two listed files of that name', async () => { + const { useStore, resolveLocalAsset } = await load() + useStore.setState({ + assetFiles: [{ path: 'Projects/A/clip.mp4' }, { path: 'Projects/B/clip.mp4' }] as never, + assetFilesListed: true + }) + expect(resolveLocalAsset('/v', NOTE, 'clip.mp4')).toEqual({ + url: 'zen-asset://note/clip.mp4', + path: null, + missing: false + }) + }) + + it('decides nothing before the first listing lands', async () => { + const { useStore, resolveLocalAsset } = await load() + useStore.setState({ assetFiles: [], assetFilesListed: false }) + expect(resolveLocalAsset('/v', NOTE, 'assets/gone.mp4')).toBeNull() + }) + + it('never calls a file missing from a list seeded by hand, and keeps the guessed URL', async () => { + // The share viewer and PDF export seed `assetFiles` themselves; the share + // viewer serves files its list leaves out from the guessed URL. + const { useStore, resolveLocalAsset } = await load() + useStore.setState({ assetFiles: [{ path: 'assets/clip.mp4' }] as never, assetFilesListed: false }) + expect(resolveLocalAsset('/v', NOTE, 'assets/gone.mp4')).toEqual({ + url: 'zen-asset://note/assets/gone.mp4', + path: null, + missing: false + }) + }) +}) + +describe('enhanceLocalAssetNodes: a missing file', () => { + it('says so where an embed on its own line would be, and leaves a picture inside a sentence alone', async () => { + const { useStore, enhanceLocalAssetNodes } = await load() + useStore.setState({ assetFiles: [{ path: 'assets/clip.mp4' }] as never, assetFilesListed: true }) + const root = document.createElement('div') + root.innerHTML = [ + '

A chart

', + '

See inline here.

' + ].join('') + + enhanceLocalAssetNodes(root, { vaultRoot: '/v', notePath: NOTE }) + + const card = root.querySelector('[data-local-asset-missing]')! + expect(card.dataset.localAssetMissing).toBe('image') + expect(card.dataset.localAssetHref).toBe('assets/gone.png') + expect(card.dataset.sourceLine).toBe('1') + // Not tagged as an asset: the asset menu has no file to act on, and a + // double-click on the card should edit its line like any block. + expect(card.dataset.localAssetKind).toBeUndefined() + expect(card.dataset.localAssetUrl).toBeUndefined() + expect(card.querySelector('[role="note"]')?.textContent).toBe("gone.png isn't on this device.") + expect(root.querySelectorAll('[data-local-asset-missing]')).toHaveLength(1) + expect(root.querySelector('img[alt="inline"]')?.dataset.localAssetUrl).toBe( + 'zen-asset://note/assets/inline.png' + ) + }) +}) diff --git a/packages/app-core/src/lib/local-assets.ts b/packages/app-core/src/lib/local-assets.ts index a289bf4d..7f8e3225 100644 --- a/packages/app-core/src/lib/local-assets.ts +++ b/packages/app-core/src/lib/local-assets.ts @@ -2,7 +2,12 @@ import { useStore } from '../store' import { externalLinkUrl } from './internal-links' import { openVaultAssetExternally } from './external-file-link' import { isExcalidrawPath, isObsidianExcalidrawPath } from '@shared/excalidraw' -import { resolveAssetPathAmong, stripQueryAndHash } from './asset-path-resolution' +import { + decodeHrefPath, + resolveAssetPathAmong, + stripQueryAndHash, + type AssetPathRef +} from './asset-path-resolution' import type { PreviewEditRequest } from './preview-outline-jump' type RequestEdit = (request?: PreviewEditRequest | null) => void @@ -46,24 +51,62 @@ export function classifyLocalAssetHref(href: string): LocalAssetKind | null { return 'file' } -export function resolveLocalAssetUrl( +/** Where an embed's href leads, as `resolveLocalAsset` finds it. */ +export interface LocalAsset { + url: string + /** Vault-relative path of the listed file the href names; null when no + * listed file answers it and `url` is the guess beside the note. */ + path: string | null + /** No file in the vault's own listing carries the name the href points + * at, so this device does not have it. An embed of it says so instead of + * drawing a player, picture or document with nothing to load. */ + missing: boolean +} + +export function resolveLocalAsset( vaultRoot: string | null | undefined, notePath: string | null | undefined, href: string -): string | null { +): LocalAsset | null { if (!vaultRoot || !notePath) return null - const resolvedRel = resolveAssetVaultRelativePath(vaultRoot, notePath, href) - if (resolvedRel) { - return window.zen.resolveVaultAssetUrl(vaultRoot, resolvedRel) + const { assetFiles, assetFilesListed } = useStore.getState() + const path = resolveAssetPathAmong(assetFiles, notePath, href) + if (path) { + const url = window.zen.resolveVaultAssetUrl(vaultRoot, path) + return url ? { url, path, missing: false } : null } // If the asset list hasn't arrived yet (cold start, before // `listAssets` resolves), skip producing a URL rather than baking in // the notedir-relative fallback. The cm-live-preview plugin // re-decorates as soon as `assetFiles` populates and the basename // search will then run with real data. This stops the wrong URL from - // being cached by the widget on the first paint. - if (useStore.getState().assetFiles.length === 0) return null - return window.zen.resolveLocalAssetUrl(vaultRoot, notePath, href) + // being cached by the widget on the first paint, and stops every embed + // from reading as missing until then. + if (assetFiles.length === 0 && !assetFilesListed) return null + const url = window.zen.resolveLocalAssetUrl(vaultRoot, notePath, href) + if (!url) return null + // A list seeded by hand (the share viewer's, PDF export's) never says a + // file is missing: the share viewer serves the files its list leaves out + // from this very URL. + return { url, path: null, missing: assetFilesListed && !listsFileNamed(assetFiles, href) } +} + +// A name two listed files share is a link that cannot choose between them, +// not a file this device lacks; it keeps the guessed URL it always had. +function listsFileNamed(assetFiles: ReadonlyArray, href: string): boolean { + const name = decodeHrefPath(href.trim()).split('/').filter(Boolean).pop()?.toLowerCase() + if (!name) return false + return assetFiles.some( + (asset) => asset.path.slice(asset.path.lastIndexOf('/') + 1).toLowerCase() === name + ) +} + +export function resolveLocalAssetUrl( + vaultRoot: string | null | undefined, + notePath: string | null | undefined, + href: string +): string | null { + return resolveLocalAsset(vaultRoot, notePath, href)?.url ?? null } /** @@ -200,6 +243,21 @@ function buildImageEmbed( return figure } +function buildEmbedEditButton(onEdit: () => void): HTMLButtonElement { + const edit = document.createElement('button') + edit.type = 'button' + edit.className = 'local-asset-embed-edit' + edit.textContent = '' + edit.title = 'Edit this block' + edit.setAttribute('aria-label', 'Edit this block') + edit.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + onEdit() + }) + return edit +} + /** * The PDF, audio and video embed of the reading view. The editor's live * preview draws a standalone audio or video line with this same builder, so @@ -251,20 +309,7 @@ export function buildEmbed( } header.append(title, open) - if (onEdit) { - const edit = document.createElement('button') - edit.type = 'button' - edit.className = 'local-asset-embed-edit' - edit.textContent = '' - edit.title = 'Edit this block' - edit.setAttribute('aria-label', 'Edit this block') - edit.addEventListener('click', (event) => { - event.preventDefault() - event.stopPropagation() - onEdit() - }) - header.append(edit) - } + if (onEdit) header.append(buildEmbedEditButton(onEdit)) figure.append(header) if (kind === 'pdf') { @@ -428,6 +473,52 @@ export function setCloudSyncNotice(host: HTMLElement, text: string | null): void if (notice.textContent !== text) notice.textContent = text } +/** The embeds that say so when their file is not on this device. Any other + * attachment keeps its chip: the vault listing leaves out files a chip can + * still name (notes, drawings, dotfiles), so it cannot vouch for those. */ +export type MissingAssetKind = 'image' | 'pdf' | 'audio' | 'video' + +export function isMissingAssetKind(kind: LocalAssetKind | null): kind is MissingAssetKind { + return kind === 'image' || kind === 'pdf' || kind === 'audio' || kind === 'video' +} + +/** + * Stands in for the picture, document or player of an embed whose file is not + * on this device, the state every other device is in when a file too large for + * Cloud stays on the one that has it. One line in the embed's card, worded and + * coloured as the Cloud notice that device shows under its player. The reading + * view and the editor build the same card; the editor passes `onEdit` for the + * `` action its other embeds carry, and the reading view the block's + * `sourceLine`, so a double-click edits the line as on any block. + */ +export function buildMissingAssetNotice( + kind: MissingAssetKind, + href: string, + options: { sourceLine?: number | null; onEdit?: (() => void) | null } = {} +): HTMLElement { + const figure = document.createElement('figure') + figure.className = 'local-asset-embed local-asset-missing not-prose' + // Not `data-local-asset-kind`: that marks a file the asset menu can act on, + // and keeps a double-click from editing the block. There is no file here. + figure.dataset.localAssetMissing = kind + figure.dataset.localAssetHref = href + if (options.sourceLine != null) figure.dataset.sourceLine = String(options.sourceLine) + + const notice = document.createElement('div') + notice.className = 'local-asset-missing-notice whitespace-normal break-words text-xs text-warning' + notice.setAttribute('role', 'note') + notice.textContent = `${localAssetLabel(href, 'This file')} isn't on this device.` + figure.append(notice) + + if (options.onEdit) figure.append(buildEmbedEditButton(options.onEdit)) + return figure +} + +function blockSourceLine(block: HTMLElement): number | null { + const line = Number(block.dataset.sourceLine) + return Number.isFinite(line) && line >= 1 ? line : null +} + export function enhanceLocalAssetNodes( root: HTMLElement, options: { @@ -462,9 +553,9 @@ export function enhanceLocalAssetNodes( root.querySelectorAll('img[src]').forEach((img) => { const raw = img.getAttribute('src') || '' - const resolved = resolveLocalAssetUrl(vaultRoot, notePath, raw) - if (!resolved) return - const assetVaultRel = resolveAssetVaultRelativePath(vaultRoot, notePath, raw) + const asset = resolveLocalAsset(vaultRoot, notePath, raw) + if (!asset) return + const { url: resolved, path: assetVaultRel } = asset // #463: a non-image file embedded with image syntax (`![](file.tldraw)`) // is a broken with no indication it's an attachment. Denote it — a @@ -485,6 +576,12 @@ export function enhanceLocalAssetNodes( const standalone = isStandaloneImageParagraph(img) if (standalone && standalone.dataset.assetEmbed !== 'true') { standalone.dataset.assetEmbed = 'true' + if (asset.missing && isMissingAssetKind(imgKind)) { + standalone.replaceWith( + buildMissingAssetNotice(imgKind, raw, { sourceLine: blockSourceLine(standalone) }) + ) + return + } // Audio and video in image syntax (`![](clip.mp4)`, the Markdown // spelling of `![[clip.mp4]]`) play here as the wikilink form does, // and as the editor draws both. PDFs in this form still chip. @@ -529,6 +626,18 @@ export function enhanceLocalAssetNodes( return } + // Ahead of `img.src`, so no request goes out for a file that is not here. + // A picture inside a sentence stays in the sentence. + if (asset.missing && imgKind === 'image') { + const standalone = isStandaloneImageParagraph(img) + if (standalone && standalone.dataset.assetEmbed !== 'true') { + standalone.replaceWith( + buildMissingAssetNotice('image', raw, { sourceLine: blockSourceLine(standalone) }) + ) + return + } + } + img.src = resolved img.loading = 'lazy' img.dataset.localAssetUrl = resolved @@ -559,10 +668,10 @@ export function enhanceLocalAssetNodes( // `https://…`) isn't a vault asset — leave both for the link-navigation // handlers instead of rewriting them to a zen-asset URL. (#201) if (/\.md(?:[#?].*)?$/i.test(raw.trim()) || externalLinkUrl(raw)) return - const resolved = resolveLocalAssetUrl(vaultRoot, notePath, raw) - if (!resolved) return + const asset = resolveLocalAsset(vaultRoot, notePath, raw) + if (!asset) return - const assetVaultRel = resolveAssetVaultRelativePath(vaultRoot, notePath, raw) + const { url: resolved, path: assetVaultRel } = asset const kind = classifyLocalAssetHref(raw) ?? 'file' anchor.href = resolved + hrefFragment(raw) anchor.dataset.localAssetUrl = resolved @@ -583,6 +692,12 @@ export function enhanceLocalAssetNodes( const paragraph = isStandaloneAnchorParagraph(anchor) if (!paragraph || paragraph.dataset.assetEmbed === 'true') return paragraph.dataset.assetEmbed = 'true' + if (asset.missing) { + paragraph.replaceWith( + buildMissingAssetNotice(kind, raw, { sourceLine: blockSourceLine(paragraph) }) + ) + return + } const label = localAssetLabel(raw, anchor.textContent?.trim() || 'Asset') if (kind === 'pdf' && pinnedAssetPath) { if (assetVaultRel === pinnedAssetPath) { diff --git a/packages/app-core/src/lib/note-editor-context.ts b/packages/app-core/src/lib/note-editor-context.ts index fdb9beb9..553d2dd5 100644 --- a/packages/app-core/src/lib/note-editor-context.ts +++ b/packages/app-core/src/lib/note-editor-context.ts @@ -17,3 +17,8 @@ export function noteEditorMatches(view: EditorView, path: string, paneId: string export function noteEditorPath(view: EditorView): string | null { return noteEditors.get(view)?.path() ?? null } + +/** The pane a registered note editor lives in, or null for any other editor. */ +export function noteEditorPaneId(view: EditorView): string | null { + return noteEditors.get(view)?.paneId ?? null +} diff --git a/packages/app-core/src/lib/overlay-open.test.ts b/packages/app-core/src/lib/overlay-open.test.ts index 1abf328f..240f6155 100644 --- a/packages/app-core/src/lib/overlay-open.test.ts +++ b/packages/app-core/src/lib/overlay-open.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' -import { isAppOverlayOpen } from './overlay-open' +import { isAppOverlayOpen, isOverlayOrDialogOpen } from './overlay-open' afterEach(() => { document.body.innerHTML = '' @@ -32,3 +32,31 @@ describe('isAppOverlayOpen', () => { expect(isAppOverlayOpen()).toBe(true) }) }) + +describe('isOverlayOrDialogOpen', () => { + const open = (attrs: Record): void => { + const el = document.createElement('div') + for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value) + document.body.appendChild(el) + } + + it('is false with nothing open', () => { + expect(isOverlayOrDialogOpen()).toBe(false) + }) + + it('covers the context menus and prompts isAppOverlayOpen covers', () => { + open({ 'data-ctx-menu': '' }) + expect(isOverlayOrDialogOpen()).toBe(true) + }) + + it('is true while Settings is open, which isAppOverlayOpen misses', () => { + open({ role: 'dialog', 'aria-modal': 'true', 'data-settings-dialog': '' }) + expect(isAppOverlayOpen()).toBe(false) + expect(isOverlayOrDialogOpen()).toBe(true) + }) + + it('is true while a shared Modal dialog (a palette, a picker) is open', () => { + open({ role: 'dialog', 'aria-modal': 'true' }) + expect(isOverlayOrDialogOpen()).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/overlay-open.ts b/packages/app-core/src/lib/overlay-open.ts index ab2cb931..8b768e8c 100644 --- a/packages/app-core/src/lib/overlay-open.ts +++ b/packages/app-core/src/lib/overlay-open.ts @@ -16,3 +16,14 @@ export function isAppOverlayOpen(): boolean { document.querySelector('[data-cloud-conflict-dialog]') ) } + +/** + * isAppOverlayOpen, plus every dialog the shared Modal shell draws (palettes, + * pickers) and Settings: both mark their panel aria-modal. For views whose + * single-key shortcuts must not act behind any of them. The close shortcut + * keeps its own rule (close-shortcut.ts), since Mod+W closes Settings itself. + */ +export function isOverlayOrDialogOpen(): boolean { + if (typeof document === 'undefined') return false + return isAppOverlayOpen() || document.querySelector('[aria-modal="true"]') !== null +} diff --git a/packages/app-core/src/lib/settings-navigation.ts b/packages/app-core/src/lib/settings-navigation.ts index d034b11a..dd5cf5e5 100644 --- a/packages/app-core/src/lib/settings-navigation.ts +++ b/packages/app-core/src/lib/settings-navigation.ts @@ -1,6 +1,7 @@ /** Settings pages other surfaces can open directly: the Cloud page from the - * status bar, the Keymaps page from `:unbind` when the action id is missing - * or unknown, the CLI page from Open CLI Settings. */ + * 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. */ export type SettingsNavigationTarget = "cloud" | "keymaps" | "external-links" | "about" | "cli"; let pendingSettingsTarget: SettingsNavigationTarget | null = null; diff --git a/packages/app-core/src/lib/typography-variables.ts b/packages/app-core/src/lib/typography-variables.ts new file mode 100644 index 00000000..65fe303b --- /dev/null +++ b/packages/app-core/src/lib/typography-variables.ts @@ -0,0 +1,48 @@ +/** + * The CSS variables the editor and Preview read for size, line height, reading + * width and the three font families. The app sets them from its preferences; + * the Quick Look preview sets them from the same preferences in config.toml, + * so a note looks alike in both. + */ +export interface TypographyVariables { + editorFontSize: number + mathFontScale: number + editorLineHeight: number + previewMaxWidth: number + editorMaxWidth?: number + contentAlign: string + completedTaskStyle: string + mathRenderer: string + lineNumberPosition?: string + interfaceFont: string | null + textFont: string | null + monoFont: string | null +} + +// Each family has its own fallback stack, so leaving a font unset uses the +// platform default. +const INTERFACE_FONT_FALLBACK = '-apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, system-ui, sans-serif' +const TEXT_FONT_FALLBACK = '"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace' +const MONO_FONT_FALLBACK = '"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace' + +export function applyTypographyVariables(html: HTMLElement, values: TypographyVariables): void { + html.style.setProperty('--z-editor-font-size', `${values.editorFontSize}px`) + html.style.setProperty('--z-math-scale', String(values.mathFontScale / 100)) + html.style.setProperty('--z-editor-line-height', String(values.editorLineHeight)) + html.style.setProperty('--z-preview-max-width', `${values.previewMaxWidth}px`) + if (values.editorMaxWidth !== undefined) { + html.style.setProperty('--z-editor-max-width', `${values.editorMaxWidth}px`) + } + html.dataset.contentAlign = values.contentAlign + html.dataset.completedTaskStyle = values.completedTaskStyle + html.dataset.mathRenderer = values.mathRenderer + if (values.lineNumberPosition !== undefined) html.dataset.lineNumberPosition = values.lineNumberPosition + + const setFont = (name: string, value: string | null, fallback: string): void => { + if (value) html.style.setProperty(name, `"${value}", ${fallback}`) + else html.style.removeProperty(name) + } + setFont('--z-interface-font', values.interfaceFont, INTERFACE_FONT_FALLBACK) + setFont('--z-text-font', values.textFont, TEXT_FONT_FALLBACK) + setFont('--z-mono-font', values.monoFont, MONO_FONT_FALLBACK) +} diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 853315c9..39cf7f45 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -670,13 +670,25 @@ describe('local vault shortcuts', () => { }) const { useStore } = await loadStore() - useStore.setState({ vault: { root: '/Users/test/Notes', name: 'Notes' } }) + useStore.setState({ + vault: { root: '/Users/test/Notes', name: 'Notes' }, + assetFilesListed: true + }) + // The previous vault's listing must not vouch for the next vault's empty + // list, or every embed there would read as missing until its files land. + const listedWhenVaultChanged: boolean[] = [] + const unsubscribe = useStore.subscribe((state, prev) => { + if (state.vault !== prev.vault) listedWhenVaultChanged.push(state.assetFilesListed) + }) await useStore.getState().openLocalVault('/Users/test/Work') + unsubscribe() expect(listAssets).toHaveBeenCalledTimes(1) expect(useStore.getState().assetFiles).toEqual(assetFiles) expect(useStore.getState().hasAssetsDir).toBe(true) + expect(listedWhenVaultChanged).toEqual([false]) + expect(useStore.getState().assetFilesListed).toBe(true) }) it('switches from a remote workspace to a local vault that shares the server path', async () => { @@ -747,6 +759,7 @@ describe('local vault shortcuts', () => { updatedAt: 1 } ], + assetFilesListed: true, selectedPath: 'inbox/Note.md', activeNote: makeNote('Body') }) @@ -759,6 +772,7 @@ describe('local vault shortcuts', () => { expect(useStore.getState().notes).toEqual([]) expect(useStore.getState().folders).toEqual([]) expect(useStore.getState().assetFiles).toEqual([]) + expect(useStore.getState().assetFilesListed).toBe(false) expect(useStore.getState().selectedPath).toBeNull() expect(useStore.getState().activeNote).toBeNull() expect(useStore.getState().workspaceRestored).toBe(true) @@ -952,6 +966,91 @@ describe('asset undo', () => { }) }) +describe('asset index', () => { + const listedClip = { + path: 'assets/clip.mp4', + name: 'clip.mp4', + kind: 'video' as const, + siblingOrder: 0, + size: 42, + updatedAt: 1 + } + + it('marks the listing read once it lands, even when the vault holds no files', async () => { + installZen({ listAssets: vi.fn().mockResolvedValue([]) }) + const { useStore } = await loadStore() + expect(useStore.getState().assetFilesListed).toBe(false) + + await useStore.getState().refreshAssets() + + expect(useStore.getState().assetFiles).toEqual([]) + expect(useStore.getState().assetFilesListed).toBe(true) + }) + + it('lists files the app just imported at once, and no listing read before the import drops them', async () => { + const pasted = { + name: 'paste.png', + path: 'assets/paste.png', + markdown: '![[assets/paste.png]]', + kind: 'image' as const + } + const settled = { + path: 'assets/paste.png', + name: 'paste.png', + kind: 'image' as const, + siblingOrder: 3, + size: 512, + updatedAt: 9 + } + const landings: Array<(assets: unknown[]) => void> = [] + const listAssets = vi.fn( + () => new Promise((resolve) => { landings.push(resolve) }) + ) + installZen({ listAssets }) + const { useStore } = await loadStore() + useStore.setState({ assetFiles: [listedClip], assetFilesListed: true }) + const paths = (): string[] => useStore.getState().assetFiles.map((asset) => asset.path) + // A listing already on its way, read before the paste was written. + const earlier = useStore.getState().refreshAssets() + + useStore.getState().indexImportedAssets([pasted, { ...pasted }]) + + // At once, before any listing: the embed about to go into the note finds it. + expect(paths()).toEqual(['assets/paste.png', 'assets/clip.mp4']) + landings[0]!([listedClip]) + await earlier + expect(paths()).toEqual(['assets/paste.png', 'assets/clip.mp4']) + + // The listing started for the import settles the file's real details. + expect(listAssets).toHaveBeenCalledTimes(2) + landings[1]!([settled, listedClip]) + await vi.waitFor(() => expect(useStore.getState().assetFiles).toEqual([settled, listedClip])) + }) + + it('adds nothing for files already listed, or before the first listing lands', async () => { + const listAssets = vi.fn().mockResolvedValue([]) + installZen({ listAssets }) + const { useStore } = await loadStore() + const imported = { + name: 'clip.mp4', + path: 'assets/clip.mp4', + markdown: '![[assets/clip.mp4]]', + kind: 'video' as const + } + + useStore.setState({ assetFiles: [listedClip], assetFilesListed: true }) + const listed = useStore.getState().assetFiles + useStore.getState().indexImportedAssets([imported]) + expect(useStore.getState().assetFiles).toBe(listed) + + // That first listing is on its way, and it carries the new file too. + useStore.setState({ assetFiles: [], assetFilesListed: false }) + useStore.getState().indexImportedAssets([{ ...imported, path: 'assets/new.mp4' }]) + expect(useStore.getState().assetFiles).toEqual([]) + expect(listAssets).not.toHaveBeenCalled() + }) +}) + describe('vault text search jumps', () => { it('records the pending editor jump before loading an unopened note', async () => { const note = makeNote('first line\nsecond line target\n') @@ -1754,6 +1853,40 @@ describe('vimWrappedLineMotions (#638)', () => { }) }) +describe('kanbanCardSort (#889)', () => { + it('defaults to the manual order and round-trips due-date ordering', async () => { + installZen() + const { useStore } = await loadStore() + expect(useStore.getState().kanbanCardSort).toBe('manual') + + useStore.getState().setKanbanCardSort('due') + expect(useStore.getState().kanbanCardSort).toBe('due') + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.kanbanCardSort).toBe('due') + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().kanbanCardSort).toBe('due') + }) + + it('normalizes an unknown stored value to the manual order', async () => { + installZen() + localStorage.setItem('zen:prefs:v2', JSON.stringify({ kanbanCardSort: 'priority' })) + const { useStore } = await loadStore() + expect(useStore.getState().kanbanCardSort).toBe('manual') + }) + + it('leaves the saved hand arrangement alone while cards follow due dates', async () => { + installZen() + const { useStore } = await loadStore() + const arranged = ['inbox/b.md\u00000', 'inbox/a.md\u00000'] + useStore.getState().setKanbanCardOrder({ 'field:status:review': arranged }) + useStore.getState().setKanbanCardSort('due') + useStore.getState().setKanbanCardSort('manual') + expect(useStore.getState().kanbanCardOrder['field:status:review']).toEqual(arranged) + }) +}) + describe('workflowsEnabled (Workflows feature switch)', () => { it('defaults off and round-trips the opt-in through persistence', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 1d453a68..6c1e8932 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -28,6 +28,7 @@ import type { DateNotePatternSettings, DeletedAsset, FolderEntry, + ImportedAsset, LocalVaultEntry, NoteComment, NoteCommentInput, @@ -744,6 +745,10 @@ interface Prefs { showArchivedTasks: boolean /** Column source used when the Tasks Kanban view is active. */ kanbanGroupBy: KanbanGroupBy + /** How cards line up inside each Kanban column: `manual` replays the order + * they were dragged into (due date until then), `due` always sorts them by + * due date and leaves that arrangement untouched for later. (#889) */ + kanbanCardSort: KanbanCardSort /** Display-only Kanban column title overrides. Keyed by `${groupBy}:${columnId}`. */ kanbanColumnTitles: Record /** Manual Kanban column arrangement per board. Keyed by groupBy → ordered @@ -765,6 +770,7 @@ interface Prefs { export type TasksViewMode = 'list' | 'calendar' | 'kanban' export type KanbanGroupBy = 'status' | 'priority' | 'folder' | `field:${string}` +export type KanbanCardSort = 'manual' | 'due' /** How the Tags view combines multiple selected tags: `all` = intersection * (AND, narrows), `any` = union (OR, widens). */ export type TagMatchMode = 'all' | 'any' @@ -1166,6 +1172,7 @@ export const DEFAULT_PREFS: Prefs = { tasksViewMode: 'list', showArchivedTasks: false, kanbanGroupBy: 'status', + kanbanCardSort: 'manual', kanbanColumnTitles: {}, kanbanColumnOrder: {}, kanbanCardOrder: {}, @@ -1515,6 +1522,7 @@ function normalizePrefs(p: Partial): Prefs { ? p.showArchivedTasks : DEFAULT_PREFS.showArchivedTasks, kanbanGroupBy: normalizeKanbanGroupBy(p.kanbanGroupBy), + kanbanCardSort: p.kanbanCardSort === 'due' ? 'due' : 'manual', kanbanColumnTitles: normalizeKanbanColumnTitles(p.kanbanColumnTitles), kanbanColumnOrder: normalizeKanbanColumnOrder(p.kanbanColumnOrder), kanbanCardOrder: normalizeKanbanCardOrder(p.kanbanCardOrder), @@ -2416,6 +2424,7 @@ function collectPrefs(s: { tasksViewMode: TasksViewMode showArchivedTasks: boolean kanbanGroupBy: KanbanGroupBy + kanbanCardSort: KanbanCardSort kanbanColumnTitles: Record kanbanColumnOrder: Record kanbanCardOrder: Record @@ -2523,6 +2532,7 @@ function collectPrefs(s: { tasksViewMode: s.tasksViewMode, showArchivedTasks: s.showArchivedTasks, kanbanGroupBy: s.kanbanGroupBy, + kanbanCardSort: s.kanbanCardSort, kanbanColumnTitles: s.kanbanColumnTitles, kanbanColumnOrder: s.kanbanColumnOrder, kanbanCardOrder: s.kanbanCardOrder, @@ -2896,6 +2906,12 @@ interface Store { typstPreambleNotes: TypstPreambleNote[] folders: FolderEntry[] assetFiles: AssetMeta[] + /** True once `refreshAssets` has read the open vault's files into + * `assetFiles`, so an empty list means a vault with no files rather than + * one not listed yet. Only that listing may call an embedded file missing: + * the share viewer and PDF export seed `assetFiles` themselves and leave + * this false. */ + assetFilesListed: boolean assetUndoStack: AssetUndoEntry[] hasAssetsDir: boolean view: View @@ -3158,6 +3174,8 @@ interface Store { showArchivedTasks: boolean /** Column source for the Tasks Kanban view. */ kanbanGroupBy: KanbanGroupBy + /** Cards in each Kanban column by hand-placed order or by due date. (#889) */ + kanbanCardSort: KanbanCardSort /** Display-only column title overrides for the Tasks Kanban view. */ kanbanColumnTitles: Record /** Manual column arrangement per board (groupBy → ordered column ids). */ @@ -3396,6 +3414,7 @@ interface Store { * surface, and a bulk archive asks once, not once per note. */ confirmArchiveNotes: (paths: string[]) => Promise setKanbanGroupBy: (group: KanbanGroupBy) => void + setKanbanCardSort: (sort: KanbanCardSort) => void setKanbanColumnTitle: ( group: KanbanGroupBy, columnId: string, @@ -3445,6 +3464,11 @@ interface Store { /** Dismiss the vault-root notice for the current vault, persisted (#216). */ dismissRootContentBanner: () => void refreshAssets: () => Promise + /** Put files the app has just saved into `assetFiles` at once, ahead of the + * listing that will carry them, and start that listing. Called before the + * note gains their embeds, which would otherwise read as files this device + * does not have until the listing lands. */ + indexImportedAssets: (assets: readonly ImportedAsset[]) => void /** Rename an asset file in place. Every note referencing it is rewritten on * disk (#785), so open buffers are flushed first and notes re-listed after. */ renameAsset: (relPath: string, nextName: string) => Promise @@ -5585,6 +5609,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -5628,6 +5653,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -5664,6 +5690,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -5707,6 +5734,7 @@ export const useStore = create((set, get) => { typstPreambleNotes: [], folders: [], assetFiles: [], + assetFilesListed: false, assetUndoStack: [], hasAssetsDir: false, view: { kind: 'folder', folder: 'inbox', subpath: '' }, @@ -5840,6 +5868,7 @@ export const useStore = create((set, get) => { tasksViewMode: loadPrefs().tasksViewMode, showArchivedTasks: loadPrefs().showArchivedTasks, kanbanGroupBy: loadPrefs().kanbanGroupBy, + kanbanCardSort: loadPrefs().kanbanCardSort, kanbanColumnTitles: loadPrefs().kanbanColumnTitles, kanbanColumnOrder: loadPrefs().kanbanColumnOrder, kanbanCardOrder: loadPrefs().kanbanCardOrder, @@ -7169,6 +7198,10 @@ export const useStore = create((set, get) => { savePrefs(collectPrefs(get())) persistVaultViewOverride({ kanbanGroupBy: group }) }, + setKanbanCardSort: (sort) => { + set({ kanbanCardSort: sort }) + savePrefs(collectPrefs(get())) + }, setKanbanColumnTitle: (group, columnId, title) => { const key = `${group}:${columnId}` const normalized = typeof title === 'string' ? normalizeKanbanColumnTitle(title) : null @@ -7516,6 +7549,7 @@ export const useStore = create((set, get) => { const assetFiles = rawAssets.filter((a) => !isDatabaseInternalPath(a.path)) set({ assetFiles, + assetFilesListed: true, hasAssetsDir: hasAssetsDirOnDisk || assetFiles.length > 0 }) recordRendererPerf('store.refreshAssets.fetch', performance.now() - startedAt, { @@ -7527,6 +7561,27 @@ export const useStore = create((set, get) => { } }, + indexImportedAssets: (assets) => { + const s = get() + // Before the first listing there is nothing to add to, and that listing + // is already on its way with these files in it. + if (!s.assetFilesListed || assets.length === 0) return + const listed = new Set(s.assetFiles.map((asset) => asset.path)) + const updatedAt = Date.now() + const added: AssetMeta[] = [] + for (const asset of assets) { + if (listed.has(asset.path)) continue + listed.add(asset.path) + added.push({ path: asset.path, name: asset.name, kind: asset.kind, siblingOrder: 0, size: 0, updatedAt }) + } + if (added.length === 0) return + set({ assetFiles: [...added, ...s.assetFiles], hasAssetsDir: true }) + // A listing already in flight may have been read before these files were + // written, and landing after this would drop them again. A fresh one + // supersedes it and brings their real size and time. + void get().refreshAssets() + }, + deleteAsset: async (relPath) => { if (typeof window.zen.deleteAsset !== 'function') { window.alert('Asset deletion is not available until the app is restarted.') @@ -10656,6 +10711,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -10747,6 +10803,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -10784,6 +10841,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -10916,6 +10974,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -11000,6 +11059,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, @@ -11084,6 +11144,7 @@ export const useStore = create((set, get) => { folders: [], hasAssetsDir: false, assetFiles: [], + assetFilesListed: false, assetUndoStack: [], closedTabStack: [], workflowRunRecord: null, diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index fa9804ce..481152b1 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -3681,6 +3681,30 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite border-top: 0; } +/* An embed whose file is not on this device (`buildMissingAssetNotice`): the + embed's card around one line, in place of the picture, document or player. + As on the Cloud notice, the line's colour and size are tokens on the + element. The editor drops the host line's strut (`cm-image-embed-line`), so + the card brings its own line-height. */ +:is(.prose-zen, .cm-editor) .local-asset-missing { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0.75rem 0; + padding: 0.6rem 0.85rem; + line-height: 1.4; +} + +:is(.prose-zen, .cm-editor) .local-asset-missing-notice { + flex: 1 1 auto; + min-width: 0; +} + +.cm-editor .cm-local-missing-embed { + max-width: min(100%, 980px); + margin: 0.5rem 0 0.6rem; +} + /* #463 — attachment chip: denotes a non-previewable file (`.tldraw`, `.zip`, …) embedded with image syntax `![](file.ext)`. Shared by the reading preview and the editor's live-preview widget so they look identical. */ diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 80d6efa5..14f60215 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.61.0", + "version": "2.62.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 689e515c..16b2d322 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -96,6 +96,7 @@ export const PORTABLE_PREF_KEYS = [ 'tasksViewMode', 'showArchivedTasks', 'kanbanGroupBy', + 'kanbanCardSort', 'kanbanFolderRoot', 'kanbanColumnTitles', 'kanbanStatuses', diff --git a/packages/bridge-contract/src/cloud-sync.ts b/packages/bridge-contract/src/cloud-sync.ts index 7e1741ef..2d88b6c6 100644 --- a/packages/bridge-contract/src/cloud-sync.ts +++ b/packages/bridge-contract/src/cloud-sync.ts @@ -240,11 +240,27 @@ export interface CloudBackupSnapshotCollection { data: CloudBackupSnapshot[]; } +/** + * Why the newest automatic backup did not happen. The service clears it once + * an automatic backup succeeds, and when automatic backups are turned off. + */ +export interface CloudBackupScheduleFailure { + /** BACKUP_QUOTA_EXCEEDED names the plan limit in `details` (`limit`, + * `current`, `allowed`); AUTOMATIC_BACKUP_FAILED is a backup that failed. */ + code: string; + /** The service's own English sentence. */ + message: string | null; + details: Record | null; + occurred_at: string | null; +} + export interface CloudBackupSchedule { enabled: boolean; frequency: "daily"; next_backup_at: string | null; last_backup_at: string | null; + /** Absent from a service that predates it. */ + last_failure?: CloudBackupScheduleFailure | null; } export interface CloudBackupScheduleResponse { diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 0ed81bd6..6338930c 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.61.0", + "version": "2.62.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 198c6ff1..ae627925 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -156,6 +156,7 @@ export const PORTABLE_DEFAULTS: Record = { tasksViewMode: 'list', showArchivedTasks: false, kanbanGroupBy: 'status', + kanbanCardSort: 'manual', kanbanFolderRoot: '', kanbanColumnTitles: {}, kanbanStatuses: [], diff --git a/packages/shared-domain/src/cloud-backup.test.ts b/packages/shared-domain/src/cloud-backup.test.ts index 2578a61f..d094a7ef 100644 --- a/packages/shared-domain/src/cloud-backup.test.ts +++ b/packages/shared-domain/src/cloud-backup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { CloudSyncVault } from '@zennotes/bridge-contract/cloud-sync' -import { restoreCloudBackup } from './cloud-backup' +import { cloudBackupCreateError, cloudBackupLimitMessage, restoreCloudBackup } from './cloud-backup' const vault: CloudSyncVault = { id: 'vault-1', @@ -111,3 +111,127 @@ describe('restoreCloudBackup', () => { ).rejects.toThrow('still running') }) }) + +/** The 409 the service answers a backup with when it would pass a plan limit, + * as the desktop and phone request errors both carry it. */ +function refused(details: unknown) { + return Object.assign(new Error('This backup would exceed your plan limits.'), { + name: 'CloudServiceRequestError', + status: 409, + code: 'BACKUP_QUOTA_EXCEEDED', + details + }) +} + +describe('cloudBackupLimitMessage', () => { + it('names the size limit in the decimal units plans are sold in', () => { + expect( + cloudBackupLimitMessage( + refused({ limit: 'max_snapshot_bytes', current: 54_200_000, allowed: 52_428_800 }) + ) + ).toBe( + 'This vault holds 54 MB, and backups on your plan hold up to 52 MB. Remove large files, or contact support to raise the limit.' + ) + }) + + it('says the vault is just over the size limit when both print the same', () => { + expect( + cloudBackupLimitMessage( + refused({ limit: 'max_snapshot_bytes', current: 52_430_000, allowed: 52_428_800 }) + ) + ).toBe( + 'This vault holds just over 52 MB, and backups on your plan hold up to 52 MB. Remove large files, or contact support to raise the limit.' + ) + }) + + it('names the file limit with grouped counts', () => { + expect( + cloudBackupLimitMessage( + refused({ limit: 'max_snapshot_items', current: 12_345, allowed: 10_000 }) + ) + ).toBe( + 'This vault has 12,345 files, and backups on your plan hold up to 10,000. Remove files you no longer need, or contact support to raise the limit.' + ) + }) + + it('asks for one older backup to go when the vault keeps as many as the plan allows', () => { + expect( + cloudBackupLimitMessage(refused({ limit: 'max_snapshots', current: 30, allowed: 30 })) + ).toBe( + 'This vault already keeps 30 backups, the most your plan allows. Delete an older backup to make room.' + ) + expect( + cloudBackupLimitMessage(refused({ limit: 'max_snapshots', current: 1, allowed: 1 })) + ).toBe( + 'This vault already keeps 1 backup, the most your plan allows. Delete an older backup to make room.' + ) + }) + + it('counts the backups to delete when the vault keeps more than a lowered limit', () => { + expect( + cloudBackupLimitMessage(refused({ limit: 'max_snapshots', current: 35, allowed: 30 })) + ).toBe( + 'This vault keeps 35 backups, more than the 30 your plan allows. Delete 6 older backups to make room.' + ) + }) + + it('reads the failure an automatic backup records the same way', () => { + expect( + cloudBackupLimitMessage({ + code: 'BACKUP_QUOTA_EXCEEDED', + message: 'The latest automatic backup was skipped because this vault reached a backup limit.', + details: { limit: 'max_snapshot_items', current: 10_001, allowed: 10_000 }, + occurred_at: '2026-10-05T03:15:00+00:00' + }) + ).toBe( + 'This vault has 10,001 files, and backups on your plan hold up to 10,000. Remove files you no longer need, or contact support to raise the limit.' + ) + }) + + it.each([ + [ + 'another refusal', + Object.assign(new Error('Upgrade required.'), { + status: 403, + code: 'FEATURE_NOT_ENTITLED', + details: { limit: 'max_snapshots', current: 30, allowed: 30 } + }) + ], + ['a limit this version does not know', refused({ limit: 'max_archive_bytes', current: 9, allowed: 8 })], + ['a refusal without details', refused(null)], + ['details sent as a list', refused(['max_snapshots', 30, 30])], + ['counts sent as text', refused({ limit: 'max_snapshots', current: '30', allowed: '30' })], + ['fractional counts', refused({ limit: 'max_snapshot_items', current: 12.5, allowed: 10 })], + ['a vault under the limit it names', refused({ limit: 'max_snapshot_bytes', current: 1_000, allowed: 52_428_800 })], + ['a network failure', new TypeError('fetch failed')], + ['nothing at all', null] + ])('leaves the service message standing for %s', (_case, failure) => { + expect(cloudBackupLimitMessage(failure)).toBeNull() + }) +}) + +describe('cloudBackupCreateError', () => { + it('rewords a plan-limit refusal as the text Electron carries across IPC, keeping the cause', () => { + const refusal = refused({ limit: 'max_snapshots', current: 30, allowed: 30 }) + const thrown = cloudBackupCreateError(refusal) + + // ipcMain.handle sends a rejection to the window as String(error). + expect(String(thrown)).toBe( + 'Error: This vault already keeps 30 backups, the most your plan allows. Delete an older backup to make room.' + ) + expect((thrown as Error).cause).toBe(refusal) + }) + + it('returns any other failure untouched', () => { + const outage = Object.assign(new Error('ZenNotes Cloud request failed (503).'), { + name: 'CloudServiceRequestError', + status: 503, + code: null, + details: null + }) + const unknownLimit = refused({ limit: 'max_archive_bytes', current: 9, allowed: 8 }) + + expect(cloudBackupCreateError(outage)).toBe(outage) + expect(cloudBackupCreateError(unknownLimit)).toBe(unknownLimit) + }) +}) diff --git a/packages/shared-domain/src/cloud-backup.ts b/packages/shared-domain/src/cloud-backup.ts index 44419ece..96b0cfba 100644 --- a/packages/shared-domain/src/cloud-backup.ts +++ b/packages/shared-domain/src/cloud-backup.ts @@ -9,6 +9,7 @@ import type { CloudSyncRunSummary, CloudSyncVaultCollection } from '@zennotes/bridge-contract/cloud-sync' +import { formatCloudBytes } from './cloud-bytes' export interface CloudBackupRestoreClient { listVaults(): Promise @@ -102,6 +103,64 @@ export function cloudBackupItemsPage( } } +/** + * The plan limit a refused backup ran into, with the numbers. The service + * names the limit and both counts only in the details of BACKUP_QUOTA_EXCEEDED + * (a refused request, or the failure an automatic backup records); its message + * names none of them. Null for any other failure and for details this version + * cannot read, so the service's own message stands. + */ +export function cloudBackupLimitMessage(failure: unknown): string | null { + if (!failure || typeof failure !== 'object') return null + const { code, details } = failure as { code?: unknown; details?: unknown } + if (code !== 'BACKUP_QUOTA_EXCEEDED' || !details || typeof details !== 'object') return null + const { limit, current, allowed } = details as Record + // The service refuses only at or over a limit, so counts below it are not a + // shape this version can word truthfully. + if (!isCount(current) || !isCount(allowed) || current < allowed) return null + + switch (limit) { + case 'max_snapshot_bytes': { + const most = formatCloudBytes(allowed) + const size = formatCloudBytes(current) + // Sizes from 10 MB up print whole: 52.43 MB and 52.42 MB both read "52 MB". + return `This vault holds ${size === most ? `just over ${most}` : size}, and backups on your plan hold up to ${most}. Remove large files, or contact support to raise the limit.` + } + case 'max_snapshot_items': + return `This vault has ${counted(current, 'file')}, and backups on your plan hold up to ${formatCount(allowed)}. Remove files you no longer need, or contact support to raise the limit.` + case 'max_snapshots': + return current === allowed + ? `This vault already keeps ${counted(current, 'backup')}, the most your plan allows. Delete an older backup to make room.` + : `This vault keeps ${counted(current, 'backup')}, more than the ${formatCount(allowed)} your plan allows. Delete ${counted(current - allowed + 1, 'older backup')} to make room.` + default: + return null + } +} + +/** + * What to throw when the service refuses a backup. Desktop errors reach the + * window as message text only, so a plan-limit refusal is reworded here while + * its details are still attached; any other failure is left as it is. + */ +export function cloudBackupCreateError(error: unknown): unknown { + const message = cloudBackupLimitMessage(error) + return message === null ? error : new Error(message, { cause: error }) +} + +function isCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +function counted(value: number, singular: string): string { + return `${formatCount(value)} ${value === 1 ? singular : `${singular}s`}` +} + +// The sentences are English and sizes print with a "." decimal point, so +// counts group the English way too, whatever locale the host runs in. +function formatCount(value: number): string { + return value.toLocaleString('en-US') +} + function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } diff --git a/packages/shared-domain/src/cloud-bytes.ts b/packages/shared-domain/src/cloud-bytes.ts new file mode 100644 index 00000000..abd57260 --- /dev/null +++ b/packages/shared-domain/src/cloud-bytes.ts @@ -0,0 +1,14 @@ +/** A Cloud size in decimal units (1 MB is 1,000,000 bytes), the units plans + * are sold in. */ +export function formatCloudBytes(bytes: number): string { + if (bytes < 1_000) return `${bytes} B` + const units = ['KB', 'MB', 'GB', 'TB'] + let value = bytes / 1_000 + let unit = units[0] + for (const candidate of units.slice(1)) { + if (value < 1_000) break + value /= 1_000 + unit = candidate + } + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}` +} diff --git a/packages/shared-domain/src/cloud-sync-coordinator.ts b/packages/shared-domain/src/cloud-sync-coordinator.ts index 1df912b0..e903e52b 100644 --- a/packages/shared-domain/src/cloud-sync-coordinator.ts +++ b/packages/shared-domain/src/cloud-sync-coordinator.ts @@ -94,6 +94,9 @@ export interface CloudSyncRepository { applyStagedCloudContent?(change: CloudSyncChange, previous: CloudSyncTrackedItem | undefined, file: CloudSyncStagedFile): Promise resolveStagedCloudConflict?(input: CloudSyncStagedConflict): Promise scan(): Promise + /** Finish filesystem work an earlier run was stopped in the middle of. Runs + * first in every sync run, before anything is pulled or scanned. */ + recoverInterruptedWork?(): Promise /** Paths with a durable user decision still pending. The coordinator leaves * both their tracked and local versions out of mutation planning until the * host removes the pending marker. */ @@ -444,6 +447,9 @@ export class CloudSyncCoordinator { } private async run(): Promise { + // An app stopped mid-rename leaves the note under a name scans skip; it + // goes back under its own name before this run looks at the vault. + await this.repository.recoverInterruptedWork?.() await this.ensureReferences() const bootstrap = await this.loadOrBootstrap() if (bootstrap.conflicts.length > 0) { @@ -1139,7 +1145,12 @@ export class CloudSyncCoordinator { throwIfCloudSyncCancelled(this.remote.downloadSignal) if (!change.content_ref || change.type !== 'upsert') return { change, conflict: await this.repository.apply(change, previous) } const ref = validateCloudSyncContentReference(change.content_ref, change) - if (await this.repository.matchesCloudContent?.(change.path, ref)) return { change, conflict: undefined } + // Matching bytes settle an upsert in place, but one that also moves the + // item (a Cloud restore out of Trash) leaves an old file behind that only + // the repository can vouch for and remove. A case-only rename keeps the + // shortcut: on a case-insensitive volume both spellings are one file. + const moving = previous !== undefined && cloudSyncPathKey(previous.path) !== cloudSyncPathKey(change.path) + if (!moving && await this.repository.matchesCloudContent?.(change.path, ref)) return { change, conflict: undefined } return this.withStaged(ref, async (file) => { const applied = file.preview ? { ...change, content: file.preview, content_ref: undefined } : change const conflict = file.preview ? await this.repository.apply(applied, previous) : diff --git a/packages/shared-domain/src/cloud-sync-host-service.test.ts b/packages/shared-domain/src/cloud-sync-host-service.test.ts index bc35683d..f58f2988 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.test.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { createHash } from 'node:crypto' import type { + CloudBackupSchedule, + CloudBackupScheduleResponse, CloudBackupSnapshotItemCollection, CloudSyncContent, CloudSyncManifestResponse, @@ -120,7 +122,7 @@ function setup(vaults: CloudSyncVault[] = [], localItems: CloudSyncLocalItem[] = cursor: body.mutations.length })), listBackups: vi.fn(async () => ({ data: [] })), - backupSchedule: vi.fn(async () => ({ + backupSchedule: vi.fn(async (): Promise => ({ data: { enabled: false, frequency: 'daily' as const, @@ -458,6 +460,33 @@ describe('CloudSyncHostService', () => { await expect(service.sync(hostVault)).rejects.toThrow('different ZenNotes Cloud account') }) + it('hands on why the last automatic backup did not happen, as the service sent it', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 0, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { client, hostVault, service } = setup([remoteVault]) + await service.link(hostVault, remoteVault.id) + const schedule: CloudBackupSchedule = { + enabled: true, + frequency: 'daily', + next_backup_at: '2026-10-05T03:00:00.000Z', + last_backup_at: '2026-10-02T03:00:00.000Z', + last_failure: { + code: 'BACKUP_QUOTA_EXCEEDED', + message: 'The latest automatic backup was skipped because this vault reached a backup limit.', + details: { limit: 'max_snapshots', current: 30, allowed: 30 }, + occurred_at: '2026-10-04T03:00:00.000Z' + } + } + client.backupSchedule.mockResolvedValueOnce({ data: schedule }) + + await expect(service.backupSchedule(hostVault)).resolves.toEqual(schedule) + }) + it('creates, lists, schedules, deletes, and restores backups for the linked vault', async () => { const remoteVault: CloudSyncVault = { id: 'vault-1', @@ -580,6 +609,34 @@ describe('CloudSyncHostService', () => { await expect(service.createBackup(hostVault)).rejects.toThrow('Resolve sync conflicts') expect(client.createBackup).not.toHaveBeenCalled() }) + + it('says which plan limit a refused backup ran into, and passes other failures through', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 0, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { client, hostVault, service } = setup([remoteVault]) + await service.link(hostVault, remoteVault.id) + const outage = Object.assign(new Error('ZenNotes Cloud request failed (503).'), { + name: 'CloudServiceRequestError', status: 503, code: null, details: null + }) + client.createBackup + .mockRejectedValueOnce(Object.assign(new Error('This backup would exceed your plan limits.'), { + name: 'CloudServiceRequestError', + status: 409, + code: 'BACKUP_QUOTA_EXCEEDED', + details: { limit: 'max_snapshot_bytes', current: 54_200_000, allowed: 52_428_800 } + })) + .mockRejectedValueOnce(outage) + + await expect(service.createBackup(hostVault, 'Before travel')).rejects.toThrow( + 'This vault holds 54 MB, and backups on your plan hold up to 52 MB. Remove large files, or contact support to raise the limit.' + ) + await expect(service.createBackup(hostVault)).rejects.toBe(outage) + }) }) diff --git a/packages/shared-domain/src/cloud-sync-host-service.ts b/packages/shared-domain/src/cloud-sync-host-service.ts index 31dc2c39..4a49af89 100644 --- a/packages/shared-domain/src/cloud-sync-host-service.ts +++ b/packages/shared-domain/src/cloud-sync-host-service.ts @@ -18,7 +18,7 @@ import type { CloudVaultLink } from '@zennotes/bridge-contract/cloud-sync' import type { CloudSyncApiClient } from './cloud-sync-api' -import { cloudBackupItemsPage, restoreCloudBackup } from './cloud-backup' +import { cloudBackupCreateError, cloudBackupItemsPage, restoreCloudBackup } from './cloud-backup' import { normalizeCloudSyncPath } from './cloud-sync' import { CLOUD_VAULT_REMOVED_MESSAGE, confirmCloudVaultMissing, isCloudResourceMissing, sameCloudVaultLink } from './cloud-vault-availability' import { @@ -186,7 +186,11 @@ export class CloudSyncHostService { const summary = await this.sync(vault) assertBackupReady(summary) const { client, link } = await this.linkedConnection(vault) - return (await client.createBackup(link.vault_id, label)).data + try { + return (await client.createBackup(link.vault_id, label)).data + } catch (error) { + throw cloudBackupCreateError(error) + } } async deleteBackup(vault: CloudSyncHostVault, backupId: string): Promise { diff --git a/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts b/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts index 3c8e1833..4d7ef0e9 100644 --- a/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts +++ b/packages/shared-domain/src/cloud-sync-portable-filesystem.test.ts @@ -71,6 +71,46 @@ class MemoryFileSystem implements PortableCloudSyncFileSystem { } } +/** + * A case-insensitive, case-preserving volume (an external drive, the iOS + * simulator on a Mac): every spelling of a name reaches the one stored file. + * Its rename overwrites the way Capacitor's does, removing an existing + * destination before the move, so a direct case-only rename destroys its own + * source. + */ +class CaseInsensitiveMemoryFileSystem extends MemoryFileSystem { + private stored(path: string): string { + const key = path.toLowerCase() + return [...this.files.keys()].find((candidate) => candidate.toLowerCase() === key) ?? path + } + + override async stat(path: string): Promise<'file' | 'directory' | null> { + return super.stat(this.stored(path)) + } + + override async readBase64(path: string): Promise { + return super.readBase64(this.stored(path)) + } + + override async writeText(path: string, value: string): Promise { + await super.writeText(this.stored(path), value) + } + + override async writeBase64(path: string, value: string): Promise { + await super.writeBase64(this.stored(path), value) + } + + override async deleteFile(path: string): Promise { + await super.deleteFile(this.stored(path)) + } + + override async rename(from: string, to: string): Promise { + const source = this.stored(from) + this.files.delete(this.stored(to)) + await super.rename(source, to) + } +} + class FailingMemoryFileSystem extends MemoryFileSystem { constructor( initial: Record, @@ -527,6 +567,303 @@ describe('PortableCloudSyncRepository', () => { }) }) +// On a case-insensitive volume both spellings of a case-only rename reach one +// file. Treating them as two removed the note just written, or refused the +// write as a conflict with itself. +describe('PortableCloudSyncRepository: an upsert that only changes the case of its path', () => { + it.each(['agreed', 'remote edit'])('keeps the one file, holding %j, under its new spelling', async (incoming) => { + const fs = new CaseInsensitiveMemoryFileSystem({ 'note.md': 'agreed' }) + + const conflict = await new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', incoming), + await tracked('note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect([...fs.files.keys()]).toEqual(['Note.md']) + expect(fs.text('Note.md')).toBe(incoming) + }) + + // How a replay finds the volume when the new bytes landed but the new + // spelling did not. + it('finishes the rename on replay once the new bytes are already in place', async () => { + const fs = new CaseInsensitiveMemoryFileSystem({ 'note.md': 'remote edit' }) + + const conflict = await new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', 'remote edit'), + await tracked('note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect([...fs.files.keys()]).toEqual(['Note.md']) + expect(fs.text('Note.md')).toBe('remote edit') + }) + + it('keeps a local edit made under the old spelling', async () => { + const fs = new CaseInsensitiveMemoryFileSystem({ 'note.md': 'local edit' }) + + const conflict = await new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', 'remote edit'), + await tracked('note.md', 'agreed') + ) + + expect(conflict).toMatchObject({ code: 'LOCAL_EDIT_CONFLICT', path: 'note.md' }) + expect([...fs.files.keys()]).toEqual(['note.md']) + expect(fs.text('note.md')).toBe('local edit') + }) + + // A case-sensitive volume (the iOS app container, Android app storage) holds + // two files, and leaving the old one behind would stop every later scan on + // a portable path collision. + it('removes the old spelling on a case-sensitive volume, where it is a separate file', async () => { + const fs = new MemoryFileSystem({ 'note.md': 'agreed' }) + + const conflict = await new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', 'agreed'), + await tracked('note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect([...fs.files.keys()]).toEqual(['Note.md']) + expect(fs.text('Note.md')).toBe('agreed') + }) + + it('never writes over a separate file whose name differs only in case', async () => { + const fs = new MemoryFileSystem({ 'note.md': 'agreed', 'Note.md': 'another note' }) + + const conflict = await new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', 'remote edit'), + await tracked('note.md', 'agreed') + ) + + expect(conflict).toMatchObject({ code: 'LOCAL_EDIT_CONFLICT', path: 'Note.md' }) + expect(fs.text('note.md')).toBe('agreed') + expect(fs.text('Note.md')).toBe('another note') + }) + + it('still removes the old file when an upsert moves the item to a different path', async () => { + const fs = new CaseInsensitiveMemoryFileSystem({ 'trash/Note.md': 'agreed' }) + + const conflict = await new PortableCloudSyncRepository(fs).apply( + await upsertFrom('trash/Note.md', 'Note.md', 'agreed'), + await tracked('trash/Note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect([...fs.files.keys()]).toEqual(['Note.md']) + expect(fs.text('Note.md')).toBe('agreed') + }) +}) + +// The case-only rename above takes two steps on a case-insensitive volume. The +// app can stop between them, leaving the note under a name sync ignores, and a +// save can land between them, which the second step used to rename over. +describe('PortableCloudSyncRepository: an interrupted case-only rename', () => { + const RECORDS = '.zennotes/zennotes-cloud-sync/respellings' + + class InterruptibleFileSystem extends CaseInsensitiveMemoryFileSystem { + stopAtSecondStep = false + afterFirstStep?: () => Promise + + override async rename(from: string, to: string): Promise { + if (from.endsWith('.tmp') && this.stopAtSecondStep) throw new Error('the app stopped') + await super.rename(from, to) + if (to.endsWith('.tmp')) await this.afterFirstStep?.() + } + + names(): string[] { + return [...this.files.keys()].sort() + } + + detour(): string { + const detour = this.names().find((name) => name.endsWith('.tmp')) + if (!detour) throw new Error('no detour on the volume') + return detour + } + } + + async function stopBetweenSteps(fs: InterruptibleFileSystem, incoming: string): Promise { + fs.stopAtSecondStep = true + await expect( + new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', incoming), + await tracked('note.md', 'agreed') + ) + ).rejects.toThrow('the app stopped') + fs.stopAtSecondStep = false + return fs.detour() + } + + it('finishes the rename on the next run, from the record written before the first step', async () => { + const fs = new InterruptibleFileSystem({ 'note.md': 'agreed' }) + const detour = await stopBetweenSteps(fs, 'remote edit') + expect(fs.names()).toEqual([`${RECORDS}/${detour.slice('Note.md.'.length, -'.tmp'.length)}.json`, detour].sort()) + + await new PortableCloudSyncRepository(fs).recoverInterruptedWork() + + expect(fs.names()).toEqual(['Note.md']) + expect(fs.text('Note.md')).toBe('remote edit') + }) + + it('keeps both files when the name was taken before the next run', async () => { + const fs = new InterruptibleFileSystem({ 'note.md': 'agreed' }) + const detour = await stopBetweenSteps(fs, 'remote edit') + await fs.writeText('note.md', 'typed after relaunch') + + await new PortableCloudSyncRepository(fs).recoverInterruptedWork() + + expect(fs.names()).toEqual([detour, 'note.md']) + expect(fs.text('note.md')).toBe('typed after relaunch') + expect(fs.text(detour)).toBe('remote edit') + }) + + it('drops the parked copy once the same bytes are back under the name', async () => { + const fs = new InterruptibleFileSystem({ 'note.md': 'agreed' }) + await stopBetweenSteps(fs, 'remote edit') + // A replay that ran before recovery wrote the Cloud bytes there again. + await fs.writeText('Note.md', 'remote edit') + + await new PortableCloudSyncRepository(fs).recoverInterruptedWork() + + expect(fs.names()).toEqual(['Note.md']) + expect(fs.text('Note.md')).toBe('remote edit') + }) + + it('never renames over a save that landed between the two steps', async () => { + const fs = new InterruptibleFileSystem({ 'note.md': 'agreed' }) + // The editor still holds the note under its old spelling and saves. + fs.afterFirstStep = () => fs.writeText('note.md', 'typed between the steps') + + const conflict = await new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', 'remote edit'), + await tracked('note.md', 'agreed') + ) + + expect(conflict).toBeUndefined() + expect(fs.text('note.md')).toBe('typed between the steps') + expect(fs.text(fs.detour())).toBe('remote edit') + + await new PortableCloudSyncRepository(fs).recoverInterruptedWork() + expect(fs.names()).toEqual([fs.detour(), 'note.md']) + expect(fs.text('note.md')).toBe('typed between the steps') + }) + + it('clears a record it cannot trust without touching any file', async () => { + const id = '0f8fad5b-d9cb-469f-a165-70867728950e' + const fs = new InterruptibleFileSystem({ + 'note.md': 'agreed', + [`other.md.${id}.tmp`]: 'parked elsewhere', + [`${RECORDS}/torn.json`]: '{"path":', + [`${RECORDS}/${id}.json`]: JSON.stringify({ path: 'note.md', detour: `other.md.${id}.tmp` }) + }) + + await new PortableCloudSyncRepository(fs).recoverInterruptedWork() + + expect(fs.names()).toEqual(['note.md', `other.md.${id}.tmp`]) + expect(fs.text('note.md')).toBe('agreed') + }) + + it('never fails a run over a record it cannot settle yet, and keeps it for the next run', async () => { + class UnreadyFileSystem extends InterruptibleFileSystem { + ready = false + override async readBase64(path: string): Promise { + if (!this.ready && path.endsWith('.tmp')) throw new Error('native storage is not ready') + return super.readBase64(path) + } + } + const fs = new UnreadyFileSystem({ 'note.md': 'agreed' }) + fs.stopAtSecondStep = true + await expect( + new PortableCloudSyncRepository(fs).apply( + await upsertFrom('note.md', 'Note.md', 'remote edit'), + await tracked('note.md', 'agreed') + ) + ).rejects.toThrow('the app stopped') + fs.stopAtSecondStep = false + await fs.writeText('Note.md', 'remote edit') + const parked = [...fs.files.keys()] + + await expect(new PortableCloudSyncRepository(fs).recoverInterruptedWork()).resolves.toBeUndefined() + expect([...fs.files.keys()]).toEqual(parked) + + fs.ready = true + await new PortableCloudSyncRepository(fs).recoverInterruptedWork() + expect([...fs.files.keys()]).toEqual(['Note.md']) + }) + + it('finishes an interrupted rename before the next sync run pulls or scans', async () => { + const statePath = 'zennotes-cloud-sync/state.json' + const fs = new InterruptibleFileSystem({ 'note.md': 'agreed' }) + const changes: CloudSyncChange[] = [] + const mutations: string[] = [] + const remote: CloudSyncRemote = { + manifest: async () => ({ data: [], cursor: 0, next_page: null }), + changes: async (_vault, after) => ({ data: changes.filter((change) => change.sequence > after), cursor: changes.length, has_more: false }), + mutate: async (_vault, body) => ({ + acknowledged: body.mutations.map((mutation) => { + mutations.push(`${mutation.type} ${'path' in mutation ? mutation.path : ''}`) + const sequence = changes.length + 1 + const revision = (mutation.base_revision ?? 0) + 1 + if (mutation.type !== 'upsert') throw new Error(`Unexpected ${mutation.type}`) + changes.push({ sequence, revision, type: 'upsert', item_id: mutation.item_id, + path: mutation.path, previous_path: null, content: mutation.content }) + return { sequence, revision, item_id: mutation.item_id, operation_id: mutation.operation_id } + }), + cursor: changes.length, + conflicts: [] + }) + } + let id = 0 + const sync = () => new CloudSyncCoordinator('vault-1', remote, new PortableCloudSyncRepository(fs), { + load: async () => (fs.text(statePath) ? JSON.parse(fs.text(statePath)!) as CloudSyncState : null), + save: async (state) => { + if (fs.stopAtSecondStep) throw new Error('the app stopped') + await fs.writeText(statePath, JSON.stringify(state)) + } + }, { itemId: () => `item-${++id}`, operationId: () => `operation-${++id}` }).sync() + + await sync() + const [created] = changes + const edit = await textContent('remote edit') + changes.push({ sequence: 2, revision: 2, type: 'upsert', item_id: created.item_id, + path: 'Note.md', previous_path: 'note.md', content: edit }) + + fs.stopAtSecondStep = true + await expect(sync()).rejects.toThrow('the app stopped') + fs.stopAtSecondStep = false + await sync() + + expect(fs.names().filter((name) => !name.startsWith('zennotes-cloud-sync/'))).toEqual(['Note.md']) + expect(fs.text('Note.md')).toBe('remote edit') + expect(mutations).toEqual(['upsert note.md']) + }) +}) + +async function tracked(path: string, data: string) { + const content = await textContent(data) + return { + item_id: 'item-1', + path, + kind: 'text' as const, + revision: 1, + sha256: content.sha256, + byte_length: content.byte_length, + media_type: content.media_type + } +} + +async function upsertFrom(previousPath: string, path: string, data: string): Promise { + return { + sequence: 2, + item_id: 'item-1', + type: 'upsert', + path, + previous_path: previousPath, + revision: 2, + content: await textContent(data) + } +} + async function textContent(data: string) { const bytes = new TextEncoder().encode(data) const digest = await crypto.subtle.digest('SHA-256', bytes) diff --git a/packages/shared-domain/src/cloud-sync-portable-filesystem.ts b/packages/shared-domain/src/cloud-sync-portable-filesystem.ts index 1828ad94..fba04019 100644 --- a/packages/shared-domain/src/cloud-sync-portable-filesystem.ts +++ b/packages/shared-domain/src/cloud-sync-portable-filesystem.ts @@ -61,6 +61,12 @@ const MEDIA_TYPES: Record = { '.yml': 'application/yaml' } +/** Where matchCase notes a respelling in progress. `zennotes-cloud-sync` is a + * local-only folder name, so scans never list it and it never syncs. */ +const RESPELLING_RECORDS = '.zennotes/zennotes-cloud-sync/respellings' +const RESPELLING_RECORD_NAME = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/i + export interface CloudSyncFileEntry { name: string type: 'file' | 'directory' @@ -189,8 +195,12 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { ? [await this.readItemOrNull(previousPath), null] : await Promise.all([this.readItemOrNull(previousPath), this.readItemOrNull(nextPath)]) const currentAtTarget = previousPath === nextPath ? source : destination + const oneFile = source !== null && destination !== null && (await this.sameFile(previousPath, nextPath)) if (currentAtTarget?.content.sha256 === change.content.sha256) { + // Only the spelling is left to change: the file already holds the Cloud + // bytes, so there is nothing of this device's to vouch for or lose. + if (oneFile) return await this.matchCase(nextPath) if (previousPath !== nextPath && source) { if (!vouchedFor(source, previous)) return localConflict(previousPath, source) await this.fs.deleteFile(previousPath) @@ -203,13 +213,14 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { return await this.keepBoth(nextPath, change.content) return localConflict(previousPath, source) } - if (destination) { + if (destination && !oneFile) { if (isCloudSyncVaultSettingsPath(nextPath)) return await this.keepBoth(nextPath, change.content) return localConflict(nextPath, destination) } await this.write(nextPath, change.content) - if (previousPath !== nextPath && source) await this.fs.deleteFile(previousPath) + if (oneFile) await this.matchCase(nextPath) + else if (previousPath !== nextPath && source) await this.fs.deleteFile(previousPath) } async resolveBootstrapConflict(input: { @@ -343,6 +354,131 @@ export class PortableCloudSyncRepository implements CloudSyncRepository { } } + /** + * Whether two spellings of one path key reach the same file. A + * case-insensitive volume (Android's shared storage, an external drive, the + * simulator on a Mac) answers both with one file, and deleting the old + * spelling there deleted the only copy. A case-sensitive volume can hold + * both as separate files, so finding a file under each spelling proves + * nothing. This layer cannot compare inodes; it asks the directory where the + * spellings part instead, since a listing names one file once, under its + * stored name. + */ + private async sameFile(left: string, right: string): Promise { + if (cloudSyncPathKey(left) !== cloudSyncPathKey(right)) return false + const leftSegments = left.split('/') + const rightSegments = right.split('/') + const index = leftSegments.findIndex((segment, position) => segment !== rightSegments[position]) + if (index < 0) return true + const names = new Set( + (await this.fs.readdir(leftSegments.slice(0, index).join('/'))).map((entry) => + entry.name.normalize('NFC') + ) + ) + return !(names.has(leftSegments[index]!) && names.has(rightSegments[index]!)) + } + + /** + * Give the one file of a case-only rename its new spelling. The volume + * keeps whatever case the file already had, and the next scan would report + * that and push the rename back. A native rename that overwrites removes an + * existing destination before moving onto it, and here that destination is + * the file itself, so the rename detours through a name nothing else holds + * and that sync ignores. A record written before the first step lets the + * next run finish the rename if the app stops between the two + * (recoverInterruptedWork); without it the note sat under the detour name + * for good, out of sight of the app and of sync. + */ + private async matchCase(path: string): Promise { + const slash = path.lastIndexOf('/') + const name = path.slice(slash + 1) + const entries = await this.fs.readdir(slash < 0 ? '' : path.slice(0, slash)) + if (entries.some((entry) => entry.name.normalize('NFC') === name)) return + const id = crypto.randomUUID() + const detour = `${path}.${id}.tmp` + const record = `${RESPELLING_RECORDS}/${id}.json` + await this.fs.writeText(record, JSON.stringify({ path, detour })) + try { + await this.fs.rename(path, detour) + } catch (error) { + await this.fs.deleteFile(record).catch(() => undefined) + throw error + } + // A save that landed under the old spelling since the first step is the + // note's newest version, and the second step would remove it as an + // existing destination. It stays where it is, and the record is left for + // the next run to settle the parked copy against it. + if ((await this.fs.stat(path)) !== null) return + await this.fs.rename(detour, path) + await this.fs.deleteFile(record).catch(() => undefined) + } + + /** + * Finish the case-only renames (matchCase) an earlier run was stopped in + * the middle of. The coordinator calls this before a run pulls or scans. + * A parked note whose name is free goes back under it; one whose name holds + * the same bytes again (a replay wrote them back) is a duplicate and goes; + * anything else stays, since it may be the only copy of one side. A record + * that cannot be trusted is dropped without touching any file. + * + * Never fails a run. The change that was being applied when the app stopped + * was not saved as applied, so the run replays it and writes the note again + * on its own; this only spares that rewrite and the parked duplicate. A + * record that cannot be settled now is kept for the next run. + */ + async recoverInterruptedWork(): Promise { + let entries: CloudSyncFileEntry[] + try { + if ((await this.fs.stat(RESPELLING_RECORDS)) !== 'directory') return + entries = await this.fs.readdir(RESPELLING_RECORDS) + } catch { + return + } + for (const entry of entries) { + if (entry.type !== 'file') continue + const recordPath = `${RESPELLING_RECORDS}/${entry.name}` + try { + const respelling = await this.readRespelling(recordPath, entry.name) + if (respelling) await this.finishRespelling(respelling) + await this.fs.deleteFile(recordPath) + } catch { + // Kept for the next run. + } + } + } + + private async readRespelling( + recordPath: string, + fileName: string + ): Promise<{ path: string; detour: string } | null> { + const id = RESPELLING_RECORD_NAME.exec(fileName)?.[1] + if (!id) return null + try { + const value: unknown = JSON.parse( + new TextDecoder().decode(base64ToBytes(await this.fs.readBase64(recordPath))) + ) + if (!value || typeof value !== 'object') return null + const { path, detour } = value as Record + if (typeof path !== 'string' || detour !== `${path}.${id}.tmp`) return null + if (!shouldSyncVaultPath(path) || this.path(path) !== path) return null + return { path, detour } + } catch { + return null + } + } + + private async finishRespelling({ path, detour }: { path: string; detour: string }): Promise { + if ((await this.fs.stat(detour)) !== 'file') return + const current = await this.fs.stat(path) + if (current === null) { + await this.fs.rename(detour, path) + return + } + if (current !== 'file') return + const [kept, parked] = await Promise.all([this.readItem(path), this.readItem(detour)]) + if (kept.content.sha256 === parked.content.sha256) await this.fs.deleteFile(detour) + } + /** Park the incoming version beside the local file rather than over it. */ private async keepBoth( relPath: string, diff --git a/packages/shared-domain/src/cloud-sync-references.test.ts b/packages/shared-domain/src/cloud-sync-references.test.ts index 8310894b..210d46da 100644 --- a/packages/shared-domain/src/cloud-sync-references.test.ts +++ b/packages/shared-domain/src/cloud-sync-references.test.ts @@ -331,6 +331,46 @@ describe('reference-mode sync', () => { expect(f.repository.stageCloudContent).not.toHaveBeenCalled() }) + // A Cloud restore moves an item with an upsert. Matching bytes at the new + // path (a replay after the write landed but the old file survived) do not + // settle it: the old file still has to go, and only the repository can + // vouch for it. + it('hands a moving upsert to the repository even when the new path already matches', async () => { + const restored = entry('large', 2) + const { content: _bytes, content_ref: _ref, ...metadata } = restored + const state = emptyCloudSyncState('vault') + state.items.large = { ...metadata, revision: 1, path: 'trash/large.md' } + const f = fixture([restored], state, [{ ...local(restored), path: 'trash/large.md' }, local(restored)]) + f.feed.push({ ...change(restored, 1), previous_path: 'trash/large.md' }) + f.repository.matchesCloudContent = async (path, ref) => + f.files.get(path)?.content.sha256 === ref.sha256 + + await f.coordinator.sync() + + expect(f.repository.applyStagedCloudContent).toHaveBeenCalledWith( + expect.objectContaining({ path: 'large.md', previous_path: 'trash/large.md' }), + expect.objectContaining({ path: 'trash/large.md' }), + expect.anything() + ) + }) + + it('keeps the shortcut for an upsert that only changes the case of its path', async () => { + const renamed = entry('large', 2) + const { content: _bytes, content_ref: _ref, ...metadata } = renamed + const state = emptyCloudSyncState('vault') + state.items.large = { ...metadata, revision: 1, path: 'LARGE.md' } + // A case-insensitive volume answers both spellings with this one file. + const f = fixture([renamed], state, [local(renamed)]) + f.feed.push({ ...change(renamed, 1), previous_path: 'LARGE.md' }) + f.repository.matchesCloudContent = async (path, ref) => + f.files.get(path)?.content.sha256 === ref.sha256 + + await f.coordinator.sync() + + expect(f.remote.download).not.toHaveBeenCalled() + expect(f.repository.applyStagedCloudContent).not.toHaveBeenCalled() + }) + it('coalesces obsolete upserts across metadata pages before hydrating the needed revision', async () => { const first = entry('large', 1) const second = entry('large', 2) diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index be1c2697..c24de87b 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.61.0", + "version": "2.62.0", "type": "module", "exports": { ".": "./src/index.ts"