From fa16dfdf20cf69ba20c9d5d42f7ee5791fb70397 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 08:09:45 -0500 Subject: [PATCH 1/7] Fix(desktop): an update installed under a running ZenNotes asks for a restart Upgrade ZenNotes with a package manager while it is open, and the next window the old process opened came up as a page of raw JavaScript. That is the "white screen of death" unyanda reported on Discord after 2.41.0 landed on Arch, and the install was never at fault: the published archive is intact, offsets and integrity hashes included. Electron parses the app.asar header once per process and keeps it for that process's lifetime (asar_util.cc, GetOrCreateAsarArchive), but every file:// load reopens the archive on disk by path and reads at the cached offset (asar_url_loader.cc). pacman, dpkg, rpm and a Homebrew cask all put a new archive at the same path, so from that moment the old process reads the new file at the old offsets. Replaying the 2.40.0 header's index.html entry against the 2.41.0 archive produces the text in the screenshot byte for byte: the tail of the zh-HK Excalidraw locale chunk and the head of zh-TW, rendered as HTML. The same swap reproduces on macOS with the shipped 2.41.0 build. Nothing in a process can be repaired once its header is stale, so the fix does not try. A packaged ZenNotes records its archive's identity and a digest of the raw header at boot (installed-bundle.ts), reading the file with process.noAsar because Electron's fs hooks present an .asar path as a directory and would answer from the same stale header. The status is checked when a window regains focus, on a 30 second poll, and right before any renderer load or reload; once the archive on disk is a different layout, that status is sticky. All five renderer loads now go through one loadRenderer, which refuses a stale load: the window shows a plain explanation page instead of whatever bytes sit at the old offsets, and a sheet names the installed and running versions with "Restart ZenNotes" and "Not Now". Declining silences the passive checks; only an action that would actually load through the stale header asks again. View > Reload and Force Reload are explicit items for the same reason, since the stock roles reload straight through the stale header. Three edges are deliberate. A byte-identical reinstall keeps every offset valid, so the header digest decides, not the inode. The restart is offered, never taken: the user may be mid-sentence. And the sheet only ever attaches to a visible window. A parentless macOS alert runs a nested modal session, and anything ending that session from outside comes back as NSModalResponseCancel, which is 0, which is the Restart button: the app relaunched itself with nobody at the keyboard during verification. AppImage, Nix and Windows installs never rewrite the running path and are never asked. Verified on the packaged macOS app over CDP with isolated userData and config dirs: the shipped build renders garbage in a window opened after the swap, the fixed build renders the explanation page, logs the refusal, raises the sheet and keeps waiting, and the poll caught a swap 28 seconds after it happened. Not driven by automation: the Restart click (the same app.relaunch plus app.quit the updater uses) and the Reload item, which CDP key events cannot reach on macOS. How to test locally: npm run pack, launch dist/mac-arm64/ZenNotes.app/Contents/MacOS/ZenNotes with ZEN_PERF=1, ZENNOTES_USER_DATA_PATH and ZENNOTES_CONFIG_DIR pointing at scratch folders, and --remote-debugging-port=9333. Extract the app's own app.asar in a scratch directory, change the version in its package.json, repack with npx asar pack, and copy it over Contents/Resources/app.asar while the app runs. Press Cmd+R, or open a note by launching the binary a second time with a .md path. Before: a window of minified JavaScript with an empty title. After: "ZenNotes Was Updated", the sheet names both versions, and the main process logs "[install] app.asar was replaced on disk". --- apps/desktop/src/main/index.ts | 231 ++++++++++++--- .../desktop/src/main/installed-bundle.test.ts | 249 ++++++++++++++++ apps/desktop/src/main/installed-bundle.ts | 276 ++++++++++++++++++ 3 files changed, 711 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/main/installed-bundle.test.ts create mode 100644 apps/desktop/src/main/installed-bundle.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1b25fdff..49c8cb26 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, @@ -289,6 +294,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 +940,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 +1035,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 +1600,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 +2352,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.", ); } @@ -4426,15 +4574,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 +4672,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 +4855,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 +5313,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(""); +} From 8444bb386ed971e1cd9b8e369981c21e8a371ad6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 08:29:51 -0500 Subject: [PATCH 2/7] Fix(excalidraw): Escape leaves the Arrow tool while the sidebar is open In an Excalidraw drawing with Vim mode on, pressing Escape while the sidebar was open did nothing: the Arrow tool, or whichever tool was active, stayed selected, and the same key worked the moment the sidebar was hidden or Zen mode was on. That is #721, and it only ever affected Vim mode. VimNav's window-level key router runs in the capture phase and, once no text editor holds focus, hands every key it does not recognise to the open sidebar, where Escape means "back to the editor" and is consumed with stopImmediatePropagation. Excalidraw binds its keys on its own container in the bubble phase, so it never saw the Escape at all: a document-capture probe counted zero arrivals while the toolbar stayed on Arrow. The obvious fix, adding the canvas to SELF_KEYED_SURFACES so VimNav ignores it outright, is wrong on purpose: the leader on a Space tap (#309), gt/gT between buffers and the Ctrl+W pane prefix are meant to work from inside a drawing, and all three live in the same handler. So the router lets its global bindings run and yields to a DOM-focused canvas right before it would route into a panel. A canvas holding focus owns Escape, the tool letters, arrow nudges and Delete. The handoff in the other direction is covered too. Giving the keyboard to the sidebar now blurs the canvas, the rule the database grid already follows, so Ctrl+W h from a drawing still lands in the sidebar and j/k drive it. And clicking into a drawing claims focusedPanel the way the editor and the archive list do, so the sidebar stops painting its cursor on the row that opened the drawing and pane navigation measures from the right panel. Verified over CDP on the built renderer with a seeded vault: with the sidebar open, `a` then Escape went from Arrow to Selection only after the change; Space tap then `e` inside the canvas still toggles the sidebar; Ctrl+W h from the drawing blurs the canvas, lights the sidebar cursor, and j/k move it exactly as they do from a markdown note. Vim mode off was unaffected before and after. How to test locally: npm run dev, Vim mode on, sidebar open, open a .excalidraw note, click the canvas, press `a`, then Escape. Before: the Arrow tool stays selected. After: the toolbar is back on Selection. Closes #721 --- .../src/components/ExcalidrawView.tsx | 15 ++++- packages/app-core/src/components/VimNav.tsx | 19 +++++-- .../src/lib/self-keyed-surfaces.test.ts | 57 +++++++++++++++++++ .../app-core/src/lib/self-keyed-surfaces.ts | 30 +++++++--- 4 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 packages/app-core/src/lib/self-keyed-surfaces.test.ts 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')} + > { + 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() } From 2c0fcf0f07806038fa6d2e49f2bfa8d5788e9b68 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 08:40:38 -0500 Subject: [PATCH 3/7] Fix(editor): a trashed note's bin icon deletes for good Open a note from the Trash view and the editor header already knew where it was: Archive had become Restore. The bin icon next to it had not been told. It still ran Move to Trash, so pressing it asked whether to move to the Trash a note that was already there, and a confirmed yes moved nothing and closed the tab, which is not what the prompt promised. That is #712. The command palette was already right (it hides Move to Trash for a trashed note); only the header lagged. In the Trash, the bin icon now reads "Delete permanently" and does exactly that: the same question the Trash view's own row action asks, then the file is removed and its tab closed. Outside the Trash nothing changes. The palette gains Delete Note Permanently for a trashed note, so the action is reachable without the mouse. Two things are shared on purpose. The wording for deleting a note for good now lives in one confirmDeletePermanently, used by the Trash view, the header and the palette, so three prompts cannot drift apart. And the workspace cleanup that trashing, archiving and deleting all need (close the tabs, drop the buffer and dirty flag, unpin the reference pane) was two hand copies in the store; it is now withoutNoteInWorkspace and all three go through it. A refused delete keeps the note open and says so in a toast, mirroring Move to Trash. Verified over CDP on the built renderer: trash a note from the header, open it from Trash, the header shows Restore and Delete permanently, the prompt reads 'Delete "Doomed" permanently? This cannot be undone.', and confirming removes the file from the vault's trash folder, empties the Trash list and closes the tab. Both docs surfaces describe the header's two actions for a trashed note. How to test locally: npm run dev, open any note, press the bin icon and confirm, then open Trash from the sidebar and click the note. Before: the header's bin icon is titled "Move to trash" and its prompt offers to move the note to the Trash. After: it is titled "Delete permanently", the prompt says so, and confirming removes the note from the Trash list and closes the tab. Closes #712 --- .../app-core/src/components/EditorPane.tsx | 16 +++- .../app-core/src/components/TrashView.tsx | 9 +- packages/app-core/src/lib/commands.test.ts | 44 +++++++++ packages/app-core/src/lib/commands.ts | 8 ++ packages/app-core/src/lib/confirm-trash.ts | 14 +++ packages/app-core/src/lib/help.ts | 2 +- packages/app-core/src/lib/trash-note.ts | 19 ++++ packages/app-core/src/store.test.ts | 78 ++++++++++++++++ packages/app-core/src/store.ts | 89 +++++++++++-------- 9 files changed, 229 insertions(+), 50 deletions(-) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 7bbc1a04..7922d2dc 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -852,6 +852,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 +3361,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 +3389,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { calendarOpen, toggleCalendarPanel, trashActive, + deleteActivePermanently, archiveActive, restoreActive, unarchiveActive, 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/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..946f5c85 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', 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..d5f1219b 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -2036,3 +2036,81 @@ 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() + }) +}) + diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 473f6606..22c09017 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 @@ -4090,6 +4097,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, @@ -6804,26 +6834,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 +6896,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() }, From 4f13af1a1eba3109457d5a1bb80cdf76166c7d4f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 08:51:22 -0500 Subject: [PATCH 4/7] Fix(editor): a renamed note stays open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the note you are editing, from the command palette or anywhere else, and on Linux it closed the moment you confirmed: the tab vanished and the editor went blank while the file itself was renamed correctly. That is #713. A rename is a move on disk, and the vault watcher reports a move as an unlink of the old path followed by an add of the new one. On Linux (inotify) that unlink reaches the renderer within milliseconds, but the reply to the rename waits until main has done fs.rename, moved the note's comments and rewritten every inbound [[wikilink]] in the vault. So the unlink arrived first, applyChange read it as a deletion and closed the tab, and the reply then renamed a tab that was no longer there. macOS delivers file events late enough that the reply usually won, which is why this was a Linux report; it reproduced on macOS the moment the rename had 400 links to rewrite, with the tab gone 300ms after Enter. The store now remembers the renames it has asked for: renamesInFlight, next to lastWrittenByPath, which is the same idea for our own writes. renameNote adds the old path before the IPC call and removes it in a finally. While a path is in flight, closeUnlinkedNote treats its unlink as the rename's own echo (both the live branch and the resync sweep), and the keep rule in refreshNotes holds its tab instead of pruning it, so the reply finds the tab where it left it and moves it to the new name. Every other unlink still closes its note, including one that arrives after a refused rename. Three tests pin the event order: unlink and add delivered before the reply, then the reply moving the tab; a genuine deletion still closing; a refused rename forgetting its path. The first fails with the guard disabled. Verified live on the built renderer with 400 inbound links: the tab stays open through the rename and ends under the new name with its heading synced. How to test locally: npm run dev on Linux, or on any platform give a note a few hundred inbound [[links]], open it, run Rename Note… from the command palette, type a new name and press Enter. Before: the tab disappears and the editor goes blank; the renamed file is in the sidebar. After: the same tab stays open under its new name. Closes #713 --- packages/app-core/src/store.test.ts | 80 +++++++++++++++++++++++++++++ packages/app-core/src/store.ts | 28 +++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index d5f1219b..0f879ac5 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -2114,3 +2114,83 @@ describe('deleteActivePermanently (#712)', () => { }) }) +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 22c09017..31572c89 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3589,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>() @@ -6068,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) => @@ -6208,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 @@ -6650,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) ) From be3841913e363c6dfe7f145c9477cc6328657544 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 09:09:14 -0500 Subject: [PATCH 5/7] Fix(desktop): links work in a note opened from Finder Open a markdown file that lives outside every vault (Finder "Open With", a double-click, `zn open `) and it comes up in the standalone "Not in a vault" window, where every link was dead: `[[topic]]` did nothing, `[README](../../README.md)` did nothing, in edit and preview alike, with no error anywhere. That is #626, and it held through 2.41.0. The app's link resolvers are all vault-bound. The preview looks a wikilink up in the vault's note index, resolves a relative link against a vault-relative note path, and ends in "open this note in the workspace"; the standalone window has none of those, and its own editor never had the main editor's link-follow handler at all. A loose file does have a directory, though, which is the base its author meant. So the window now hands links to the host. `resolveStandaloneLink` takes a relative or absolute href from the file's own folder, adding `.md` when a link leaves it off, and finds a `[[wikilink]]` by name in that folder or below it: four levels deep, dot-folders and node_modules skipped, the shallowest match wins, alias and anchors stripped, and Obsidian's path-like `[[sub/page]]` taken relative to the file. The IPC handler resolves against the path main already holds for that window, never one the renderer supplies, and routes a markdown target through the Finder opener, so it lands in its vault when it has one and in a standalone window of its own otherwise; any other file opens with the OS, and a miss comes back as a reason the window shows in its banner. In the renderer, `standaloneLinkForAnchor` reads the target the markdown pipeline stamps on a wikilink and judges everything else by href shape, keeping web and mail links in the browser and in-page anchors with the browser's scrolling. ExternalFileApp intercepts preview clicks in the capture phase, ahead of Preview's vault-bound handler, gives its editor the same mousedown rule as EditorPane (a plain click follows a rendered link in live preview, Cmd/Ctrl-click follows any link, #201) and maps `gd`. `pointerOverRange` moves out of EditorPane into lib/cm-pointer-range.ts so both editors share one copy. Deliberately left alone: relative images in that window's preview still resolve through the vault asset path and stay broken. Same shape of fix, separate change. Verified over CDP on the built app with the layout from the report (proj/README.md, proj/docs/wiki/index.md, pages beside and below it): in edit mode, plain and Cmd-clicks opened topic-name.md, the converter page through its alias, README.md two levels up and the report in a subfolder, each in its own window; preview mode did the same on a fresh launch; a link to a missing file explains itself in the banner. The resolver has 11 tests on a real temp tree, the classifier 5. How to test locally: outside any vault, make proj/README.md and proj/docs/wiki/index.md containing `[[topic]]`, `[README](../../README.md)` and `[report](out/REPORT.md)` with the targets present, open index.md from Finder or with the file as an argument, and click each link in edit and preview. Before: nothing happens. After: each target opens in its own window. Closes #626 --- apps/desktop/src/main/index.ts | 31 +++ .../desktop/src/main/standalone-links.test.ts | 159 ++++++++++++++++ apps/desktop/src/main/standalone-links.ts | 178 ++++++++++++++++++ apps/desktop/src/preload/index.ts | 3 + apps/web/src/bridge/http-bridge.ts | 6 + .../app-core/src/components/EditorPane.tsx | 24 +-- .../src/components/ExternalFileApp.tsx | 98 +++++++++- packages/app-core/src/lib/cm-pointer-range.ts | 24 +++ packages/app-core/src/lib/help.ts | 2 +- .../app-core/src/lib/standalone-links.test.ts | 65 +++++++ packages/app-core/src/lib/standalone-links.ts | 46 +++++ packages/bridge-contract/src/bridge.ts | 10 + packages/bridge-contract/src/ipc.ts | 10 + 13 files changed, 627 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/main/standalone-links.test.ts create mode 100644 apps/desktop/src/main/standalone-links.ts create mode 100644 packages/app-core/src/lib/cm-pointer-range.ts create mode 100644 packages/app-core/src/lib/standalone-links.test.ts create mode 100644 packages/app-core/src/lib/standalone-links.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 49c8cb26..9db6ac09 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -261,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); @@ -4252,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 => { 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/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/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 7922d2dc..4bee95da 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -68,6 +68,7 @@ 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, @@ -317,29 +318,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({ diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index e053cc98..195167a5 100644 --- a/packages/app-core/src/components/ExternalFileApp.tsx +++ b/packages/app-core/src/components/ExternalFileApp.tsx @@ -37,6 +37,13 @@ import { paperHighlight } from './FloatingNoteApp' import { editorTabSize } from '../lib/editor-tab-size' +import { linkRangeAtCursor } from '../lib/internal-links' +import { pointerOverRange } from '../lib/cm-pointer-range' +import { + standaloneLinkForAnchor, + standaloneLinkForEditorTarget, + type StandaloneLinkAction +} from '../lib/standalone-links' const SAVE_DEBOUNCE_MS = 350 const programmatic = Annotation.define() @@ -52,6 +59,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 +106,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) => { @@ -136,6 +184,16 @@ export function ExternalFileApp(): JSX.Element { ...historyKeymap, ...searchKeymap ]), + 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 +218,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 +290,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 +365,9 @@ export function ExternalFileApp(): JSX.Element {
- {moveError && ( + {(moveError || linkError) && (
- {moveError} + {moveError ?? linkError}
)} @@ -309,7 +375,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 +406,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 +431,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/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/help.ts b/packages/app-core/src/lib/help.ts index 946f5c85..5dd8ef49 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -492,7 +492,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { 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/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/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 } From 443a210486893c9e8c776833fe03743a8f8986c4 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 09:46:41 -0500 Subject: [PATCH 6/7] Fix(keymaps): app shortcuts win over Vim's Ctrl chords, and the exceptions are written down On Linux and Windows, where Mod is Ctrl, pressing Ctrl+P inside a Vim-mode editor moved the cursor up and the Search Notes palette never opened; Ctrl+N moved down and New Note Here never fired. Meanwhile Ctrl+F opened CodeMirror's find panel over Vim's page-forward while Ctrl+B paged back as Vim intended, and Ctrl+W did nothing visible with no word anywhere about why. That is #510, in three parts. The first is a bug. codemirror-vim aliases and to k and j and stops propagation of every key it handles, and the app listened for those two shortcuts in the window's bubble-phase handler, after the editor. Everything the app already wanted to win over Vim runs in the capture phase for exactly this reason (pane focus #124, pane modes #579, the inline-format chords, the jumplist #488); these two were the ones left behind. They move there now, under the same modal, recorder and autocomplete guards, with one carve-out so Search Notes keeps closing the palette it opened. The second is a gap in the docs. Ctrl+W has been Vim's pane prefix while a tab is open since 2.4.0, on purpose, with :q and :bd as the way to close a tab, and the help said "Mod+W closes the tab" without the caveat. The Global shortcuts help now states the rule: with Vim mode on and the editor focused, an app shortcut on a Ctrl chord wins over Vim's, except the chords Vim mode keeps, Ctrl+W (pane prefix), Ctrl+O and Ctrl+I (jumplist) and Ctrl+D and Ctrl+U (half page). The shortcut descriptions say the same where each applies. The third is a judgment call, made in Vim's favour. The find panel's Mod-f binding was the one motion chord the editor still took from Vim, and only because CodeMirror's search keymap happens to claim it. With Vim mode on and Mod resolving to Ctrl, vimAwareSearchKeymap drops it, so Ctrl+F pages forward like Ctrl+B and / searches, the Vim way; every other search binding stays. macOS keeps Cmd+F because it never collided, and Vim-off keeps the panel everywhere. Linux and Windows Vim users who reached the find panel with Ctrl+F now get page-forward there; the release notes say so. Verified live before and after, by stashing the fix and rebuilding. A browser user-agent override does not flip the app's Mod key (the app asks the host for its platform), so the Linux collision was staged on a Mac by rebinding the two shortcuts to Ctrl+P and Ctrl+N. Before: each Ctrl+P moved the cursor up a line and nothing opened, Ctrl+N created nothing. After: Ctrl+P opens the palette and a second press closes it, Ctrl+N creates the note with its title focused, Cmd+F still opens the find bar with Vim on. The Ctrl+F paging half is covered by the keymap unit tests, not driven live. How to test locally: on Linux or Windows, Vim mode on, click into a note, press Ctrl+P. Before: the cursor moves up. After: the search palette opens, and Ctrl+P again closes it. Then Ctrl+F. Before: the find bar. After: the view pages forward, and Ctrl+B pages back. Closes #510 --- packages/app-core/src/App.tsx | 63 +++++++++++++------ .../app-core/src/components/EditorPane.tsx | 5 +- .../src/components/ExternalFileApp.tsx | 5 +- .../src/components/FloatingNoteApp.tsx | 5 +- .../src/components/PinnedReferencePane.tsx | 5 +- .../src/components/QuickCaptureApp.tsx | 5 +- .../src/lib/cm-vim-default-keymap.test.ts | 26 +++++++- .../app-core/src/lib/cm-vim-default-keymap.ts | 18 ++++++ packages/app-core/src/lib/help.ts | 10 +-- packages/app-core/src/lib/keymaps.ts | 11 ++-- 10 files changed, 110 insertions(+), 43 deletions(-) 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 4bee95da..949372d2 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -63,7 +63,7 @@ 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' @@ -94,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' @@ -405,7 +404,7 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens indentWithTab, ...vimAwareDefaultKeymap(vimMode), ...historyKeymap, - ...searchKeymap, + ...vimAwareSearchKeymap(vimMode), ...completionKeymapForEditor ]) } diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index 195167a5..7d19779d 100644 --- a/packages/app-core/src/components/ExternalFileApp.tsx +++ b/packages/app-core/src/components/ExternalFileApp.tsx @@ -15,7 +15,7 @@ import { Annotation, Compartment, EditorState, type Transaction } from '@codemir import { EditorView, drawSelection, highlightActiveLine, keymap } 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' @@ -24,7 +24,6 @@ import { applyVimInsertEscape } from '../lib/vim-insert-escape' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' -import { searchKeymap } from '@codemirror/search' import type { ExternalFileContent } from '@shared/ipc' import { livePreviewPlugin } from '../lib/cm-live-preview' import { headingFolding } from '../lib/cm-heading-fold' @@ -182,7 +181,7 @@ export function ExternalFileApp(): JSX.Element { indentWithTab, ...vimAwareDefaultKeymap(prefs.vimMode), ...historyKeymap, - ...searchKeymap + ...vimAwareSearchKeymap(prefs.vimMode) ]), EditorView.domEventHandlers({ mousedown: (event, view) => { 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/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/help.ts b/packages/app-core/src/lib/help.ts index 5dd8ef49..cee68712 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -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,7 +488,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions — they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.) Both walk the same cycle, in the order the panels appear on screen: sidebar → note list → editor → connections → comments → outline → calendar, and back again.' }, { keys: '↑ / ↓ / Enter / Esc', action: 'Move inside a focused panel', detail: 'Once a panel has focus, the arrows move its row cursor, Home and End jump to the ends, Enter opens the row under the cursor, and Esc (or ←) returns focus to the editor. These work with Vim mode off; the single-key motions (j / k, gg / G) stay Vim-only.' }, { keys: 'Mod+.', action: 'Toggle Zen mode', detail: 'Hide or restore the app chrome so only the active editor, preview, or split view stays on screen.' }, - { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab.' }, + { 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.' }, 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, From a3232659083ad53db050560427a75bb1f4381a2b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 2 Sep 2026 10:10:32 -0500 Subject: [PATCH 7/7] chore(release): 2.42.0 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) 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/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/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/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/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"