diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1f19e735..b0661e43 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.41.0", + "version": "2.42.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1b25fdff..9db6ac09 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -236,6 +236,11 @@ import { installAppUpdate, scheduleBackgroundAppUpdateCheck, } from "./updater"; +import { + createInstalledBundleGuard, + replacedBundleDialog, + staleBundlePageHtml, +} from "./installed-bundle"; import type { McpClientId, McpInstructionsPayload } from "@shared/mcp-clients"; import { instructionsFilePath, @@ -256,6 +261,7 @@ import { candidatePathsFromArgv, resolveMarkdownOpenTarget, } from "./file-open"; +import { isStandaloneLink, resolveStandaloneLink } from "./standalone-links"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const nodeRequire = createRequire(import.meta.url); @@ -289,6 +295,16 @@ protocol.registerSchemesAsPrivileged([ { scheme: TYPST_ASSET_SCHEME, privileges: PRIVILEGED_ASSET_PRIVILEGES }, ]); +// The archive this process booted from, watched for a package manager +// replacing it while ZenNotes runs. Captured here, before any window loads, +// because what it defends is the header Electron cached at startup (see +// installed-bundle.ts). Unpackaged runs get an inert guard. +const installedBundle = createInstalledBundleGuard( + app.isPackaged && app.getAppPath().endsWith(".asar") + ? app.getAppPath() + : null, +); + let mainWindow: BrowserWindow | null = null; let mainWindowReadyForAppEvents = false; let creatingMainWindow: Promise | null = null; @@ -925,15 +941,7 @@ function openExternalFileWindow(absPath: string): void { installFrameEscape(win); applyZoomFactor(win, currentZoomFactor); - const params = `?externalFile=${encodeURIComponent(resolved)}`; - const devServerUrl = process.env["ELECTRON_RENDERER_URL"]; - if (devServerUrl) { - void win.loadURL(`${devServerUrl}${params}`); - } else { - void win.loadFile(path.join(__dirname, "../renderer/index.html"), { - search: params.slice(1), - }); - } + void loadRenderer(win, `externalFile=${encodeURIComponent(resolved)}`); } function decodeLocalAssetRequestPath(url: string): string | null { @@ -1028,6 +1036,155 @@ function installNavigationGuards(win: BrowserWindow): void { }); } +/** + * Every window is the same renderer entry with a query string saying what to + * be, and this is the one place it is loaded from. That makes it the place a + * load through a stale archive header is turned away: a packaged ZenNotes + * whose app.asar was replaced on disk (see installed-bundle.ts) would + * otherwise open a window on whatever bytes now sit at the old offsets, which + * is the 2.41.0 "white screen" on Arch. Resolves false when the load was + * refused; the window shows a plain explanation instead and the user is asked + * to restart. + */ +async function loadRenderer( + win: BrowserWindow, + query?: string, +): Promise { + const devServerUrl = process.env["ELECTRON_RENDERER_URL"]; + if (devServerUrl) { + await win.loadURL(query ? `${devServerUrl}?${query}` : devServerUrl); + return true; + } + if (installedBundle.status() === "replaced") { + console.warn( + "[install] app.asar was replaced on disk; refusing to load the renderer through this process's stale header", + ); + const page = staleBundlePageHtml( + app.getVersion(), + installedBundle.installedVersion(), + ); + await win.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(page)}`, + ); + if (win.isVisible()) void promptRestartForReplacedBundle(win); + else win.once("show", () => void promptRestartForReplacedBundle(win)); + return false; + } + await win.loadFile( + path.join(__dirname, "../renderer/index.html"), + query ? { search: query } : undefined, + ); + return true; +} + +let replacedBundlePrompt: Promise | null = null; +let replacedBundlePromptDeclined = false; +let replacedBundleRestartChosen = false; + +/** + * Asks to restart into the archive that replaced ours, as a sheet on a window + * the user can see. One dialog at a time, and once declined the passive + * checks (focus, the poll) stay quiet; only an action that would actually + * load through the stale header asks again. + * + * Never an app-level alert. With no parent, macOS runs the alert as a nested + * modal session, and anything that ends that session from outside comes back + * as NSModalResponseCancel, which is 0, which is the Restart button: observed + * relaunching the app with nobody at the keyboard. A sheet reports an outside + * dismissal as the cancel button instead. With no visible window there is + * nobody to ask anyway; the next window to open carries the same explanation + * on its page and asks then. + */ +function promptRestartForReplacedBundle( + parent: BrowserWindow | null | undefined, + options: { passive?: boolean } = {}, +): Promise { + // Quitting closes windows and moves focus around, which would otherwise + // raise the question again on the way out. + if (replacedBundleRestartChosen) return Promise.resolve(); + if (options.passive && replacedBundlePromptDeclined) return Promise.resolve(); + if (replacedBundlePrompt) return replacedBundlePrompt; + const owner = visibleWindowFor(parent); + if (!owner) return Promise.resolve(); + const copy = replacedBundleDialog( + app.getVersion(), + installedBundle.installedVersion(), + ); + const dialogOptions: Electron.MessageBoxOptions = { + type: "info", + buttons: copy.buttons, + defaultId: 0, + cancelId: 1, + title: copy.title, + message: copy.message, + detail: copy.detail, + }; + console.warn(`[install] ${copy.message} Asking to restart.`); + replacedBundlePrompt = dialog + .showMessageBox(owner, dialogOptions) + .then(({ response }) => { + if (response === 0) { + replacedBundleRestartChosen = true; + console.log("[install] restarting into the replaced app.asar"); + app.relaunch(); + app.quit(); + return; + } + replacedBundlePromptDeclined = true; + }) + .finally(() => { + replacedBundlePrompt = null; + }); + return replacedBundlePrompt; +} + +/** The preferred window when it can carry a sheet, else any window that can. */ +function visibleWindowFor( + preferred: BrowserWindow | null | undefined, +): BrowserWindow | null { + if (preferred && !preferred.isDestroyed() && preferred.isVisible()) { + return preferred; + } + return ( + BrowserWindow.getAllWindows().find( + (win) => !win.isDestroyed() && win.isVisible(), + ) ?? null + ); +} + +/** View > Reload. The stock roles reload straight through a stale archive + * header (see loadRenderer), so the same question is asked first. */ +function reloadFocusedWindow(ignoringCache: boolean): void { + const win = BrowserWindow.getFocusedWindow(); + if (!win) return; + if (installedBundle.status() === "replaced") { + void promptRestartForReplacedBundle(win); + return; + } + if (ignoringCache) win.webContents.reloadIgnoringCache(); + else win.reload(); +} + +const INSTALLED_BUNDLE_POLL_MS = 30_000; + +/** + * Notices the replacement soon after it happens rather than at the next + * window: on focus, which is the user coming back from the terminal that ran + * the upgrade, and on a slow poll for a process left with no window to focus. + */ +function watchInstalledBundle(): void { + if (!app.isPackaged) return; + const check = () => { + if (installedBundle.status() !== "replaced") return; + void promptRestartForReplacedBundle( + BrowserWindow.getFocusedWindow() ?? mainWindow, + { passive: true }, + ); + }; + app.on("browser-window-focus", check); + setInterval(check, INSTALLED_BUNDLE_POLL_MS).unref(); +} + function mimeTypeForPath(absPath: string): string { const ext = path.extname(absPath).toLowerCase(); switch (ext) { @@ -1444,12 +1601,7 @@ async function createWindow( } } - const devServerUrl = process.env["ELECTRON_RENDERER_URL"]; - if (devServerUrl) { - void win.loadURL(devServerUrl); - } else { - void win.loadFile(path.join(__dirname, "../renderer/index.html")); - } + void loadRenderer(win); return win; } @@ -2201,16 +2353,13 @@ async function exportNotePdf( } installNavigationGuards(exportWindow); applyZoomFactor(exportWindow, currentZoomFactor); - const params = `?exportNote=${encodeURIComponent(relPath)}`; - const devServerUrl = process.env["ELECTRON_RENDERER_URL"]; - if (devServerUrl) { - await exportWindow.loadURL(`${devServerUrl}${params}`); - } else { - await exportWindow.loadFile( - path.join(__dirname, "../renderer/index.html"), - { - search: params.slice(1), - }, + const loaded = await loadRenderer( + exportWindow, + `exportNote=${encodeURIComponent(relPath)}`, + ); + if (!loaded) { + throw new Error( + "ZenNotes was updated on disk. Restart it to finish the update, then export again.", ); } @@ -4104,6 +4253,36 @@ function registerIpc(): void { }, ); + // A link followed inside a standalone window resolves against that + // window's own file, never against a path the renderer supplies (#626). + handle( + IPC.APP_FOLLOW_EXTERNAL_FILE_LINK, + async (event, link: unknown): Promise<{ ok: boolean; error?: string }> => { + const win = requireEventWindow(event); + const abs = externalFileWindows.get(win.id); + if (!abs || !isMarkdownFilePath(abs)) { + throw new Error("No markdown file is bound to this window."); + } + if (!isStandaloneLink(link)) return { ok: false, error: "Not a link." }; + const target = await resolveStandaloneLink(abs, link); + if (!target) { + const named = link.kind === "wikilink" ? `[[${link.target}]]` : link.href; + return { + ok: false, + error: `Nothing named ${named} next to ${path.basename(abs)}.`, + }; + } + if (target.kind === "markdown") { + const opened = await openMarkdownFileFromOS(target.absPath, false); + return opened + ? { ok: true } + : { ok: false, error: `Could not open ${target.absPath}.` }; + } + const failure = await shell.openPath(target.absPath); + return failure ? { ok: false, error: failure } : { ok: true }; + }, + ); + handle( IPC.APP_MOVE_EXTERNAL_FILE_TO_VAULT, async (event): Promise => { @@ -4426,15 +4605,7 @@ function openFloatingNoteWindow(relPath: string): void { inheritWindowWorkspaceSession(sourceWindow, win); } - const params = `?floating=1¬e=${encodeURIComponent(relPath)}`; - const devServerUrl = process.env["ELECTRON_RENDERER_URL"]; - if (devServerUrl) { - void win.loadURL(`${devServerUrl}${params}`); - } else { - void win.loadFile(path.join(__dirname, "../renderer/index.html"), { - search: params.slice(1), - }); - } + void loadRenderer(win, `floating=1¬e=${encodeURIComponent(relPath)}`); } /** @@ -4532,15 +4703,7 @@ async function ensureQuickCaptureWindow(): Promise { }); } - const params = "?quickCapture=1"; - const devServerUrl = process.env["ELECTRON_RENDERER_URL"]; - if (devServerUrl) { - void win.loadURL(`${devServerUrl}${params}`); - } else { - void win.loadFile(path.join(__dirname, "../renderer/index.html"), { - search: params.slice(1), - }); - } + void loadRenderer(win, "quickCapture=1"); quickCaptureWindow = win; return win; @@ -4723,8 +4886,16 @@ function installAppMenu(): void { { label: "View", submenu: [ - { role: "reload" }, - { role: "forceReload" }, + { + label: "Reload", + accelerator: "CmdOrCtrl+R", + click: () => reloadFocusedWindow(false), + }, + { + label: "Force Reload", + accelerator: "CmdOrCtrl+Shift+R", + click: () => reloadFocusedWindow(true), + }, ...(app.isPackaged ? [] : ([ @@ -5173,6 +5344,7 @@ app.whenReady().then(async () => { installAppMenu(); registerIpc(); initAppUpdater(); + watchInstalledBundle(); registerAppDeepLinkProtocol(); const startupDeepLinkResult = handleStartupDeepLinks(process.argv); void flushPendingCloudAuthCallbacks(); diff --git a/apps/desktop/src/main/installed-bundle.test.ts b/apps/desktop/src/main/installed-bundle.test.ts new file mode 100644 index 00000000..ff9f879d --- /dev/null +++ b/apps/desktop/src/main/installed-bundle.test.ts @@ -0,0 +1,249 @@ +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + archiveReader, + createInstalledBundleGuard, + replacedBundleDialog, + sameArchiveIdentity, + staleBundlePageHtml, + type ArchiveIdentity, + type ArchiveReader +} from './installed-bundle' + +/** A minimal asar: the two Pickle records, then the files back to back. */ +function buildAsar(files: Record): Buffer { + const header: { files: Record } = { files: {} } + const chunks: Buffer[] = [] + let offset = 0 + for (const [name, content] of Object.entries(files)) { + const bytes = Buffer.from(content, 'utf8') + header.files[name] = { size: bytes.length, offset: String(offset) } + chunks.push(bytes) + offset += bytes.length + } + const json = Buffer.from(JSON.stringify(header), 'utf8') + const pad = (4 - (json.length % 4)) % 4 + const lead = Buffer.alloc(16) + lead.writeUInt32LE(4, 0) + lead.writeUInt32LE(8 + json.length + pad, 4) + lead.writeUInt32LE(4 + json.length + pad, 8) + lead.writeUInt32LE(json.length, 12) + return Buffer.concat([lead, json, Buffer.alloc(pad), ...chunks]) +} + +const pkg = (version: string) => JSON.stringify({ name: 'zennotes', version }) + +const tempDirs: string[] = [] +function tempArchive(name: string, bytes: Buffer): string { + const dir = mkdtempSync(join(tmpdir(), 'zen-installed-bundle-')) + tempDirs.push(dir) + const file = join(dir, name) + writeFileSync(file, bytes) + return file +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('archiveReader', () => { + it('reads the version out of package.json without going through the asar hooks', () => { + const file = tempArchive('app.asar', buildAsar({ 'package.json': pkg('2.41.0'), 'a.js': 'x' })) + expect(archiveReader.packageVersion(file)).toBe('2.41.0') + }) + + it('reads package.json at its offset, not at the start of the data', () => { + const file = tempArchive( + 'app.asar', + buildAsar({ 'out/main/index.js': 'console.log(1)', 'package.json': pkg('2.42.0') }) + ) + expect(archiveReader.packageVersion(file)).toBe('2.42.0') + }) + + it('answers null for a file that is not an archive, and for a missing one', () => { + const file = tempArchive('app.asar', Buffer.from('')) + expect(archiveReader.packageVersion(file)).toBeNull() + expect(archiveReader.packageVersion(join(tempDirs[0], 'gone.asar'))).toBeNull() + }) + + it('digests the header, so a moved file with the same layout matches and a new layout does not', () => { + const a = tempArchive('a.asar', buildAsar({ 'package.json': pkg('2.41.0'), 'x.js': 'aaaa' })) + const same = tempArchive('b.asar', buildAsar({ 'package.json': pkg('2.41.0'), 'x.js': 'bbbb' })) + const grown = tempArchive('c.asar', buildAsar({ 'package.json': pkg('2.41.0'), 'x.js': 'aaaaa' })) + expect(archiveReader.headerDigest(a)).toBe(archiveReader.headerDigest(same)) + expect(archiveReader.headerDigest(a)).not.toBe(archiveReader.headerDigest(grown)) + expect(() => archiveReader.headerDigest(tempArchive('d.asar', Buffer.alloc(3)))).toThrow() + }) + + it('leaves process.noAsar the way it found it', () => { + const proc = process as unknown as { noAsar?: boolean } + const file = tempArchive('app.asar', buildAsar({ 'package.json': pkg('1.0.0') })) + proc.noAsar = false + archiveReader.identity(file) + expect(proc.noAsar).toBe(false) + archiveReader.packageVersion(join(tempDirs[0], 'gone.asar')) + expect(proc.noAsar).toBe(false) + }) +}) + +describe('createInstalledBundleGuard (scripted reader)', () => { + const id = (ino: number, size = 100, mtimeMs = 1): ArchiveIdentity => ({ ino, size, mtimeMs }) + + function scripted(overrides: Partial = {}): ArchiveReader & { + identity: ReturnType + headerDigest: ReturnType + packageVersion: ReturnType + } { + return { + identity: vi.fn(() => id(1)), + headerDigest: vi.fn(() => 'boot'), + packageVersion: vi.fn(() => '2.41.0'), + ...overrides + } as never + } + + it('is inert without an archive path', () => { + const guard = createInstalledBundleGuard(null) + expect(guard.status()).toBe('unknown') + expect(guard.installedVersion()).toBeNull() + }) + + it('is inert when the archive cannot be captured at boot', () => { + const reader = scripted({ + identity: vi.fn(() => { + throw new Error('ENOENT') + }) + }) + expect(createInstalledBundleGuard('/opt/x/app.asar', reader).status()).toBe('unknown') + }) + + it('reports current while the file on disk is the one it booted from', () => { + const reader = scripted() + const guard = createInstalledBundleGuard('/opt/x/app.asar', reader) + expect(guard.status()).toBe('current') + expect(guard.status()).toBe('current') + // Only the boot capture ever read the header. + expect(reader.headerDigest).toHaveBeenCalledTimes(1) + }) + + it('reports replaced, sticky, once the header on disk is a different layout', () => { + const reader = scripted() + const guard = createInstalledBundleGuard('/opt/x/app.asar', reader) + reader.identity.mockReturnValue(id(2, 120, 9)) + reader.headerDigest.mockReturnValue('upgraded') + expect(guard.status()).toBe('replaced') + expect(guard.installedVersion()).toBe('2.41.0') + // Even if the original came back, this process's cached header is gone. + reader.identity.mockReturnValue(id(1)) + reader.headerDigest.mockReturnValue('boot') + expect(guard.status()).toBe('replaced') + expect(reader.packageVersion).toHaveBeenCalledTimes(1) + }) + + it('treats a byte-identical reinstall as current and stops re-reading the header', () => { + const reader = scripted() + const guard = createInstalledBundleGuard('/opt/x/app.asar', reader) + reader.identity.mockReturnValue(id(2, 100, 5)) + expect(guard.status()).toBe('current') + expect(guard.status()).toBe('current') + expect(reader.headerDigest).toHaveBeenCalledTimes(2) + expect(reader.packageVersion).not.toHaveBeenCalled() + }) + + it('answers unknown, not replaced, while the archive is unreadable mid-upgrade', () => { + const reader = scripted() + const guard = createInstalledBundleGuard('/opt/x/app.asar', reader) + reader.identity.mockImplementation(() => { + throw new Error('ENOENT') + }) + expect(guard.status()).toBe('unknown') + reader.identity.mockImplementation(() => id(3, 7, 7)) + reader.headerDigest.mockImplementation(() => { + throw new Error('short read') + }) + expect(guard.status()).toBe('unknown') + reader.headerDigest.mockImplementation(() => 'upgraded') + expect(guard.status()).toBe('replaced') + }) + + it('keeps the installed version at null when the new archive does not say', () => { + const reader = scripted({ packageVersion: vi.fn(() => null) }) + const guard = createInstalledBundleGuard('/opt/x/app.asar', reader) + reader.identity.mockReturnValue(id(2)) + reader.headerDigest.mockReturnValue('upgraded') + expect(guard.status()).toBe('replaced') + expect(guard.installedVersion()).toBeNull() + }) +}) + +describe('createInstalledBundleGuard (real files)', () => { + it('sees a package upgrade land under a running process', () => { + const file = tempArchive( + 'app.asar', + buildAsar({ 'package.json': pkg('2.40.0'), 'out/renderer/index.html': '' }) + ) + const guard = createInstalledBundleGuard(file) + expect(guard.status()).toBe('current') + + // pacman: unlink, then write the new archive at the same path. + const upgraded = tempArchive( + 'staged.asar', + buildAsar({ + 'package.json': pkg('2.41.0'), + 'out/renderer/assets/zh-TW.js': 'var u={}', + 'out/renderer/index.html': '' + }) + ) + rmSync(file) + copyFileSync(upgraded, file) + + expect(guard.status()).toBe('replaced') + expect(guard.installedVersion()).toBe('2.41.0') + }) + + it('stays quiet when the same archive is written back byte for byte', () => { + const bytes = buildAsar({ 'package.json': pkg('2.41.0'), 'out/renderer/index.html': '' }) + const file = tempArchive('app.asar', bytes) + const guard = createInstalledBundleGuard(file) + rmSync(file) + writeFileSync(file, bytes) + expect(guard.status()).toBe('current') + }) +}) + +describe('replacedBundleDialog / staleBundlePageHtml', () => { + it('names both versions when they differ', () => { + const copy = replacedBundleDialog('2.40.0', '2.41.0') + expect(copy.message).toBe( + 'ZenNotes 2.41.0 is installed, but this window is still running 2.40.0.' + ) + expect(copy.buttons[0]).toMatch(/restart/i) + }) + + it('does not claim an upgrade it cannot see', () => { + expect(replacedBundleDialog('2.41.0', null).message).toContain('replaced while 2.41.0 was running') + expect(replacedBundleDialog('2.41.0', '2.41.0').message).not.toContain('is installed, but') + }) + + it('writes without an em dash and escapes what it puts into the page', () => { + const copy = replacedBundleDialog('2.40.0', '2.41.0') + for (const text of [copy.title, copy.message, copy.detail]) expect(text).not.toContain('—') + const html = staleBundlePageHtml('2.40.0', '2.41.0') + expect(html).not.toContain('') + expect(html).toContain('<b>2.41.0</b>') + expect(html).toContain('Quit ZenNotes and open it again.') + }) +}) + +describe('sameArchiveIdentity', () => { + it('needs inode, size and mtime to all match', () => { + const a = { ino: 1, size: 2, mtimeMs: 3 } + expect(sameArchiveIdentity(a, { ...a })).toBe(true) + expect(sameArchiveIdentity(a, { ...a, ino: 9 })).toBe(false) + expect(sameArchiveIdentity(a, { ...a, size: 9 })).toBe(false) + expect(sameArchiveIdentity(a, { ...a, mtimeMs: 9 })).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/installed-bundle.ts b/apps/desktop/src/main/installed-bundle.ts new file mode 100644 index 00000000..b81ec91f --- /dev/null +++ b/apps/desktop/src/main/installed-bundle.ts @@ -0,0 +1,276 @@ +/** + * The archive a packaged ZenNotes booted from, and whether it is still the + * one on disk. + * + * Electron serves the renderer's `file://` loads out of `app.asar` through a + * header it parses once per process and keeps for the life of that process + * (shell/common/asar/asar_util.cc, GetOrCreateAsarArchive). Each load then + * opens the archive by path and reads at the cached offset + * (shell/browser/net/asar/asar_url_loader.cc). A package manager that + * upgrades ZenNotes while it is running (pacman, dpkg, rpm, a Homebrew cask) + * puts a new archive at the same path, and from then on every load in the old + * process reads the new file at the old offsets. Not a crash: the wrong bytes, + * served as the right file. The 2.41.0 "white screen" on Arch was the tail of + * one Excalidraw locale chunk and the head of the next rendered as HTML, which + * is exactly what the 2.40.0 header's `index.html` entry points at inside the + * 2.41.0 archive. + * + * Nothing in the process can be repaired once the header is stale, so this + * module does not try. It records what the archive looked like at boot and + * answers one question, whether that is still the archive Electron is reading, + * so that a load through a stale header is refused and the user is asked to + * restart instead. Installs that never rewrite the running path (an AppImage, + * whose mount pins the old file; a Nix store path; a Windows install locked + * while it runs) never see a change and are never asked. + * + * Every read here treats the archive as the plain file it is. Electron's Node + * fs hooks present an `.asar` path as a directory and would answer from the + * same stale header this module exists to detect, so they are stepped around + * with `process.noAsar` for the duration of each read. + */ + +import { createHash } from "node:crypto"; +import { closeSync, openSync, readSync, statSync } from "node:fs"; + +export interface ArchiveIdentity { + ino: number; + size: number; + mtimeMs: number; +} + +export type InstalledBundleStatus = "current" | "replaced" | "unknown"; + +export interface InstalledBundleGuard { + /** + * Whether the archive on disk is still the one this process booted from. + * `replaced` is sticky: a stale header does not recover. `unknown` covers an + * unpackaged run and the moments an upgrade leaves the archive unreadable, + * and never blocks anything. + */ + status(): InstalledBundleStatus; + /** Version stamped into the archive that replaced ours, when readable. */ + installedVersion(): string | null; +} + +/** The raw reads the guard needs, split out so tests can script a sequence. */ +export interface ArchiveReader { + identity(archivePath: string): ArchiveIdentity; + headerDigest(archivePath: string): string; + packageVersion(archivePath: string): string | null; +} + +export function sameArchiveIdentity( + a: ArchiveIdentity, + b: ArchiveIdentity, +): boolean { + return a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs; +} + +/** Runs `fn` with Electron's asar fs hooks off, so an `.asar` path is read as + * the file it is rather than as the archive's root directory. */ +export function withRawFs(fn: () => T): T { + const proc = process as unknown as { noAsar?: boolean }; + const previous = proc.noAsar; + proc.noAsar = true; + try { + return fn(); + } finally { + proc.noAsar = previous ?? false; + } +} + +interface ArchiveHeader { + json: Buffer; + /** Byte position every `offset` in the header JSON is relative to. */ + dataStart: number; +} + +interface AsarFileEntry { + size?: unknown; + offset?: unknown; + unpacked?: unknown; +} + +/** + * The archive starts with two Pickle records, the same shape @electron/asar + * writes and Electron's C++ reader parses: uint32 4, uint32 header pickle + * size, uint32 payload size, uint32 JSON length, the JSON, padding to a + * multiple of four. File data follows the header pickle. + */ +function readArchiveHeader(fd: number): ArchiveHeader { + const lead = Buffer.alloc(16); + if (readSync(fd, lead, 0, 16, 0) !== 16 || lead.readUInt32LE(0) !== 4) { + throw new Error("not an asar archive"); + } + const headerPickleSize = lead.readUInt32LE(4); + const jsonLength = lead.readUInt32LE(12); + if (jsonLength === 0 || jsonLength + 8 > headerPickleSize) { + throw new Error("asar header sizes disagree"); + } + const json = Buffer.alloc(jsonLength); + if (readSync(fd, json, 0, jsonLength, 16) !== jsonLength) { + throw new Error("truncated asar header"); + } + return { json, dataStart: 8 + headerPickleSize }; +} + +function openArchive(archivePath: string, fn: (fd: number) => T): T { + return withRawFs(() => { + const fd = openSync(archivePath, "r"); + try { + return fn(fd); + } finally { + closeSync(fd); + } + }); +} + +export const archiveReader: ArchiveReader = { + identity(archivePath) { + const stats = withRawFs(() => statSync(archivePath)); + return { ino: stats.ino, size: stats.size, mtimeMs: stats.mtimeMs }; + }, + headerDigest(archivePath) { + return openArchive(archivePath, (fd) => + createHash("sha256").update(readArchiveHeader(fd).json).digest("hex"), + ); + }, + packageVersion(archivePath) { + try { + return openArchive(archivePath, (fd) => { + const { json, dataStart } = readArchiveHeader(fd); + const header = JSON.parse(json.toString("utf8")) as { + files?: Record; + }; + const entry = header.files?.["package.json"]; + if ( + !entry || + entry.unpacked || + typeof entry.size !== "number" || + (typeof entry.offset !== "string" && typeof entry.offset !== "number") + ) { + return null; + } + const data = Buffer.alloc(entry.size); + const at = dataStart + Number(entry.offset); + if (readSync(fd, data, 0, entry.size, at) !== entry.size) return null; + const { version } = JSON.parse(data.toString("utf8")) as { + version?: unknown; + }; + return typeof version === "string" ? version : null; + }); + } catch { + return null; + } + }, +}; + +const INERT_GUARD: InstalledBundleGuard = { + status: () => "unknown", + installedVersion: () => null, +}; + +/** + * Captures the archive at construction, so build it as early in the process + * as possible: the header it defends is the one Electron cached at startup. + * A null path (an unpackaged run, or asar disabled) yields a guard that never + * reports anything. + */ +export function createInstalledBundleGuard( + archivePath: string | null, + reader: ArchiveReader = archiveReader, +): InstalledBundleGuard { + if (!archivePath) return INERT_GUARD; + let bootIdentity: ArchiveIdentity; + let bootDigest: string; + try { + bootIdentity = reader.identity(archivePath); + bootDigest = reader.headerDigest(archivePath); + } catch { + return INERT_GUARD; + } + let replaced = false; + let installedVersion: string | null = null; + return { + status() { + if (replaced) return "replaced"; + let now: ArchiveIdentity; + try { + now = reader.identity(archivePath); + } catch { + return "unknown"; + } + if (sameArchiveIdentity(now, bootIdentity)) return "current"; + // The file moved under us. A byte-identical reinstall keeps every offset + // valid, so compare the layout itself before calling the header stale. + let digest: string; + try { + digest = reader.headerDigest(archivePath); + } catch { + return "unknown"; + } + if (digest === bootDigest) { + bootIdentity = now; + return "current"; + } + replaced = true; + installedVersion = reader.packageVersion(archivePath); + return "replaced"; + }, + installedVersion: () => installedVersion, + }; +} + +export interface ReplacedBundleCopy { + title: string; + message: string; + detail: string; + buttons: [restart: string, later: string]; +} + +export function replacedBundleDialog( + runningVersion: string, + installedVersion: string | null, +): ReplacedBundleCopy { + const updated = + installedVersion !== null && installedVersion !== runningVersion; + return { + title: "ZenNotes Was Updated", + message: updated + ? `ZenNotes ${installedVersion} is installed, but this window is still running ${runningVersion}.` + : `The ZenNotes install on disk was replaced while ${runningVersion} was running.`, + detail: + "A running copy cannot load files from the version that replaced it, so new windows and reloads would open blank. Restart ZenNotes to finish the update. Notes are saved to disk as you type.", + buttons: ["Restart ZenNotes", "Not Now"], + }; +} + +const escapeHtml = (text: string): string => + text.replace(/[&<>"]/g, (c) => + c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """, + ); + +/** What a window shows in place of a renderer it must not load. */ +export function staleBundlePageHtml( + runningVersion: string, + installedVersion: string | null, +): string { + const copy = replacedBundleDialog(runningVersion, installedVersion); + return [ + "", + '', + `${escapeHtml(copy.title)}`, + "
", + `

${escapeHtml(copy.title)}

`, + `

${escapeHtml(copy.message)}

`, + `

${escapeHtml(copy.detail)}

`, + "

Quit ZenNotes and open it again.

", + "
", + ].join(""); +} diff --git a/apps/desktop/src/main/standalone-links.test.ts b/apps/desktop/src/main/standalone-links.test.ts new file mode 100644 index 00000000..e3f28f64 --- /dev/null +++ b/apps/desktop/src/main/standalone-links.test.ts @@ -0,0 +1,159 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + isStandaloneLink, + resolveStandaloneLink, + WIKILINK_SEARCH_DEPTH +} from './standalone-links' + +// The layout from #626: a generated wiki two levels below a repo root. +// proj/README.md +// proj/docs/wiki/index.md <- the standalone note +// proj/docs/wiki/topic-name.md +// proj/docs/wiki/t00-converter-ts.md +// proj/docs/wiki/graphify-out/GRAPH_REPORT.md +// proj/docs/wiki/graphify-out/deep/Nested.md +// proj/docs/wiki/diagram.png +let root = '' +let note = '' +const dirs: string[] = [] + +function file(rel: string, body = '# x\n'): string { + const abs = path.join(root, rel) + mkdirSync(path.dirname(abs), { recursive: true }) + writeFileSync(abs, body) + return abs +} + +function setup(): void { + root = mkdtempSync(path.join(tmpdir(), 'zen-standalone-links-')) + dirs.push(root) + file('proj/README.md') + note = file('proj/docs/wiki/index.md') + file('proj/docs/wiki/topic-name.md') + file('proj/docs/wiki/t00-converter-ts.md') + file('proj/docs/wiki/graphify-out/GRAPH_REPORT.md') + file('proj/docs/wiki/graphify-out/deep/Nested.md') + file('proj/docs/wiki/diagram.png', 'png') + file('proj/docs/wiki/node_modules/pkg/Hidden.md') + file('proj/docs/wiki/.cache/Shadow.md') +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +const resolve = (link: Parameters[1]) => + resolveStandaloneLink(note, link) + +describe('resolveStandaloneLink: relative hrefs', () => { + it('resolves ../ and ./ hrefs from the note directory', async () => { + setup() + expect(await resolve({ kind: 'href', href: '../../README.md' })).toEqual({ + kind: 'markdown', + absPath: path.join(root, 'proj/README.md') + }) + expect(await resolve({ kind: 'href', href: 'graphify-out/GRAPH_REPORT.md' })).toEqual({ + kind: 'markdown', + absPath: path.join(root, 'proj/docs/wiki/graphify-out/GRAPH_REPORT.md') + }) + expect(await resolve({ kind: 'href', href: './topic-name.md#section' })).toEqual({ + kind: 'markdown', + absPath: path.join(root, 'proj/docs/wiki/topic-name.md') + }) + }) + + it('adds the markdown extension for an extension-less link and decodes percent escapes', async () => { + setup() + file('proj/docs/wiki/My Page.md') + expect((await resolve({ kind: 'href', href: '../../README' }))?.absPath).toBe( + path.join(root, 'proj/README.md') + ) + expect((await resolve({ kind: 'href', href: 'My%20Page.md' }))?.absPath).toBe( + path.join(root, 'proj/docs/wiki/My Page.md') + ) + }) + + it('reports a non-markdown file as a plain file', async () => { + setup() + expect(await resolve({ kind: 'href', href: 'diagram.png' })).toEqual({ + kind: 'file', + absPath: path.join(root, 'proj/docs/wiki/diagram.png') + }) + }) + + it('accepts absolute paths and file URLs', async () => { + setup() + const readme = path.join(root, 'proj/README.md') + expect((await resolve({ kind: 'href', href: readme }))?.absPath).toBe(readme) + expect((await resolve({ kind: 'href', href: `file://${readme}` }))?.absPath).toBe(readme) + }) + + it('answers null for web, mail, app-scheme and in-page links, and for missing files', async () => { + setup() + for (const href of ['https://example.com/a.md', 'mailto:a@b.c', 'zen-asset://local/x.png', '#heading', '', 'nope.md', '../../missing']) + expect(await resolve({ kind: 'href', href })).toBeNull() + }) +}) + +describe('resolveStandaloneLink: wikilinks', () => { + it('finds a page by name next to the note, ignoring alias and anchors', async () => { + setup() + const expected = path.join(root, 'proj/docs/wiki/t00-converter-ts.md') + expect((await resolve({ kind: 'wikilink', target: 't00-converter-ts' }))?.absPath).toBe(expected) + expect((await resolve({ kind: 'wikilink', target: 't00-converter-ts|converter.ts' }))?.absPath).toBe(expected) + expect((await resolve({ kind: 'wikilink', target: 'T00-Converter-TS#Usage' }))?.absPath).toBe(expected) + expect((await resolve({ kind: 'wikilink', target: 'topic-name^block' }))?.kind).toBe('markdown') + }) + + it('looks below the note directory, shallowest match first, skipping dot and node_modules folders', async () => { + setup() + expect((await resolve({ kind: 'wikilink', target: 'Nested' }))?.absPath).toBe( + path.join(root, 'proj/docs/wiki/graphify-out/deep/Nested.md') + ) + file('proj/docs/wiki/Nested.md') + expect((await resolve({ kind: 'wikilink', target: 'Nested' }))?.absPath).toBe( + path.join(root, 'proj/docs/wiki/Nested.md') + ) + expect(await resolve({ kind: 'wikilink', target: 'Hidden' })).toBeNull() + expect(await resolve({ kind: 'wikilink', target: 'Shadow' })).toBeNull() + }) + + it('does not look above the note directory or past the depth limit', async () => { + setup() + expect(await resolve({ kind: 'wikilink', target: 'README' })).toBeNull() + const deep = 'proj/docs/wiki/' + Array.from({ length: WIKILINK_SEARCH_DEPTH + 1 }, (_, i) => `d${i}`).join('/') + file(`${deep}/TooDeep.md`) + expect(await resolve({ kind: 'wikilink', target: 'TooDeep' })).toBeNull() + }) + + it('treats a path-like target as relative to the note, like Obsidian', async () => { + setup() + expect((await resolve({ kind: 'wikilink', target: 'graphify-out/GRAPH_REPORT' }))?.absPath).toBe( + path.join(root, 'proj/docs/wiki/graphify-out/GRAPH_REPORT.md') + ) + expect((await resolve({ kind: 'wikilink', target: '../../README.md' }))?.absPath).toBe( + path.join(root, 'proj/README.md') + ) + expect(await resolve({ kind: 'wikilink', target: 'graphify-out/Missing' })).toBeNull() + }) + + it('answers null for an empty or anchor-only target', async () => { + setup() + expect(await resolve({ kind: 'wikilink', target: '' })).toBeNull() + expect(await resolve({ kind: 'wikilink', target: '#Heading' })).toBeNull() + }) +}) + +describe('isStandaloneLink', () => { + it('accepts only the two link shapes with string payloads', () => { + expect(isStandaloneLink({ kind: 'wikilink', target: 'x' })).toBe(true) + expect(isStandaloneLink({ kind: 'href', href: 'x' })).toBe(true) + expect(isStandaloneLink({ kind: 'href', target: 'x' })).toBe(false) + expect(isStandaloneLink({ kind: 'note', href: 'x' })).toBe(false) + expect(isStandaloneLink('x')).toBe(false) + expect(isStandaloneLink(null)).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/standalone-links.ts b/apps/desktop/src/main/standalone-links.ts new file mode 100644 index 00000000..9208882f --- /dev/null +++ b/apps/desktop/src/main/standalone-links.ts @@ -0,0 +1,178 @@ +/** + * Following a link from a note that has no vault. + * + * A markdown file opened from outside every known vault (Finder "Open With", + * a double-click, `zn open `) lives in a standalone window with no vault + * behind it, and every resolver in the app is vault-bound: wikilinks look up + * the vault's note index, relative links resolve against a vault-relative note + * path, and both end in "open this note in the workspace". None of that has + * anything to stand on for a loose file, so every link in that window was + * dead (#626). + * + * The one thing a loose file does have is a directory. This resolves a link + * the way the file's author meant it: a relative href from the file's own + * directory, a `[[wikilink]]` by name within that directory's tree (a + * generated wiki keeps its pages together), and the result is handed back + * to the Finder opener, which already knows whether an absolute markdown path + * belongs to a known vault or gets a standalone window of its own. + */ +import path from 'node:path' +import { promises as fsp, type Dirent } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { isMarkdownFilePath, MARKDOWN_FILE_EXTENSIONS } from './file-open' + +export type StandaloneLink = + | { kind: 'wikilink'; target: string } + | { kind: 'href'; href: string } + +export type StandaloneLinkTarget = + | { kind: 'markdown'; absPath: string } + | { kind: 'file'; absPath: string } + +export interface StandaloneLinkIo { + isFile(absPath: string): Promise + readdir(absPath: string): Promise +} + +const defaultIo: StandaloneLinkIo = { + async isFile(absPath) { + try { + return (await fsp.stat(absPath)).isFile() + } catch { + return false + } + }, + readdir: (absPath) => fsp.readdir(absPath, { withFileTypes: true }) +} + +/** How far below the note's directory a bare wikilink name is looked for. A + * generated wiki nests a few levels at most; a whole home directory is not a + * place to go looking for `[[notes]]`. */ +export const WIKILINK_SEARCH_DEPTH = 4 +/** Upper bound on directory entries visited for one lookup. */ +export const WIKILINK_SEARCH_BUDGET = 5000 + +const SKIPPED_DIRS = new Set(['node_modules', '.git']) + +/** True for a StandaloneLink shape the renderer may send; anything else is + * refused before it reaches the filesystem. */ +export function isStandaloneLink(value: unknown): value is StandaloneLink { + if (!value || typeof value !== 'object') return false + const link = value as { kind?: unknown; target?: unknown; href?: unknown } + if (link.kind === 'wikilink') return typeof link.target === 'string' + if (link.kind === 'href') return typeof link.href === 'string' + return false +} + +/** + * The file a link from `notePath` names, or null when it names nothing on + * disk. Web URLs, mail links, in-page anchors and app schemes are not files + * and resolve to null here; the renderer keeps those on their own paths. + */ +export async function resolveStandaloneLink( + notePath: string, + link: StandaloneLink, + io: StandaloneLinkIo = defaultIo +): Promise { + const noteDir = path.dirname(path.resolve(notePath)) + const abs = + link.kind === 'wikilink' + ? await resolveWikilink(noteDir, link.target, io) + : await resolveHref(noteDir, link.href, io) + if (!abs) return null + return { kind: isMarkdownFilePath(abs) ? 'markdown' : 'file', absPath: abs } +} + +async function resolveHref( + noteDir: string, + rawHref: string, + io: StandaloneLinkIo +): Promise { + const href = rawHref.trim() + if (!href || href.startsWith('#')) return null + let candidate: string + if (/^file:\/\//i.test(href)) { + try { + candidate = fileURLToPath(href) + } catch { + return null + } + } else { + // Any other scheme (https:, mailto:, zen-asset:, a Windows drive letter is + // handled below) is not a path from this directory. + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) && !/^[a-zA-Z]:[\\/]/.test(href)) return null + const pathOnly = href.split(/[?#]/)[0] ?? href + let decoded = pathOnly + try { + decoded = decodeURIComponent(pathOnly) + } catch { + // A stray `%` in a hand-written link is still a path. + } + if (!decoded) return null + candidate = path.isAbsolute(decoded) ? decoded : path.resolve(noteDir, decoded) + } + return await existingFile(candidate, io) +} + +/** `candidate` when it is a file, else the markdown file it names without an + * extension (`[Readme](../README)` for `README.md`), else null. */ +async function existingFile(candidate: string, io: StandaloneLinkIo): Promise { + if (await io.isFile(candidate)) return candidate + if (path.extname(candidate)) return null + for (const ext of MARKDOWN_FILE_EXTENSIONS) { + if (await io.isFile(candidate + ext)) return candidate + ext + } + return null +} + +async function resolveWikilink( + noteDir: string, + rawTarget: string, + io: StandaloneLinkIo +): Promise { + // `[[Doc|alias]]`, `[[Doc#Heading]]` and `[[Doc^block]]` all name Doc. + const target = rawTarget.split('|')[0].split(/[#^]/)[0].trim() + if (!target) return null + // A path-like target is Obsidian's "relative to this note" form. + if (target.includes('/') || /\.(md|markdown)$/i.test(target)) { + const found = await existingFile(path.resolve(noteDir, target), io) + if (found) return found + if (!/\.(md|markdown)$/i.test(target)) return null + return null + } + return await findByName(noteDir, target, io) +} + +/** The shallowest markdown file named `name` (case-insensitively, extension + * aside) under `root`, ties broken alphabetically for a stable answer. */ +async function findByName(root: string, name: string, io: StandaloneLinkIo): Promise { + const wanted = name.toLowerCase() + let visited = 0 + let level: string[] = [root] + for (let depth = 0; depth <= WIKILINK_SEARCH_DEPTH && level.length > 0; depth++) { + const next: string[] = [] + const hits: string[] = [] + for (const dir of level.sort()) { + let entries: Dirent[] + try { + entries = await io.readdir(dir) + } catch { + continue + } + for (const entry of entries) { + if (++visited > WIKILINK_SEARCH_BUDGET) return hits.sort()[0] ?? null + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (!entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name)) next.push(full) + continue + } + if (!entry.isFile() || !isMarkdownFilePath(entry.name)) continue + const stem = entry.name.slice(0, entry.name.length - path.extname(entry.name).length) + if (stem.toLowerCase() === wanted) hits.push(full) + } + } + if (hits.length > 0) return hits.sort()[0] + level = next + } + return null +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index efaf3ec7..dfb7a65a 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -54,6 +54,7 @@ import type { DeletedAsset, DirectoryBrowseResult, ExternalFileContent, + ExternalFileLink, FolderEntry, ImportedAsset, LinkMetadata, @@ -606,6 +607,8 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.APP_WRITE_EXTERNAL_FILE, body), moveExternalFileToVault: (): Promise => ipcRenderer.invoke(IPC.APP_MOVE_EXTERNAL_FILE_TO_VAULT), + followExternalFileLink: (link: ExternalFileLink): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke(IPC.APP_FOLLOW_EXTERNAL_FILE_LINK, link), openMarkdownFile: (absPath: string): Promise => ipcRenderer.invoke(IPC.APP_OPEN_MARKDOWN_FILE, absPath), openFileDialog: (): Promise => ipcRenderer.invoke(IPC.APP_OPEN_FILE_DIALOG), diff --git a/apps/server/package.json b/apps/server/package.json index 1badd83d..d229f14a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.41.0", + "version": "2.42.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 2e5aea8f..31925e4f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.41.0", + "version": "2.42.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 06ffaa52..e037dd19 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -41,6 +41,7 @@ import type { DeletedAsset, DirectoryBrowseResult, ExternalFileContent, + ExternalFileLink, FolderEntry, ImportedAsset, LinkMetadata, @@ -1198,6 +1199,10 @@ async function moveExternalFileToVault(): Promise { return notImplemented('moveExternalFileToVault') } +async function followExternalFileLink(_link: ExternalFileLink): Promise<{ ok: boolean; error?: string }> { + return { ok: false, error: 'desktop-only' } +} + async function openMarkdownFile(_absPath: string): Promise { // The web client has no OS filesystem to open standalone markdown files // from; drag-and-drop-to-open is a desktop-only capability. @@ -1529,6 +1534,7 @@ export const httpBridge: ZenBridge = { readExternalFile, writeExternalFile, moveExternalFileToVault, + followExternalFileLink, openMarkdownFile, openFileDialog, openFolderTemporary, diff --git a/package-lock.json b/package-lock.json index 22789af0..7892168e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.41.0", + "version": "2.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.41.0", + "version": "2.42.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.41.0", + "version": "2.42.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -861,11 +861,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.41.0" + "version": "2.42.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.41.0", + "version": "2.42.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16242,7 +16242,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.41.0", + "version": "2.42.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16306,11 +16306,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.41.0" + "version": "2.42.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.41.0", + "version": "2.42.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16321,7 +16321,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.41.0" + "version": "2.42.0" } } } diff --git a/package.json b/package.json index b1a184cf..1265aa3b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.41.0", + "version": "2.42.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 33bd21fd..754bee87 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.41.0", + "version": "2.42.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 f74bd8e5..ce3635ce 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -716,12 +716,6 @@ function App(): JSX.Element { void state.createAndOpen('quick', '', { title, focusTitle: true }) return } - if (matchesShortcut(e, overrides, 'global.newNoteHere')) { - // ⌘N — new note in the current folder (#614) - e.preventDefault() - void state.createNoteInCurrentFolder() - return - } if (matchesShortcut(e, overrides, 'global.toggleWordWrap')) { // ⌥Z — toggle word wrap (matches VSCode/Sublime convention) e.preventDefault() @@ -805,14 +799,6 @@ function App(): JSX.Element { void state.toggleRecentNote() return } - if (matchesShortcut(e, overrides, 'global.searchNotes')) { - // ⌘P — note search - e.preventDefault() - setBufferPaletteOpen(false) - setVaultTextSearchOpen(false) - setSearchOpen(!state.searchOpen) - 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. @@ -956,6 +942,25 @@ function App(): JSX.Element { // Settings keybinding recorder is open — the recorder also captures keys // in this phase, so a focusPane shortcut bound to e.g. Ctrl+H must not // intercept it. (#124) + const overrides = state.keymapOverrides + const modalOrMenuOpen = + !!document.querySelector('[data-ctx-menu]') || + !!document.querySelector('[data-prompt-modal]') || + !!document.querySelector('[data-confirm-modal]') + // Search Notes is a toggle: its own shortcut closes the palette it + // opened (#510 moved it here from the bubble handler, which had no + // overlay guard at all). A confirm on top of the palette, such as the + // Ctrl+D trash confirmation, keeps the key to itself. + if ( + state.searchOpen && + !modalOrMenuOpen && + matchesShortcut(e, overrides, 'global.searchNotes') + ) { + e.preventDefault() + e.stopImmediatePropagation() + setSearchOpen(false) + return + } if ( state.settingsOpen || state.searchOpen || @@ -965,9 +970,7 @@ function App(): JSX.Element { state.templatePaletteOpen || state.embedDrawingPaletteOpen || state.outlinePaletteOpen || - document.querySelector('[data-ctx-menu]') || - document.querySelector('[data-prompt-modal]') || - document.querySelector('[data-confirm-modal]') || + modalOrMenuOpen || // An open autocomplete popup (slash menu, [[ links, the callout [! type // picker) owns the keyboard: its Ctrl+J/Ctrl+K/Ctrl+N/Ctrl+P navigation // must win over a focusPane shortcut a user remapped onto those chords, @@ -977,7 +980,6 @@ function App(): JSX.Element { ) { return } - const overrides = state.keymapOverrides const paneDir = matchesShortcut(e, overrides, 'global.focusPaneLeft') ? 'h' : matchesShortcut(e, overrides, 'global.focusPaneDown') @@ -1014,6 +1016,31 @@ function App(): JSX.Element { return } + // Search Notes (Mod+P) and New Note Here (Mod+N) live here for the same + // reason (#510). On Linux and Windows Mod is Ctrl, and codemirror-vim + // aliases / to k/j and stops propagation of every key it + // handles, so from a focused editor the bubble-phase handler never saw + // them: the note search palette could not be opened from the note being + // edited. The rule this settles, written down in the keymaps help: with + // Vim mode on, an app shortcut on a Ctrl chord wins over Vim's, except + // the chords Vim mode reserves on purpose, Ctrl+W (pane prefix), Ctrl+O + // and Ctrl+I (jumplist, #488) and Ctrl+D / Ctrl+U (half page). + if (matchesShortcut(e, overrides, 'global.searchNotes')) { + e.preventDefault() + e.stopImmediatePropagation() + setBufferPaletteOpen(false) + setVaultTextSearchOpen(false) + setSearchOpen(true) + return + } + if (matchesShortcut(e, overrides, 'global.newNoteHere')) { + // #614 + e.preventDefault() + e.stopImmediatePropagation() + void state.createNoteInCurrentFolder() + return + } + // With Vim mode ON, VimNav owns every key inside a focused panel. With it // OFF that listener isn't installed at all, so `Alt+hjkl` could put focus // in a panel there was then no way to drive — you could reach Connections diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 7bbc1a04..949372d2 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -63,11 +63,12 @@ import { markerHopCommands } from '../lib/cm-marker-hop' import { isInMarkdownCode } from '../lib/cm-auto-pairs' import { toggleCheckbox } from '../lib/cm-toggle-checkbox' import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' -import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { isVimAwaitingArgument } from '../lib/vim-nav' import { toCodeMirrorKey, vimHalfPageKeymap } from '../lib/vim-half-page-keymap' import { scrollOff } from '../lib/cm-scrolloff' import { followLinkTarget } from '../lib/follow-link' +import { pointerOverRange } from '../lib/cm-pointer-range' import { setHoveredLink } from '../lib/hovered-link' import { setYankToClipboardEnabled, @@ -93,7 +94,6 @@ import { unfoldHeadingAtCursor } from '../lib/cm-heading-fold' import { tags as t } from '@lezer/highlight' -import { searchKeymap } from '@codemirror/search' import { autocompletion } from '@codemirror/autocomplete' import { useStore } from '../store' import type { LineNumberMode } from '../store' @@ -317,29 +317,6 @@ const LARGE_DOC_EDITOR_HYDRATE_DELAY_MS = 180 // chords are stripped from `defaultKeymap` so Vim's `` & co. work (see // cm-vim-default-keymap). Built behind a compartment and reconfigured on Vim // toggle or keymap-override changes. -/** - * Whether the pointer actually rests on the rendered glyphs of [from, to]. - * posAtCoords clamps coordinates in the blank space beside a line to the - * nearest caret, and live preview hides a link's closing syntax, so that - * caret lands inside a link that merely ends its line; without this check the - * whole blank stretch after the line hovers and follows like the link (#587). - */ -function pointerOverRange( - view: EditorView, - from: number, - to: number, - x: number, - y: number -): boolean { - const start = view.coordsAtPos(from, 1) - const end = view.coordsAtPos(to, -1) - if (!start || !end) return false - if (y < start.top || y > end.bottom) return false - if (y <= start.bottom && x < start.left) return false - if (y >= end.top && x > end.right) return false - return true -} - // Straight quotes join the hop's markers exactly where they auto-pair: with the // prose setting on, or inside code, where auto-pair always closes them (#685). const markerHop = markerHopCommands({ @@ -427,7 +404,7 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens indentWithTab, ...vimAwareDefaultKeymap(vimMode), ...historyKeymap, - ...searchKeymap, + ...vimAwareSearchKeymap(vimMode), ...completionKeymapForEditor ]) } @@ -852,6 +829,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const updateNoteBody = useStore((s) => s.updateNoteBody) const persistNote = useStore((s) => s.persistNote) const trashActive = useStore((s) => s.trashActive) + const deleteActivePermanently = useStore((s) => s.deleteActivePermanently) const archiveActive = useStore((s) => s.archiveActive) const restoreActive = useStore((s) => s.restoreActive) const unarchiveActive = useStore((s) => s.unarchiveActive) @@ -3360,9 +3338,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { )} - void trashActive()}> - - + {folder === 'trash' ? ( + // A trashed note cannot be trashed again; here the bin icon means the + // only thing left that it can mean (#712). + void deleteActivePermanently()}> + + + ) : ( + void trashActive()}> + + + )} ) }, [ @@ -3380,6 +3366,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { calendarOpen, toggleCalendarPanel, trashActive, + deleteActivePermanently, archiveActive, restoreActive, unarchiveActive, diff --git a/packages/app-core/src/components/ExcalidrawView.tsx b/packages/app-core/src/components/ExcalidrawView.tsx index ac9d487a..9aa7fe64 100644 --- a/packages/app-core/src/components/ExcalidrawView.tsx +++ b/packages/app-core/src/components/ExcalidrawView.tsx @@ -3,6 +3,7 @@ import type { ComponentProps } from 'react' import { Excalidraw, serializeAsJSON } from '@excalidraw/excalidraw' import '@excalidraw/excalidraw/index.css' import { parseExcalidrawDocument } from '@shared/excalidraw' +import { useStore } from '../store' type InitialData = ComponentProps['initialData'] type ExcalidrawProps = ComponentProps @@ -50,6 +51,7 @@ function readThemeMode(): 'light' | 'dark' { */ export function ExcalidrawView({ path }: { path: string }): JSX.Element { const [initialData, setInitialData] = useState(undefined) + const setFocusedPanel = useStore((s) => s.setFocusedPanel) const saveTimer = useRef | null>(null) const lastSaved = useRef('') const latestScene = useRef(null) @@ -139,7 +141,18 @@ export function ExcalidrawView({ path }: { path: string }): JSX.Element { } return ( -
+ // Clicking into the drawing claims the keyboard the same way the editor + // and the archive list do. Without the claim, focusedPanel stayed on the + // sidebar row that opened the drawing: the sidebar kept painting its + // cursor, and pane navigation (Ctrl+W h/l) measured from the wrong + // panel. VimNav yields to the canvas by DOM focus regardless (#721). +
setFocusedPanel('editor')} + onFocusCapture={() => setFocusedPanel('editor')} + > () @@ -52,6 +58,7 @@ export function ExternalFileApp(): JSX.Element { const [mode, setMode] = useState<'edit' | 'preview'>('edit') const [moving, setMoving] = useState(false) const [moveError, setMoveError] = useState(null) + const [linkError, setLinkError] = useState(null) const viewRef = useRef(null) const saveTimerRef = useRef | null>(null) // Source of truth for the body: seeded on load and updated on every @@ -98,6 +105,46 @@ export function ExternalFileApp(): JSX.Element { } }, []) + // Links have no vault to resolve against here, so the host resolves them + // from the file's own directory (#626). Web links stay in the browser. + const followLink = useCallback(async (decision: StandaloneLinkAction): Promise => { + if (!decision) return false + if (decision.action === 'browser') { + window.open(decision.url, '_blank') + return true + } + setLinkError(null) + try { + const result = await window.zen.followExternalFileLink(decision.link) + if (!result.ok) setLinkError(result.error ?? 'Could not open that link.') + } catch (err) { + setLinkError(err instanceof Error ? err.message : 'Could not open that link.') + } + return true + }, []) + const followLinkRef = useRef(followLink) + followLinkRef.current = followLink + + const linkAtPointer = useCallback( + (view: EditorView, event: MouseEvent): StandaloneLinkAction | 'edit' | null => { + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) + if (pos == null) return null + const doc = view.state.doc.toString() + const link = linkRangeAtCursor(doc, pos) + if (!link || !pointerOverRange(view, link.from, link.to, event.clientX, event.clientY)) return null + // Same rule as the main editor (#201): Cmd/Ctrl-click always follows; a + // plain click follows a rendered link (selection outside it) in live + // preview and otherwise lands the cursor to edit. + if (!(event.metaKey || event.ctrlKey)) { + const sel = view.state.selection.main + const rendered = prefs.livePreview && (sel.to < link.from || sel.from > link.to) + if (!rendered) return 'edit' + } + return standaloneLinkForEditorTarget(doc.slice(link.from, link.to), link.target) + }, + [prefs.livePreview] + ) + // Mount CodeMirror once content is loaded. const setContainerRef = useCallback( (el: HTMLDivElement | null) => { @@ -134,8 +181,18 @@ export function ExternalFileApp(): JSX.Element { indentWithTab, ...vimAwareDefaultKeymap(prefs.vimMode), ...historyKeymap, - ...searchKeymap + ...vimAwareSearchKeymap(prefs.vimMode) ]), + EditorView.domEventHandlers({ + mousedown: (event, view) => { + if (event.button !== 0 || event.altKey || event.shiftKey) return false + const decision = linkAtPointer(view, event) + if (!decision || decision === 'edit') return false + event.preventDefault() + void followLinkRef.current(decision) + return true + } + }), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return if (upd.transactions.some((tr: Transaction) => tr.annotation(programmatic))) return @@ -160,7 +217,7 @@ export function ExternalFileApp(): JSX.Element { viewRef.current.focus() }, // eslint-disable-next-line react-hooks/exhaustive-deps - [persist, prefs.livePreview, prefs.vimMode] + [persist, prefs.livePreview, prefs.vimMode, linkAtPointer] ) // Seed the live CM view the first time content arrives. @@ -232,6 +289,14 @@ export function ExternalFileApp(): JSX.Element { await persist(currentBody()) } externalFileHandlers.close = (): void => window.zen.windowClose() + externalFileHandlers.followLinkAtCursor = (): void => { + const view = viewRef.current + if (!view) return + const doc = view.state.doc.toString() + const link = linkRangeAtCursor(doc, view.state.selection.main.head) + if (!link) return + void followLinkRef.current(standaloneLinkForEditorTarget(doc.slice(link.from, link.to), link.target)) + } registerExternalFileVimCommands() applyVimInsertEscape(prefs.vimInsertEscape) }, [persist, currentBody, prefs.vimInsertEscape]) @@ -299,9 +364,9 @@ export function ExternalFileApp(): JSX.Element {
- {moveError && ( + {(moveError || linkError) && (
- {moveError} + {moveError ?? linkError}
)} @@ -309,7 +374,22 @@ export function ExternalFileApp(): JSX.Element { {mode === 'edit' ? (
) : content ? ( -
+
{ + const anchor = (event.target as HTMLElement | null)?.closest('a') + if (!anchor) return + const decision = standaloneLinkForAnchor(anchor) + if (!decision) return + event.preventDefault() + event.stopPropagation() + void followLink(decision) + }} + >
) : ( @@ -325,7 +405,8 @@ export function ExternalFileApp(): JSX.Element { const externalFileHandlers: { persist: null | (() => Promise) close: null | (() => void) -} = { persist: null, close: null } + followLinkAtCursor: null | (() => void) +} = { persist: null, close: null, followLinkAtCursor: null } let externalFileVimRegistered = false @@ -349,4 +430,10 @@ function registerExternalFileVimCommands(): void { Vim.defineEx('x', 'x', () => { void externalFileHandlers.persist?.().then(deferredClose) }) + // `gd` follows the link under the cursor, as in the main editor. This + // window has no keymap overrides to consult, so it is the default chord. + Vim.defineAction('zenStandaloneFollowLink', () => { + externalFileHandlers.followLinkAtCursor?.() + }) + Vim.mapCommand('gd', 'action', 'zenStandaloneFollowLink', {}, { context: 'normal' }) } diff --git a/packages/app-core/src/components/FloatingNoteApp.tsx b/packages/app-core/src/components/FloatingNoteApp.tsx index a695e39c..f238bbe5 100644 --- a/packages/app-core/src/components/FloatingNoteApp.tsx +++ b/packages/app-core/src/components/FloatingNoteApp.tsx @@ -27,7 +27,7 @@ import { } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' -import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { resolveCodeLanguage } from '../lib/cm-code-languages' @@ -41,7 +41,6 @@ import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language' import { tags as t } from '@lezer/highlight' -import { searchKeymap } from '@codemirror/search' import type { NoteContent, VaultChangeEvent } from '@shared/ipc' import type { VimWrappedLineMotionMode } from '@shared/app-config' import type { LineNumberMode } from '../store' @@ -355,7 +354,7 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element indentWithTab, ...vimAwareDefaultKeymap(prefs.vimMode), ...historyKeymap, - ...searchKeymap + ...vimAwareSearchKeymap(prefs.vimMode) ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index b7a71c07..c261e678 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -27,7 +27,7 @@ import { } 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' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { resolveCodeLanguage } from '../lib/cm-code-languages' @@ -39,7 +39,6 @@ import { } from '../lib/cm-markdown-list-indent' import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language' import { tags as t } from '@lezer/highlight' -import { searchKeymap } from '@codemirror/search' import { autocompletion } from '@codemirror/autocomplete' import { useStore } from '../store' import type { LineNumberMode } from '../store' @@ -275,7 +274,7 @@ export function PinnedReferencePane(): JSX.Element | null { indentWithTab, ...vimAwareDefaultKeymap(s0.vimMode), ...historyKeymap, - ...searchKeymap, + ...vimAwareSearchKeymap(s0.vimMode), ...completionKeymapForEditor ]), EditorView.updateListener.of((upd) => { diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 2051dca4..9ea86bfc 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -41,7 +41,7 @@ import { } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' -import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { registerDisplayLineMotion } from '../lib/cm-vim-display-line' import { registerHeadingMotion } from '../lib/cm-vim-heading-motion' @@ -55,7 +55,6 @@ import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language' import { tags as t } from '@lezer/highlight' -import { searchKeymap } from '@codemirror/search' import { autocompletion, closeCompletion, @@ -520,7 +519,7 @@ export function QuickCaptureApp(): JSX.Element { ...completionKeymapForEditor, ...vimAwareDefaultKeymap(prefs.vimMode), ...historyKeymap, - ...searchKeymap + ...vimAwareSearchKeymap(prefs.vimMode) ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return diff --git a/packages/app-core/src/components/TrashView.tsx b/packages/app-core/src/components/TrashView.tsx index 7b9497d3..0002056a 100644 --- a/packages/app-core/src/components/TrashView.tsx +++ b/packages/app-core/src/components/TrashView.tsx @@ -6,6 +6,7 @@ import { CollectionViewHeader } from './CollectionViewHeader' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { confirmApp } from '../lib/confirm-requests' +import { confirmDeletePermanently } from '../lib/confirm-trash' import { isAppOverlayOpen } from '../lib/overlay-open' function formatDate(ms: number): string { @@ -100,13 +101,7 @@ export function TrashView(): JSX.Element { const deleteNoteForever = useCallback( async (note: NoteMeta) => { - const ok = await confirmApp({ - title: `Delete "${note.title}" permanently?`, - description: 'This cannot be undone.', - confirmLabel: 'Delete permanently', - danger: true - }) - if (!ok) return + if (!(await confirmDeletePermanently(note.title))) return await window.zen.deleteNote(note.path) await refreshNotes() }, diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 507b16ad..ece7c224 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -46,7 +46,7 @@ import { import { getBufferNavigationTarget } from '../lib/buffer-navigation' import { focusEditorNormalMode } from '../lib/editor-focus' import { atlasHoldsKeyboard } from '../lib/atlas' -import { SELF_KEYED_SURFACES } from '../lib/self-keyed-surfaces' +import { EXCALIDRAW_SURFACE, SELF_KEYED_SURFACES } from '../lib/self-keyed-surfaces' import { isWorkspaceVirtualTabPath } from '../lib/workspace-tabs' import { isExcalidrawPath, @@ -480,7 +480,7 @@ export function VimNav(): JSX.Element | null { if ( sequenceTokenFromEvent(e) === leaderToken && !leaderPending.current && - target?.closest('[data-excalidraw-view]') + target?.closest(EXCALIDRAW_SURFACE) ) { if (!e.repeat) excalidrawSpaceDownAt.current = Date.now() return @@ -644,7 +644,7 @@ export function VimNav(): JSX.Element | null { const gTabTokens = getSequenceTokens(overrides, 'vim.tabNext') const gPrevTokens = getSequenceTokens(overrides, 'vim.tabPrevious') const gTok = sequenceTokenFromEvent(e) - const inExcalidrawView = !!target?.closest('[data-excalidraw-view]') + const inExcalidrawView = !!target?.closest(EXCALIDRAW_SURFACE) if (gTabPending.current) { // Shift is delivered as its own keydown before `T`; keep the pending // `g` prefix alive so Excalidraw can complete Vim-style `gT`. @@ -1157,6 +1157,17 @@ export function VimNav(): JSX.Element | null { } } + // ------- Excalidraw canvas ----------------------------------------- + // A canvas holding DOM focus owns every key the global bindings above + // left alone: Escape leaves the current tool, letters pick tools, arrows + // nudge the selection, Delete removes it. The panel routing below would + // otherwise take whatever the canvas did not stop, and with the sidebar + // open that included Escape, so the Arrow tool could not be left (#721). + // Handing the keyboard to a panel blurs the canvas first (see + // releaseSelfKeyedSurfaceFocus), which is what makes DOM focus the + // right test here. + if (target?.closest(EXCALIDRAW_SURFACE)) return + // ------- Sidebar navigation (explicit) ----------------------------- // When focusedPanel is 'sidebar', always handle here — even if the // editor still holds stale DOM focus from a previous interaction. @@ -1269,7 +1280,7 @@ export function VimNav(): JSX.Element | null { const downAt = excalidrawSpaceDownAt.current excalidrawSpaceDownAt.current = null const target = e.target instanceof HTMLElement ? e.target : null - if (!target?.closest('[data-excalidraw-view]')) return + if (!target?.closest(EXCALIDRAW_SURFACE)) return if (Date.now() - downAt < EXCALIDRAW_LEADER_TAP_MS) { armLeader('leader', false) } diff --git a/packages/app-core/src/lib/cm-pointer-range.ts b/packages/app-core/src/lib/cm-pointer-range.ts new file mode 100644 index 00000000..d9c1f2f9 --- /dev/null +++ b/packages/app-core/src/lib/cm-pointer-range.ts @@ -0,0 +1,24 @@ +import type { EditorView } from '@codemirror/view' + +/** + * Whether the pointer actually rests on the rendered glyphs of [from, to]. + * posAtCoords clamps coordinates in the blank space beside a line to the + * nearest caret, and live preview hides a link's closing syntax, so that + * caret lands inside a link that merely ends its line; without this check the + * whole blank stretch after the line hovers and follows like the link (#587). + */ +export function pointerOverRange( + view: EditorView, + from: number, + to: number, + x: number, + y: number +): boolean { + const start = view.coordsAtPos(from, 1) + const end = view.coordsAtPos(to, -1) + if (!start || !end) return false + if (y < start.top || y > end.bottom) return false + if (y <= start.bottom && x < start.left) return false + if (y >= end.top && x > end.right) return false + return true +} diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts index 0bc0c921..7695fc2b 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts @@ -4,7 +4,7 @@ import { EditorState } from '@codemirror/state' import { EditorView, keymap, type KeyBinding } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' -import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from './cm-vim-default-keymap' +import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from './cm-vim-default-keymap' // Regression guard for the macOS Vim `Ctrl-d` bug: defaultKeymap's emacs-style // mac chords used to shadow Vim's (half-page down) and delete a char. @@ -180,3 +180,27 @@ describe('vimAwareMarkdownKeymap (Enter in Vim normal mode acts as )', () => expect(view.state.doc.toString()).toBe('- item\n- ') }) }) + +// #510: on Linux/Windows the search panel's Mod-f is Ctrl+F, which Vim wants +// for page-forward; the keymap runs ahead of the Vim plugin, so the panel won. +describe('vimAwareSearchKeymap', () => { + const keys = (bindings: readonly KeyBinding[]) => bindings.map((b) => b.key) + + it('drops Mod-f in Vim mode where Mod is Ctrl, so Ctrl+F pages like Ctrl+B', () => { + expect(keys(vimAwareSearchKeymap(true, false))).not.toContain('Mod-f') + }) + + it('keeps the rest of the search keymap there (find next, replace, select matches)', () => { + const kept = keys(vimAwareSearchKeymap(true, false)) + for (const key of ['F3', 'Mod-g', 'Escape', 'Mod-Shift-l', 'Mod-Alt-g', 'Mod-d']) { + expect(kept).toContain(key) + } + }) + + it('keeps Mod-f on macOS in Vim mode (Cmd+F never collided) and everywhere with Vim off', () => { + expect(keys(vimAwareSearchKeymap(true, true))).toContain('Mod-f') + expect(keys(vimAwareSearchKeymap(false, false))).toContain('Mod-f') + expect(keys(vimAwareSearchKeymap(false, true))).toContain('Mod-f') + }) +}) + diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.ts b/packages/app-core/src/lib/cm-vim-default-keymap.ts index 17b19515..2a2e586b 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -2,8 +2,10 @@ import { defaultKeymap } from '@codemirror/commands' import { markdownKeymap } from '@codemirror/lang-markdown' import { Prec, type Extension } from '@codemirror/state' import { keymap, type EditorView, type KeyBinding } from '@codemirror/view' +import { searchKeymap } from '@codemirror/search' import { getCM } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' +import { isMacPlatform } from './keymaps' /** * macOS-only Vim keymap conflict (`Ctrl-d` deletes instead of half-page-down). @@ -124,6 +126,22 @@ export function vimAwareDefaultKeymap(vimMode: boolean): readonly KeyBinding[] { return vimMode ? vimModeKeymap : defaultKeymap } +/** + * CodeMirror's `searchKeymap`, made Vim-aware where Mod is Ctrl. + * + * The search panel is bound to `Mod-f`. On Linux and Windows that is Ctrl+F, + * which in Vim is page-forward, and because the keymap handler runs ahead of + * the Vim plugin the panel won every time while `` (page-back) reached + * Vim: the one motion chord the editor still took from Vim (#510). With Vim + * mode on, Vim users search with `/`, so the binding is dropped there and + * Ctrl+F pages like Ctrl+B. macOS keeps it: Cmd+F never collided with Vim. + * The rest of the keymap (find next, replace, select matches) stays as is. + */ +export function vimAwareSearchKeymap(vimMode: boolean, mac: boolean = isMacPlatform()): readonly KeyBinding[] { + if (!vimMode || mac) return searchKeymap + return searchKeymap.filter((binding) => binding.key !== 'Mod-f') +} + /** * Vim-aware replacement for `markdown({ addKeymap: true })`'s keymap. * diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index bb12011d..40c9f889 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -269,3 +269,47 @@ describe('New Note in Current Folder (#403)', () => { expect(createAndOpen).toHaveBeenCalledWith('inbox', '', { focusTitle: true }) }) }) + +describe('note commands for a trashed note (#712)', () => { + const note = { + path: 'trash/Old idea.md', + title: 'Old idea', + folder: 'trash' as const, + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: 1, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '', + body: '' + } + + it('offers Restore and Delete Permanently, not Move to Trash, while the active note is in the Trash', async () => { + const { buildCommands, useStore } = await loadCommands() + useStore.setState({ selectedPath: note.path, activeNote: note }) + const ids = buildCommands().map((cmd) => cmd.id) + expect(ids).toContain('note.restore') + expect(ids).toContain('note.delete-permanently') + expect(ids).not.toContain('note.trash') + }) + + it('offers Move to Trash, not Delete Permanently, for a note outside the Trash', async () => { + const { buildCommands, useStore } = await loadCommands() + useStore.setState({ selectedPath: 'inbox/Idea.md', activeNote: { ...note, path: 'inbox/Idea.md', folder: 'inbox' as const } }) + const ids = buildCommands().map((cmd) => cmd.id) + expect(ids).toContain('note.trash') + expect(ids).not.toContain('note.delete-permanently') + }) + + it('runs the store action that deletes the active note for good', async () => { + const { buildCommands, useStore } = await loadCommands() + const deleteActivePermanently = vi.fn().mockResolvedValue(undefined) + useStore.setState({ selectedPath: note.path, activeNote: note, deleteActivePermanently }) + await buildCommands().find((cmd) => cmd.id === 'note.delete-permanently')?.run() + expect(deleteActivePermanently).toHaveBeenCalledTimes(1) + }) +}) + diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index a36d7a0f..c7676b79 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -339,6 +339,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => getState().activeNote?.folder === 'trash', run: () => getState().restoreActive() }, + { + id: 'note.delete-permanently', + title: 'Delete Note Permanently', + category: 'Note', + keywords: 'trash purge forever remove', + when: () => getState().activeNote?.folder === 'trash', + run: () => getState().deleteActivePermanently() + }, { id: 'note.copy-wikilink', title: 'Copy Note as Wikilink', diff --git a/packages/app-core/src/lib/confirm-trash.ts b/packages/app-core/src/lib/confirm-trash.ts index 07f47aca..d1e1bb22 100644 --- a/packages/app-core/src/lib/confirm-trash.ts +++ b/packages/app-core/src/lib/confirm-trash.ts @@ -9,3 +9,17 @@ export function confirmMoveToTrash(title?: string | null): Promise { confirmLabel: 'Move to Trash' }) } + +/** The one wording for deleting a note for good, wherever it is offered: the + * Trash view's row action, the editor header of a trashed note, the command + * palette. */ +export function confirmDeletePermanently(title?: string | null): Promise { + const trimmed = title?.trim() + const target = trimmed ? `"${trimmed}"` : 'this note' + return confirmApp({ + title: `Delete ${target} permanently?`, + description: 'This cannot be undone.', + confirmLabel: 'Delete permanently', + danger: true + }) +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 66ec5afc..cee68712 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -447,7 +447,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Destructive actions ask first', body: - 'Moving a note to Trash now asks for confirmation before anything is deleted from the active workspace, and the Trash view separates restore from permanent delete. “Empty Trash” clears the whole bin in one confirmed step, and assets deleted from the Files view land in Trash too, restorable to their original location.' + 'Moving a note to Trash now asks for confirmation before anything is deleted from the active workspace, and the Trash view separates restore from permanent delete. Open a trashed note and the editor header offers the same two actions, Restore and Delete permanently, in place of Archive and Move to Trash. “Empty Trash” clears the whole bin in one confirmed step, and assets deleted from the Files view land in Trash too, restorable to their original location.' }, { title: 'Updates are release-driven', @@ -470,14 +470,14 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'global-shortcuts', title: 'Global shortcuts', - description: 'These work across the main app shell.', + description: 'These work across the main app shell. With Vim mode on and the editor focused, an app shortcut on a Ctrl chord wins over Vim\u2019s own use of that chord, except the ones Vim mode keeps on purpose: Ctrl+W (the pane prefix), Ctrl+O and Ctrl+I (the jumplist), and Ctrl+D and Ctrl+U (half-page scroll). On macOS these shortcuts use Cmd and never collide.', items: [ - { keys: 'Mod+P', action: 'Search notes', detail: 'Open the note search palette.' }, + { keys: 'Mod+P', action: 'Search notes', detail: 'Open the note search palette, from the editor too: on Linux and Windows this wins over Vim\u2019s Ctrl+P (cursor up).' }, { keys: 'Ctrl+D (in Search notes)', action: 'Move the highlighted note to Trash', detail: 'Trash a note straight from the search results, with the usual confirmation; the palette stays open, so a clean-up pass is search, Ctrl+D, search, Ctrl+D.' }, { keys: 'Mod+F', action: 'Search notes (non-Vim mode)', detail: 'Open the note search palette directly when Vim mode is off.' }, - { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it.' }, + { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'With Vim mode on, Linux and Windows keep Ctrl+F as Vim\u2019s page-forward (search with / instead); on macOS and with Vim off the bar opens as usual. In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it.' }, { keys: 'Shift+Mod+P', action: 'Open commands', detail: 'Open the command palette.' }, - { keys: 'Mod+N', action: 'New note in current folder', detail: 'Create a note in the active note\u2019s folder (or the browsed folder when no note is open) and focus its title. Rebindable under Settings \u2192 Keymaps.' }, + { keys: 'Mod+N', action: 'New note in current folder', detail: 'Create a note in the active note\u2019s folder (or the browsed folder when no note is open) and focus its title. On Linux and Windows this wins over Vim\u2019s Ctrl+N (cursor down). Rebindable under Settings \u2192 Keymaps.' }, { keys: 'Shift+Mod+N', action: 'New Quick Note', detail: 'Create a quick capture note in the main window and focus its title.' }, { keys: 'Shift+Mod+Space', action: 'Open quick capture window', detail: 'Open the floating, always-on-top capture window. Bound system-wide (CommandOrControl+Shift+Space by default) so it works over any app; change it under Settings → Editor.' }, { keys: 'Mod+,', action: 'Open Settings', detail: 'Open settings for appearance, editor behavior, fonts, vault controls, and app details.' }, @@ -488,11 +488,11 @@ 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.' }, + { 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: '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.' }, - { keys: 'Mod+O', action: 'Open file', detail: 'Desktop only: pick a Markdown file with the native dialog. A file inside a known vault opens against that vault; anything else opens in a standalone external-file window.' }, + { keys: 'Mod+O', action: 'Open file', detail: 'Desktop only: pick a Markdown file with the native dialog. A file inside a known vault opens against that vault; anything else opens in a standalone external-file window. Links in that window follow from the file\'s own folder: a relative link such as `../README.md` opens the file it names, and a `[[wikilink]]` finds a page of that name in the folder or below it, each in its own window (or in its vault, when the target lives in one).' }, { keys: 'Mod+4 / Mod+5 / Mod+6', action: 'Edit / Split / Preview mode', detail: 'Switch the active note between the raw editor, side-by-side split, and rendered preview.' }, { keys: 'Mod+L', action: 'Toggle checkbox', detail: 'Turn the current line into a checkbox and toggle it on repeat. See the “Any line becomes a checkbox” card in Core concepts for the full state rules.' }, { keys: 'Alt+Q (macOS: Ctrl+Q)', action: 'Reflow paragraph', detail: 'Join the hard-wrapped lines of the paragraph under the cursor (or every paragraph in the selection) into one line, so the editor wraps it to the pane. Headings, lists, tables, code, and explicit line breaks are untouched. See the “Reflow a hard-wrapped paragraph” card. Remappable as editor.reflowParagraph.' }, diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index e7edd4e6..2302579d 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -148,7 +148,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ scope: "app", group: "global", title: "Search notes", - description: "Open the vault-wide note search palette.", + description: + "Open the vault-wide note search palette. Wins over Vim's Ctrl+P (cursor up) when Mod is Ctrl.", defaultBinding: "Mod+P", }, { @@ -186,7 +187,7 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ group: "global", title: "New note in current folder", description: - "Create a note in the active note's folder (or the browsed folder when no note is open) and focus its title.", + "Create a note in the active note's folder (or the browsed folder when no note is open) and focus its title. Wins over Vim's Ctrl+N (cursor down) when Mod is Ctrl.", defaultBinding: "Mod+N", }, { @@ -331,7 +332,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ scope: "app", group: "global", title: "Close active tab", - description: "Close the current note or virtual 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.", defaultBinding: "Mod+W", }, { @@ -674,7 +676,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ scope: "pane", group: "vim", title: "Pane command prefix", - description: "Start pane focus and split commands.", + description: + "Start pane focus and split commands. One of the chords Vim mode reserves for itself from a focused editor, with Ctrl+O / Ctrl+I (jumplist) and Ctrl+D / Ctrl+U (half page).", defaultBinding: "Ctrl+W", vimOnly: true, maxTokens: 1, diff --git a/packages/app-core/src/lib/self-keyed-surfaces.test.ts b/packages/app-core/src/lib/self-keyed-surfaces.test.ts new file mode 100644 index 00000000..6de36e0d --- /dev/null +++ b/packages/app-core/src/lib/self-keyed-surfaces.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' + +import { + EXCALIDRAW_SURFACE, + SELF_KEYED_SURFACES, + releaseSelfKeyedSurfaceFocus +} from './self-keyed-surfaces' + +afterEach(() => { + document.body.innerHTML = '' +}) + +function mount(html: string): void { + document.body.innerHTML = html +} + +describe('releaseSelfKeyedSurfaceFocus', () => { + it('blurs a focused Excalidraw canvas so the sidebar can take the keys', () => { + mount('
') + const canvas = document.querySelector('.excalidraw-container')! + canvas.focus() + expect(document.activeElement).toBe(canvas) + releaseSelfKeyedSurfaceFocus() + expect(document.activeElement).toBe(document.body) + }) + + it("keeps Excalidraw's text editor focused mid-edit", () => { + mount('
') + const editor = document.querySelector('textarea')! + editor.focus() + releaseSelfKeyedSurfaceFocus() + expect(document.activeElement).toBe(editor) + }) + + it('still blurs the self-keyed grids it always covered', () => { + mount('
') + const grid = document.querySelector('[data-zen-db-grid]')! + grid.focus() + releaseSelfKeyedSurfaceFocus() + expect(document.activeElement).toBe(document.body) + }) + + it('leaves focus alone outside those surfaces', () => { + mount('') + const button = document.getElementById('b')! + button.focus() + releaseSelfKeyedSurfaceFocus() + expect(document.activeElement).toBe(button) + }) +}) + +describe('EXCALIDRAW_SURFACE', () => { + it('is not one of the surfaces VimNav yields to outright, so the leader tap, gt/gT and Ctrl+W keep working in a drawing', () => { + expect(SELF_KEYED_SURFACES.split(', ')).not.toContain(EXCALIDRAW_SURFACE) + }) +}) diff --git a/packages/app-core/src/lib/self-keyed-surfaces.ts b/packages/app-core/src/lib/self-keyed-surfaces.ts index 5305a50b..eded9841 100644 --- a/packages/app-core/src/lib/self-keyed-surfaces.ts +++ b/packages/app-core/src/lib/self-keyed-surfaces.ts @@ -32,15 +32,31 @@ export const SELF_KEYED_SURFACES = [ '[data-atlas-view]' ].join(', ') -/** Blur the active element when it sits inside a self-keyed surface, so keys - * follow the store's focused panel instead of the surface's own handler. - * An interactive control inside the surface (a cell editor mid-edit, a - * header button) keeps focus: blurring it commits or cancels the user's - * edit, the exact yank DatabaseTableView's claimFocus refuses in the other - * direction. The handoff only needs the surface's own grid element blurred. */ +/** + * The Excalidraw canvas is deliberately NOT in the list above. VimNav's global + * bindings are meant to work from inside a drawing: the leader on a Space tap + * (#309), gt/gT between buffers, the Ctrl+W pane prefix. So VimNav lets those + * run first and yields only afterwards, at the point where it would otherwise + * route the key into a panel (#721: with the sidebar open, Escape went to the + * sidebar's "back to editor" instead of leaving the Arrow tool). What the + * canvas shares with the list is the handoff rule below: giving the keyboard + * to the sidebar must take DOM focus away from it too. + */ +export const EXCALIDRAW_SURFACE = '[data-excalidraw-view]' + +/** Every surface that keeps the keys while it holds DOM focus. */ +const FOCUS_HANDOFF_SURFACES = `${SELF_KEYED_SURFACES}, ${EXCALIDRAW_SURFACE}` + +/** Blur the active element when it sits inside a surface that keeps its own + * keys, so keys follow the store's focused panel instead of the surface's own + * handler. An interactive control inside the surface (a cell editor mid-edit, + * a header button, Excalidraw's text editor) keeps focus: blurring it commits + * or cancels the user's edit, the exact yank DatabaseTableView's claimFocus + * refuses in the other direction. The handoff only needs the surface's own + * container blurred. */ export function releaseSelfKeyedSurfaceFocus(): void { const active = document.activeElement - if (!(active instanceof HTMLElement) || !active.closest(SELF_KEYED_SURFACES)) return + if (!(active instanceof HTMLElement) || !active.closest(FOCUS_HANDOFF_SURFACES)) return if (active.closest('input, textarea, button, [contenteditable="true"]')) return active.blur() } diff --git a/packages/app-core/src/lib/standalone-links.test.ts b/packages/app-core/src/lib/standalone-links.test.ts new file mode 100644 index 00000000..e7d1b1d9 --- /dev/null +++ b/packages/app-core/src/lib/standalone-links.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { standaloneLinkForAnchor, standaloneLinkForEditorTarget } from './standalone-links' + +function anchor(href: string, wikilink?: string): HTMLAnchorElement { + const a = document.createElement('a') + a.setAttribute('href', href) + if (wikilink !== undefined) a.dataset.wikilink = wikilink + return a +} + +describe('standaloneLinkForAnchor', () => { + it('sends a wikilink by the target the pipeline stamped on it', () => { + expect(standaloneLinkForAnchor(anchor('zen://note/t00-converter-ts', 't00-converter-ts'))).toEqual({ + action: 'host', + link: { kind: 'wikilink', target: 't00-converter-ts' } + }) + }) + + it('sends relative and absolute hrefs to the host', () => { + expect(standaloneLinkForAnchor(anchor('../../README.md'))).toEqual({ + action: 'host', + link: { kind: 'href', href: '../../README.md' } + }) + expect(standaloneLinkForAnchor(anchor('graphify-out/GRAPH_REPORT.md'))).toEqual({ + action: 'host', + link: { kind: 'href', href: 'graphify-out/GRAPH_REPORT.md' } + }) + expect(standaloneLinkForAnchor(anchor('/tmp/spec.pdf'))).toEqual({ + action: 'host', + link: { kind: 'href', href: '/tmp/spec.pdf' } + }) + }) + + it('keeps web and mail links for the browser', () => { + expect(standaloneLinkForAnchor(anchor('https://example.com/x.md'))).toEqual({ + action: 'browser', + url: 'https://example.com/x.md' + }) + expect(standaloneLinkForAnchor(anchor('mailto:a@b.c'))).toEqual({ action: 'browser', url: 'mailto:a@b.c' }) + }) + + it('leaves in-page anchors and app asset URLs alone', () => { + expect(standaloneLinkForAnchor(anchor('#usage'))).toBeNull() + expect(standaloneLinkForAnchor(anchor(''))).toBeNull() + expect(standaloneLinkForAnchor(anchor('zen-asset://local/x.png'))).toBeNull() + }) +}) + +describe('standaloneLinkForEditorTarget', () => { + it('tells a wikilink from a markdown href by the source it came from', () => { + expect(standaloneLinkForEditorTarget('[[topic-name]]', 'topic-name')).toEqual({ + action: 'host', + link: { kind: 'wikilink', target: 'topic-name' } + }) + expect(standaloneLinkForEditorTarget('[README](../../README.md)', '../../README.md')).toEqual({ + action: 'host', + link: { kind: 'href', href: '../../README.md' } + }) + expect(standaloneLinkForEditorTarget('https://example.com', 'https://example.com')).toEqual({ + action: 'browser', + url: 'https://example.com' + }) + }) +}) diff --git a/packages/app-core/src/lib/standalone-links.ts b/packages/app-core/src/lib/standalone-links.ts new file mode 100644 index 00000000..6d38d041 --- /dev/null +++ b/packages/app-core/src/lib/standalone-links.ts @@ -0,0 +1,46 @@ +/** + * Which links a standalone external-file window hands to the host. + * + * That window has no vault, so the app's resolvers (note index, vault root) + * have nothing to work with; the host resolves against the file's own + * directory instead (#626). This module decides what is worth sending: a + * `[[wikilink]]` or a local href goes to the host, a web or mail link opens + * in the browser as everywhere else, and an in-page anchor stays with the + * browser's own scrolling. + */ +import type { ExternalFileLink } from '@shared/ipc' +import { externalLinkUrl } from './internal-links' + +export type StandaloneLinkAction = + | { action: 'host'; link: ExternalFileLink } + | { action: 'browser'; url: string } + | null + +/** + * A preview anchor: a wikilink carries the target the markdown pipeline put + * on it; anything else is judged by its href. + */ +export function standaloneLinkForAnchor(anchor: HTMLAnchorElement): StandaloneLinkAction { + const wikilink = anchor.dataset.wikilink + if (wikilink !== undefined) return { action: 'host', link: { kind: 'wikilink', target: wikilink } } + return standaloneLinkForHref(anchor.getAttribute('href') ?? '') +} + +/** A link target under the editor cursor: `[[…]]` source names a wikilink, + * everything else is a Markdown href or a bare URL. */ +export function standaloneLinkForEditorTarget(source: string, target: string): StandaloneLinkAction { + if (source.startsWith('[[')) return { action: 'host', link: { kind: 'wikilink', target } } + return standaloneLinkForHref(target) +} + +function standaloneLinkForHref(rawHref: string): StandaloneLinkAction { + const href = rawHref.trim() + if (!href || href.startsWith('#')) return null + const web = externalLinkUrl(href) + if (web) return { action: 'browser', url: web } + if (/^(mailto|tel):/i.test(href)) return { action: 'browser', url: href } + // A scheme the app reserves for its own assets is not a file the host can + // find from this directory. + if (/^zen(-[a-z]+)?:/i.test(href)) return null + return { action: 'host', link: { kind: 'href', href } } +} diff --git a/packages/app-core/src/lib/trash-note.ts b/packages/app-core/src/lib/trash-note.ts index 69576afe..b02c4cc4 100644 --- a/packages/app-core/src/lib/trash-note.ts +++ b/packages/app-core/src/lib/trash-note.ts @@ -39,3 +39,22 @@ export async function moveNoteToTrash( return null } } + +/** + * Delete a note for good and say so when that could not happen, the same + * contract as moveNoteToTrash. Returns true when the file is gone. + */ +export async function deleteNotePermanently(path: string): Promise { + try { + await window.zen.deleteNote(path) + return true + } catch (err) { + useToastStore + .getState() + .addToast( + `Could not delete: ${humanIpcError(err, 'the note could not be deleted.')}`, + 'error' + ) + return false + } +} diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 0126973e..0f879ac5 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -2036,3 +2036,161 @@ describe('flushDirtyNotes drains queued task writes (#503)', () => { expect(disk).toBe('- [x] alpha') }) }) + +describe('deleteActivePermanently (#712)', () => { + const TRASHED = 'trash/Old idea.md' + function trashedNote() { + return { ...makeNote('gone soon', TRASHED), folder: 'trash' as const } + } + + it('deletes the trashed note for good on confirm and drops its tab and buffer', async () => { + const deleteNote = vi.fn().mockResolvedValue(undefined) + installZen({ deleteNote, listNotes: vi.fn().mockResolvedValue([]) }) + const { useStore } = await loadStore() + const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') + const note = trashedNote() + useStore.setState({ + notes: [note], + selectedPath: TRASHED, + activeNote: note, + noteContents: { [TRASHED]: note } + }) + + const p = useStore.getState().deleteActivePermanently() + const req = getConfirmRequest() + expect(req?.options.title).toBe('Delete "Old idea" permanently?') + expect(req?.options.confirmLabel).toBe('Delete permanently') + expect(req?.options.danger).toBe(true) + settleConfirmRequest(req!, true) + await p + + expect(deleteNote).toHaveBeenCalledWith(TRASHED) + expect(useStore.getState().noteContents[TRASHED]).toBeUndefined() + expect(useStore.getState().selectedPath).not.toBe(TRASHED) + }) + + it('does nothing when the confirmation is declined', async () => { + const deleteNote = vi.fn().mockResolvedValue(undefined) + installZen({ deleteNote }) + const { useStore } = await loadStore() + const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') + const note = trashedNote() + useStore.setState({ notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) + + const p = useStore.getState().deleteActivePermanently() + settleConfirmRequest(getConfirmRequest()!, false) + await p + + expect(deleteNote).not.toHaveBeenCalled() + expect(useStore.getState().selectedPath).toBe(TRASHED) + expect(useStore.getState().noteContents[TRASHED]).toBeDefined() + }) + + it('keeps the note open and says so when the host refuses', async () => { + const deleteNote = vi.fn().mockRejectedValue(new Error('EACCES')) + installZen({ deleteNote }) + const { useStore } = await loadStore() + const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') + const { useToastStore } = await import('./lib/toast') + const note = trashedNote() + useStore.setState({ notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) + + const p = useStore.getState().deleteActivePermanently() + settleConfirmRequest(getConfirmRequest()!, true) + await p + + expect(deleteNote).toHaveBeenCalledWith(TRASHED) + expect(useStore.getState().selectedPath).toBe(TRASHED) + expect(useToastStore.getState().toasts.some((t) => /Could not delete/.test(t.message))).toBe(true) + }) + + it('is a no-op with no active note', async () => { + const deleteNote = vi.fn().mockResolvedValue(undefined) + installZen({ deleteNote }) + const { useStore } = await loadStore() + useStore.setState({ selectedPath: null, activeNote: null }) + await useStore.getState().deleteActivePermanently() + expect(deleteNote).not.toHaveBeenCalled() + }) +}) + +describe('renaming the open note while the watcher reports the move (#713)', () => { + const OLD = 'inbox/Hello.md' + const NEW = 'inbox/Hello again.md' + const oldNote = { ...makeNote('# Hello\n\nplain note', OLD) } + const newNote = { ...makeNote('# Hello\n\nplain note', NEW) } + + function installRenameZen(renameNote: ReturnType, listNotes: ReturnType) { + installZen({ + renameNote, + listNotes, + readNote: vi.fn().mockImplementation(async (path: string) => (path === NEW ? newNote : oldNote)), + writeNote: vi.fn().mockResolvedValue(undefined), + setVaultSettings: vi.fn().mockImplementation(async (settings: unknown) => settings) + }) + } + + it('keeps the tab when the unlink of the old path lands before the rename reply, then moves it', async () => { + let settle: (meta: typeof newNote) => void = () => {} + const renameNote = vi.fn().mockImplementation(() => new Promise((resolve) => { settle = resolve })) + // The listing the watcher-triggered refresh sees mid-rename: the old file + // is gone and the new one is not indexed yet. + const listNotes = vi.fn().mockResolvedValue([]) + installRenameZen(renameNote, listNotes) + const { useStore } = await loadStore() + useStore.setState({ notes: [oldNote], syncTitleHeadingOnRename: false }) + await useStore.getState().selectNote(OLD) + expect(useStore.getState().selectedPath).toBe(OLD) + + const rename = useStore.getState().renameActive('Hello again') + await vi.waitFor(() => expect(renameNote).toHaveBeenCalledWith(OLD, 'Hello again')) + + // inotify: the move is an unlink of the old path and an add of the new + // one, both delivered before the host has answered the rename. + await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + await useStore.getState().applyChange({ kind: 'add', path: NEW, folder: 'inbox' }) + await useStore.getState().refreshNotes() + expect(useStore.getState().selectedPath).toBe(OLD) + expect(JSON.stringify(useStore.getState().paneLayout)).toContain(OLD) + + listNotes.mockResolvedValue([newNote]) + settle(newNote) + await rename + + expect(useStore.getState().selectedPath).toBe(NEW) + expect(JSON.stringify(useStore.getState().paneLayout)).toContain(NEW) + expect(JSON.stringify(useStore.getState().paneLayout)).not.toContain(OLD) + expect(useStore.getState().noteContents[NEW]).toBeDefined() + expect(useStore.getState().noteContents[OLD]).toBeUndefined() + }) + + it('still closes a note that was really deleted', async () => { + const renameNote = vi.fn() + installRenameZen(renameNote, vi.fn().mockResolvedValue([oldNote])) + const { useStore } = await loadStore() + useStore.setState({ notes: [oldNote] }) + await useStore.getState().selectNote(OLD) + + await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + + expect(JSON.stringify(useStore.getState().paneLayout)).not.toContain(OLD) + expect(useStore.getState().noteContents[OLD]).toBeUndefined() + expect(renameNote).not.toHaveBeenCalled() + }) + + it('forgets the rename once the host has refused it, so a later unlink counts again', async () => { + const renameNote = vi.fn().mockRejectedValue(new Error('EEXIST')) + installRenameZen(renameNote, vi.fn().mockResolvedValue([oldNote])) + const { useStore } = await loadStore() + useStore.setState({ notes: [oldNote], syncTitleHeadingOnRename: false }) + await useStore.getState().selectNote(OLD) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await useStore.getState().renameActive('Taken') + expect(useStore.getState().selectedPath).toBe(OLD) + + await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + expect(JSON.stringify(useStore.getState().paneLayout)).not.toContain(OLD) + }) +}) + diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 473f6606..31572c89 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -104,9 +104,9 @@ import type { Override } from '@shared/overrides' import type { CustomCodeLanguage } from '@shared/custom-code-languages' import { customCodeLanguageRegistry } from './lib/custom-code-languages' import { formatMarkdown } from './lib/format-markdown' -import { confirmMoveToTrash } from './lib/confirm-trash' +import { confirmDeletePermanently, confirmMoveToTrash } from './lib/confirm-trash' import { humanIpcError } from './lib/ipc-error' -import { moveNoteToTrash } from './lib/trash-note' +import { deleteNotePermanently, moveNoteToTrash } from './lib/trash-note' import { confirmApp } from './lib/confirm-requests' import { pickServerDirectoryApp } from './lib/server-directory-picker-requests' import { promptApp } from './lib/prompt-requests' @@ -3253,6 +3253,13 @@ interface Store { /** Move any note to the Trash the way trashing the active note does (confirm, * move, drop its tabs and buffers). Resolves true when the note moved. */ trashNote: (path: string) => Promise + /** Delete the active note for good. Offered where the note is already in + * the Trash, where Move to Trash would be a no-op with a misleading + * prompt (#712). */ + deleteActivePermanently: () => Promise + /** Delete any note for good (confirm, delete, drop its tabs and buffers). + * Resolves true when the file is gone. */ + deleteNotePermanently: (path: string) => Promise restoreActive: () => Promise archiveActive: () => Promise unarchiveActive: () => Promise @@ -3582,6 +3589,20 @@ const PATH_SAVE_DEBOUNCE_MS = 350 */ const lastWrittenByPath = new Map() +/** + * Old paths of renames the host has not answered yet. A rename is a move on + * disk, and the watcher reports a move as an unlink of the old path followed + * by an add of the new one; on Linux (inotify) the unlink lands in the + * renderer within milliseconds, while the reply to the rename waits for the + * host to rewrite every inbound wikilink first. So the unlink arrived first, + * closed the note's tab as a deletion, and the reply then renamed a tab that + * was no longer there: the renamed note vanished from the editor (#713). + * macOS delivers file events late enough that the reply usually won, which + * is why the race only showed on Linux. While a path is in this set, its + * unlink is the rename's own echo and its tab is kept through refreshes. + */ +const renamesInFlight = new Set() + // --- CSV database debounced persistence + echo suppression --- const DATABASE_SAVE_DEBOUNCE_MS = 400 const databaseSaveTimers = new Map>() @@ -4090,6 +4111,29 @@ async function prefetchInitialVisibleNotes(state: Store): Promise { scheduleBackgroundPrefetch() } +/** + * The workspace with `path` gone: its tabs closed, its buffer and dirty flag + * dropped, the reference pane unpinned if it was showing it. The one shape + * trashing, archiving and deleting a note all leave behind. + */ +function withoutNoteInWorkspace(s: Store, path: string): Partial { + const nextLayout = rewritePathsInTree(s.paneLayout, (p) => (p === path ? null : p)) + const ensured = ensureActivePane(nextLayout, s.activePaneId) + const { [path]: _drop, ...contents } = s.noteContents + const { [path]: _d, ...dirty } = s.noteDirty + void _drop + void _d + return { + paneLayout: ensured.layout, + activePaneId: ensured.activePaneId, + noteContents: contents, + noteDirty: dirty, + pendingJumpLocation: null, + pinnedRefPath: s.pinnedRefPath === path ? null : s.pinnedRefPath, + ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) + } +} + export const useStore = create((set, get) => { const selectNoteImpl = async ( relPath: string | null, @@ -6038,6 +6082,7 @@ export const useStore = create((set, get) => { const keep = (path: string): boolean => existingPaths.has(path) || isWorkspaceVirtualTabPath(path) || + renamesInFlight.has(path) || (path === s.selectedPath && (s.noteContents[path] !== undefined || s.noteDirty[path] === true)) const prunedLayout = rewritePathsInTree(s.paneLayout, (path) => @@ -6178,6 +6223,9 @@ export const useStore = create((set, get) => { // The live feed's unlink handling, shared with the resync path below: // a deleted note's tab closes wherever it is open. const closeUnlinkedNote = (notePath: string): void => { + // The unlink half of a rename we asked for: the reply will move the + // tab to the new path (see renamesInFlight). + if (renamesInFlight.has(notePath)) return set((s) => { const nextLayout = rewritePathsInTree(s.paneLayout, (p) => p === notePath ? null : p @@ -6620,8 +6668,14 @@ export const useStore = create((set, get) => { if (Object.values(get().noteDirty).some(Boolean)) { throw new Error('Could not rename while notes still have unsaved changes') } - const meta = await window.zen.renameNote(oldPath, nextTitle) - set((s) => renameNoteState(s, oldPath, meta)) + renamesInFlight.add(oldPath) + let meta: NoteMeta + try { + meta = await window.zen.renameNote(oldPath, nextTitle) + set((s) => renameNoteState(s, oldPath, meta)) + } finally { + renamesInFlight.delete(oldPath) + } await get().applyFavorites( rewriteFavoriteNotePath(get().vaultSettings.favorites, oldPath, meta.path) ) @@ -6804,26 +6858,23 @@ export const useStore = create((set, get) => { if (!(await moveNoteToTrash(path, { temporarySession: state.vault?.temporary === true }))) { return false } - { - set((s) => { - const nextLayout = rewritePathsInTree(s.paneLayout, (p) => (p === path ? null : p)) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const { [path]: _drop, ...contents } = s.noteContents - const { [path]: _d, ...dirty } = s.noteDirty - void _drop - void _d - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - pendingJumpLocation: null, - pinnedRefPath: s.pinnedRefPath === path ? null : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) - await get().refreshNotes() - } + set((s) => withoutNoteInWorkspace(s, path)) + await get().refreshNotes() + return true + }, + + deleteActivePermanently: async () => { + const path = get().selectedPath + if (!path) return + await get().deleteNotePermanently(path) + }, + + deleteNotePermanently: async (path) => { + const title = get().notes.find((note) => note.path === path)?.title + if (!(await confirmDeletePermanently(title))) return false + if (!(await deleteNotePermanently(path))) return false + set((s) => withoutNoteInWorkspace(s, path)) + await get().refreshNotes() return true }, @@ -6869,23 +6920,7 @@ export const useStore = create((set, get) => { if (!path) return if (!(await get().confirmArchiveNotes([path]))) return await window.zen.archiveNote(path) - set((s) => { - const nextLayout = rewritePathsInTree(s.paneLayout, (p) => (p === path ? null : p)) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const { [path]: _drop, ...contents } = s.noteContents - const { [path]: _d, ...dirty } = s.noteDirty - void _drop - void _d - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - pendingJumpLocation: null, - pinnedRefPath: s.pinnedRefPath === path ? null : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) + set((s) => withoutNoteInWorkspace(s, path)) await get().refreshNotes() }, diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 5fc12e67..9500b17c 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.41.0", + "version": "2.42.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index c82241f4..2417f065 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -4,6 +4,7 @@ import type { CliInstallStatus, DeletedAsset, ExternalFileContent, + ExternalFileLink, FolderEntry, ImportedAsset, LinkMetadata, @@ -370,6 +371,15 @@ export interface ZenBridge { writeExternalFile(body: string): Promise /** Move the current standalone editor window's file into the active vault. */ moveExternalFileToVault(): Promise + /** + * Follow a link from the current standalone editor window's file. The host + * resolves it against the file's directory (a relative href, or a wikilink + * by name within that directory's tree) and opens a markdown target the way + * Finder would (its vault, else another standalone window) or any other + * file with the OS. Resolves `ok: false` with a reason when nothing on disk + * matches. Desktop only. + */ + followExternalFileLink(link: ExternalFileLink): Promise<{ ok: boolean; error?: string }> /** * Open a markdown file from an absolute OS path — as a note when it lives * inside a known vault, otherwise a standalone external-file window. The diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 26242d74..cfd2ae1b 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -151,6 +151,7 @@ export const IPC = { APP_READ_EXTERNAL_FILE: 'app:read-external-file', APP_WRITE_EXTERNAL_FILE: 'app:write-external-file', APP_MOVE_EXTERNAL_FILE_TO_VAULT: 'app:move-external-file-to-vault', + APP_FOLLOW_EXTERNAL_FILE_LINK: 'app:follow-external-file-link', APP_OPEN_MARKDOWN_FILE: 'app:open-markdown-file', APP_OPEN_FILE_DIALOG: 'app:open-file-dialog', APP_OPEN_FOLDER_TEMPORARY: 'app:open-folder-temporary', @@ -723,6 +724,15 @@ export interface MoveExternalFileResult { relPath: string } +/** + * A link clicked inside a standalone external-file window, for the host to + * resolve against that file's own directory (#626): a `[[wikilink]]` target + * (alias and anchors may still be attached) or a Markdown href. + */ +export type ExternalFileLink = + | { kind: 'wikilink'; target: string } + | { kind: 'href'; href: string } + export interface LocalVaultEntry extends VaultInfo { lastOpenedAt: number } diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index a924627f..d1bcf59b 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.41.0", + "version": "2.42.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 2365b13e..70a22451 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.41.0", + "version": "2.42.0", "type": "module", "exports": { ".": "./src/index.ts"