diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ed2a228e..b264f2b5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.52.0", + "version": "2.53.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/cli/args.test.ts b/apps/desktop/src/cli/args.test.ts index dbf5da2f..9b45b2cf 100644 --- a/apps/desktop/src/cli/args.test.ts +++ b/apps/desktop/src/cli/args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getString, parse } from './args' +import { getBool, getString, parse, VALUELESS_FLAGS } from './args' // A markdown task passed to `zn capture "- [ ] task"` arrives as one token // that starts with `-`; it must stay positional, not be parsed as a flag. @@ -24,3 +24,43 @@ describe('cli args parse — leading-dash text', () => { expect(parse(['--', '--not-a-flag']).positionals).toEqual(['--not-a-flag']) }) }) + +// A switch written before a positional used to swallow it: `zn open +// --new-window ~/notes` parsed as new-window="~/notes" with no path (#815). +describe('cli args parse: switches never take the next token as a value', () => { + it('keeps the path after --new-window positional', () => { + const args = parse(['open', '--new-window', '/home/user/notes']) + expect(args.positionals).toEqual(['open', '/home/user/notes']) + expect(getBool(args, 'new-window')).toBe(true) + }) + + it('applies to every listed switch, before or after the positional', () => { + for (const name of VALUELESS_FLAGS) { + const before = parse([`--${name}`, 'inbox/a.md']) + expect(before.positionals, `--${name} before`).toEqual(['inbox/a.md']) + expect(getBool(before, name), `--${name} before`).toBe(true) + + const after = parse(['inbox/a.md', `--${name}`]) + expect(after.positionals, `--${name} after`).toEqual(['inbox/a.md']) + expect(getBool(after, name), `--${name} after`).toBe(true) + } + }) + + it('still accepts an explicit --flag=value for a switch', () => { + expect(getString(parse(['--json=false']), 'json')).toBe('false') + expect(getBool(parse(['--json=false']), 'json')).toBe(false) + }) + + it('leaves value flags alone', () => { + const args = parse(['list', '--tag', 'idea', '--limit', '5']) + expect(args.positionals).toEqual(['list']) + expect(getString(args, 'tag')).toBe('idea') + expect(getString(args, 'limit')).toBe('5') + }) + + it('reads the short -n switch without eating the path', () => { + const args = parse(['open', '-n', '/home/user/notes']) + expect(args.positionals).toEqual(['open', '/home/user/notes']) + expect(getBool(args, 'n')).toBe(true) + }) +}) diff --git a/apps/desktop/src/cli/args.ts b/apps/desktop/src/cli/args.ts index aa6cdf2f..766fb3f0 100644 --- a/apps/desktop/src/cli/args.ts +++ b/apps/desktop/src/cli/args.ts @@ -3,7 +3,7 @@ * zn [] [positional...] [--flag value | --flag=value | -x value] * Repeated flags (e.g. `--tag a --tag b`) collect into an array via getMany(). * Boolean flags are inferred when no value follows or when the next token - * starts with `--`. + * starts with `--`, and the flags in VALUELESS_FLAGS never take one. */ export interface ParsedArgs { @@ -14,6 +14,25 @@ export interface ParsedArgs { flags: Map } +/** + * Long flags that are switches, never `--flag `. Without this list a + * switch written before a positional swallowed it: `zn open --new-window + * ~/notes` parsed as new-window="~/notes" and no path (#815), and `zn delete + * --yes inbox/a.md` the same way. `--flag=value` still works for all of them. + * The Go CLI's parser mirrors this list; keep the two in sync. + */ +export const VALUELESS_FLAGS: ReadonlySet = new Set([ + 'all', + 'include-excluded', + 'json', + 'meta', + 'new-window', + 'page', + 'reopen', + 'unchecked', + 'yes' +]) + export function parse(argv: string[]): ParsedArgs { const positionals: string[] = [] const flags = new Map() @@ -36,7 +55,7 @@ export function parse(argv: string[]): ParsedArgs { } const name = token.slice(2) const next = argv[i + 1] - if (next != null && !next.startsWith('--')) { + if (!VALUELESS_FLAGS.has(name) && next != null && !next.startsWith('--')) { push(flags, name, next) i += 1 } else { diff --git a/apps/desktop/src/cli/commands/open.test.ts b/apps/desktop/src/cli/commands/open.test.ts index 841707b3..2398adb7 100644 --- a/apps/desktop/src/cli/commands/open.test.ts +++ b/apps/desktop/src/cli/commands/open.test.ts @@ -19,8 +19,16 @@ import { spawn } from 'node:child_process' import { cmdOpen } from './open' import type { ParsedArgs } from '../args' -function makeArgs(positionals: string[]): ParsedArgs { - return { positionals, flags: new Map() } +function makeArgs(positionals: string[], flags: Record = {}): ParsedArgs { + return { + positionals, + flags: new Map(Object.entries(flags).map(([name, value]) => [name, [value]])) + } +} + +function lastMessage(): string { + const writes = vi.mocked(process.stdout.write).mock.calls + return String(writes[writes.length - 1]?.[0] ?? '') } let tmpDir: string @@ -166,6 +174,30 @@ describe('cmdOpen', () => { expect(argv).toEqual([mdFile, vaultDir]) }) + // #815: `-n` / `--new-window` asks the app for a fresh window instead of + // raising the one that already shows the vault or folder. + it('forwards --new-window ahead of the paths when -n is given', async () => { + await cmdOpen('', makeArgs([vaultDir], { n: 'true' })) + expect(spawn).toHaveBeenCalledTimes(1) + const [, argv] = vi.mocked(spawn).mock.calls[0] + expect(argv).toEqual(['--new-window', vaultDir]) + expect(lastMessage()).toBe(`Opening folder ${vaultDir} in a new ZenNotes window\n`) + }) + + it('accepts the long --new-window spelling and covers every path of the launch', async () => { + await cmdOpen('', makeArgs([mdFile, vaultDir], { 'new-window': 'true' })) + const [, argv] = vi.mocked(spawn).mock.calls[0] + expect(argv).toEqual(['--new-window', mdFile, vaultDir]) + expect(lastMessage()).toBe('Opening 2 items in a new ZenNotes window\n') + }) + + it('passes no switch, and says so, without the flag', async () => { + await cmdOpen('', makeArgs([vaultDir])) + const [, argv] = vi.mocked(spawn).mock.calls[0] + expect(argv).toEqual([vaultDir]) + expect(lastMessage()).toBe(`Opening folder ${vaultDir} in ZenNotes\n`) + }) + it('reports a launch that dies instead of printing success', async () => { // A crash in the app's first moments (no display, broken sandbox) used to // be invisible: the CLI printed "Opening …" regardless. diff --git a/apps/desktop/src/cli/commands/open.ts b/apps/desktop/src/cli/commands/open.ts index 3693e5d4..267befb4 100644 --- a/apps/desktop/src/cli/commands/open.ts +++ b/apps/desktop/src/cli/commands/open.ts @@ -7,12 +7,18 @@ * zn open inbox/demo/03 — Tables and Task Lists.md (unquoted is fine) * zn open ~/code/myproject/docs (a folder → focused session) * zn open ~/notes (a vault) + * zn open -n ~/notes (a second window on that vault) * * A file must be markdown; a folder opens as a focused, non-persisted * session rooted at that folder — the app scopes the window to it and * shows only its notes, without registering a vault. That's the way to * read a repo's `docs/` or zoom in on one folder of a big vault (#466). * + * When a window already shows the vault or folder, the app raises that + * window. `-n` / `--new-window` asks for a fresh one instead, the way + * Chrome's flag of the same name does (#815): a second workspace on the + * same notes, with its own tabs, leaving the first where it was. + * * Paths resolve against the current directory first, then the active * vault root — so the vault-relative paths `zn list` prints open from * anywhere. When the shell has split an unquoted path with spaces into @@ -27,8 +33,8 @@ import { spawn } from 'node:child_process' import { promises as fsp } from 'node:fs' import path from 'node:path' -import { isMarkdownFilePath } from '../../main/file-open.js' -import { type ParsedArgs } from '../args.js' +import { isMarkdownFilePath, NEW_WINDOW_SWITCH } from '../../main/file-open.js' +import { getBool, type ParsedArgs } from '../args.js' import { emitOk } from '../format.js' export interface ResolvedOpenTarget { @@ -106,13 +112,17 @@ export async function cmdOpen(vault: string, args: ParsedArgs): Promise { } const absPaths = resolved.map((r) => r.abs) + const newWindow = getBool(args, 'n') || getBool(args, 'new-window') + // The switch rides in argv ahead of the paths; the app reads it per launch, + // so it covers every path given here and none of a later `zn open`. + const launchArgs = newWindow ? [NEW_WINDOW_SWITCH, ...absPaths] : absPaths // Re-launch our own binary in GUI mode. The CLI wrapper set // ELECTRON_RUN_AS_NODE so this process runs as plain Node, so we must // drop it for the child or it would start as Node too instead of the app. const env = { ...process.env } delete env.ELECTRON_RUN_AS_NODE - const child = spawn(process.execPath, absPaths, { + const child = spawn(process.execPath, launchArgs, { detached: true, stdio: 'ignore', env @@ -140,10 +150,11 @@ export async function cmdOpen(vault: string, args: ParsedArgs): Promise { child.unref() if (failure) throw new Error(failure) + const where = newWindow ? 'in a new ZenNotes window' : 'in ZenNotes' if (resolved.length === 1) { const only = resolved[0]! - emitOk(`Opening ${only.isDirectory ? 'folder ' : ''}${only.abs} in ZenNotes`) + emitOk(`Opening ${only.isDirectory ? 'folder ' : ''}${only.abs} ${where}`) } else { - emitOk(`Opening ${resolved.length} items in ZenNotes`) + emitOk(`Opening ${resolved.length} items ${where}`) } } diff --git a/apps/desktop/src/cli/help.ts b/apps/desktop/src/cli/help.ts index 8b7c51e6..7ef82e0e 100644 --- a/apps/desktop/src/cli/help.ts +++ b/apps/desktop/src/cli/help.ts @@ -174,7 +174,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [ { heading: 'OPEN', rows: [ - { name: 'open ', description: 'Open markdown files, or a folder / vault (a focused session), in the app' } + { name: 'open ', description: 'Open markdown files, or a folder / vault (a focused session), in the app', flags: '-n, --new-window' } ] }, { @@ -215,7 +215,8 @@ const EXAMPLES: string[] = [ 'zn comment list inbox/Plan.md', 'zn comment reply inbox/Plan.md "Agreed, fixed in the second paragraph." --author Claude', 'zn open ~/Downloads/notes.md', - 'zn open ~/code/project/docs # focus a folder as a session' + 'zn open ~/code/project/docs # focus a folder as a session', + 'zn open -n ~/notes # a second window on a vault that is already open' ] function header(width: number): string[] { diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 0aee4f6b..8ab2faf7 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -257,9 +257,12 @@ describe('DesktopCloudSyncService', () => { expect(await service.settingsConflict(localRoot)).toBeNull() await writeFile(parkedPath, JSON.stringify({ favorites: ['cloud.md'] })) + // The cloud's copy travels with the question, so the app can show what + // differs and let the user answer one section at a time. expect(await service.settingsConflict(localRoot)).toEqual({ path: '.zennotes/vault.json', - cloud_path: '.zennotes/vault.cloud-conflict.json' + cloud_path: '.zennotes/vault.cloud-conflict.json', + cloud_settings: { favorites: ['cloud.md'] } }) // Keeping this device's settings drops the pending copy and changes nothing. @@ -283,8 +286,12 @@ describe('DesktopCloudSyncService', () => { await expect(service.resolveSettingsConflict(localRoot, 'cloud')).rejects.toThrow( 'could not be read' ) - // The question stays open rather than resolving itself badly. - expect(await service.settingsConflict(localRoot)).not.toBeNull() + // The question stays open rather than resolving itself badly, and is + // asked whole-file: there are no contents to compare. + expect(await service.settingsConflict(localRoot)).toEqual({ + path: '.zennotes/vault.json', + cloud_path: '.zennotes/vault.cloud-conflict.json' + }) }) it('links only a vault owned by the connected account', async () => { diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index eb9c9f6e..fc02a1b4 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -507,14 +507,21 @@ export class DesktopCloudSyncService { * it by accident. */ async settingsConflict(localRoot: string): Promise { const parked = path.join(localRoot, ...CLOUD_SYNC_SETTINGS_CONFLICT_PATH.split('/')) + let raw: string try { - await fs.access(parked) + raw = await fs.readFile(parked, 'utf8') } catch { return null } + // The parsed copy lets the app show what differs and offer a per-section + // answer. A copy that does not parse is still a pending question (the + // file is there, and sync will not touch vault.json until it is gone), so + // it is reported without the contents and the app asks whole-file. + const cloudSettings = parseParkedSettings(raw) return { path: CLOUD_SYNC_VAULT_SETTINGS_PATH, - cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH + cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH, + ...(cloudSettings ? { cloud_settings: cloudSettings } : {}) } } @@ -528,17 +535,14 @@ export class DesktopCloudSyncService { ): Promise { const parked = path.join(localRoot, ...CLOUD_SYNC_SETTINGS_CONFLICT_PATH.split('/')) if (choice === 'cloud') { - const raw = await fs.readFile(parked, 'utf8') - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - throw new Error('The settings from the cloud could not be read, so nothing was changed.') - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + const parsed = parseParkedSettings(await fs.readFile(parked, 'utf8')) + if (!parsed) { throw new Error('The settings from the cloud could not be read, so nothing was changed.') } - await setVaultSettings(localRoot, parsed as Parameters[1]) + await setVaultSettings( + localRoot, + parsed as unknown as Parameters[1] + ) } await fs.rm(parked, { force: true }) } @@ -630,6 +634,19 @@ function rootFingerprint(localRoot: string): string { return fingerprint(path.resolve(localRoot)) } +/** The parked cloud vault.json as an object, or null when the bytes are not + * one (invalid JSON, a bare list, a hand-edited scalar). */ +function parseParkedSettings(raw: string): Record | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + return parsed as Record +} + function isCloudVaultLink(value: unknown): value is CloudVaultLink { if (!value || typeof value !== 'object') return false const link = value as Partial diff --git a/apps/desktop/src/main/file-open.test.ts b/apps/desktop/src/main/file-open.test.ts index d3c3ba0c..47a8e9ba 100644 --- a/apps/desktop/src/main/file-open.test.ts +++ b/apps/desktop/src/main/file-open.test.ts @@ -1,9 +1,11 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import { + argvRequestsNewWindow, candidatePathsFromArgv, isMarkdownFilePath, markdownPathsFromArgv, + NEW_WINDOW_SWITCH, resolveMarkdownOpenTarget, vaultRelativeNotePath } from './file-open' @@ -188,3 +190,25 @@ describe('candidatePathsFromArgv own-app-path filter (#579)', () => { } ) }) + +describe('--new-window switch (#815)', () => { + // `zn open -n` forwards the switch ahead of the paths; the app must both + // read it and keep treating it as a switch, never as a path to open. + it('is read from anywhere in argv, and only as the exact switch', () => { + expect(argvRequestsNewWindow(['/bin/ZenNotes', NEW_WINDOW_SWITCH, '/home/user/vault'])).toBe( + true + ) + expect(argvRequestsNewWindow(['/bin/ZenNotes', '/home/user/vault', NEW_WINDOW_SWITCH])).toBe( + true + ) + expect(argvRequestsNewWindow(['/bin/ZenNotes', '/home/user/vault'])).toBe(false) + // A path that merely contains the words is a path. + expect(argvRequestsNewWindow(['/bin/ZenNotes', '/vault/--new-window'])).toBe(false) + }) + + it('does not leak into the candidate paths', () => { + const argv = ['/bin/ZenNotes', NEW_WINDOW_SWITCH, '/home/user/vault', '/home/user/todo.md'] + expect(candidatePathsFromArgv(argv)).toEqual(['/home/user/vault', '/home/user/todo.md']) + expect(markdownPathsFromArgv(argv)).toEqual(['/home/user/todo.md']) + }) +}) diff --git a/apps/desktop/src/main/file-open.ts b/apps/desktop/src/main/file-open.ts index ec10ea28..1eb2f3f1 100644 --- a/apps/desktop/src/main/file-open.ts +++ b/apps/desktop/src/main/file-open.ts @@ -27,6 +27,19 @@ export function vaultRelativeNotePath(vaultRoot: string, absPath: string): strin return segments.join('/') } +/** + * The argv switch that asks for a fresh window even when one already shows the + * same vault, folder or note (#815). `zn open -n` forwards it; Chrome spells + * its equivalent the same way, so a launcher can also pass it to the app + * directly. Chromium ignores switches it does not know, and Electron does not + * define this one, so it reaches `second-instance` untouched. + */ +export const NEW_WINDOW_SWITCH = '--new-window' + +export function argvRequestsNewWindow(argv: readonly string[]): boolean { + return argv.includes(NEW_WINDOW_SWITCH) +} + export type MarkdownOpenTarget = | { kind: 'vault'; vaultRoot: string; relPath: string } | { kind: 'external'; absPath: string } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2fd2d629..e5a00e13 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -242,6 +242,7 @@ import { } from "./raycast-integration"; import { checkForAppUpdates, + describeInstall, downloadAppUpdate, getAppUpdateState, initAppUpdater, @@ -268,6 +269,7 @@ import { ZENNOTES_DEEP_LINK_SCHEME, } from "./deep-links"; import { + argvRequestsNewWindow, isMarkdownFilePath, MARKDOWN_FILE_EXTENSIONS, candidatePathsFromArgv, @@ -390,7 +392,13 @@ let cloudAuthLoopbackServer: CloudAuthLoopbackServer | null = null; let cloudSyncService: DesktopCloudSyncService | null = null; // Markdown files handed to us by the OS (Finder "Open With", a file // double-click, drag onto the dock, or a Windows/Linux argv launch). -const pendingFileOpens: { absPath: string; reuseMainWindow: boolean }[] = []; +// `newWindow` is the `--new-window` ask (#815): open a fresh window even when +// one already shows the same vault or folder, instead of raising that one. +const pendingFileOpens: { + absPath: string; + reuseMainWindow: boolean; + newWindow: boolean; +}[] = []; // windowId -> absolute path of the standalone external file it edits. const externalFileWindows = new Map(); // Per-window renderer readiness, so note-open requests can target any @@ -706,8 +714,13 @@ function findWindowForVaultRoot(root: string): BrowserWindow | null { function queueMarkdownFileOpen( rawPath: string, reuseMainWindow: boolean, + newWindow = false, ): void { - pendingFileOpens.push({ absPath: path.resolve(rawPath), reuseMainWindow }); + pendingFileOpens.push({ + absPath: path.resolve(rawPath), + reuseMainWindow, + newWindow, + }); // Only flush eagerly once startup is finished. During startup `app.isReady()` // is already true (we're inside whenReady), so an eager flush here would // drain the queue before whenReady's own flush runs — that flush would then @@ -727,13 +740,16 @@ function handleStartupMarkdownArgs( // skipping by index alone let the app dir through as a folder to open. const isUnpackagedElectronLaunch = (process as NodeJS.Process & { defaultApp?: boolean }).defaultApp === true; + // `--new-window` covers every path of this launch (#815). On a cold start it + // changes nothing: there is no window to reuse yet. + const newWindow = argvRequestsNewWindow(argv); let queued = 0; for (const candidate of candidatePathsFromArgv( argv, isUnpackagedElectronLaunch, app.getAppPath(), )) { - queueMarkdownFileOpen(candidate, reuseMainWindow); + queueMarkdownFileOpen(candidate, reuseMainWindow, newWindow); queued += 1; } return queued; @@ -772,7 +788,13 @@ async function drainPendingFileOpens(): Promise { let openedAny = false; for (const item of items) { try { - if (await openMarkdownFileFromOS(item.absPath, item.reuseMainWindow)) { + if ( + await openMarkdownFileFromOS( + item.absPath, + item.reuseMainWindow, + item.newWindow, + ) + ) { openedAny = true; } } catch (err) { @@ -782,9 +804,14 @@ async function drainPendingFileOpens(): Promise { return openedAny; } +// `newWindow` (#815) opens a fresh window on the vault or folder even when one +// already shows it. A file outside every vault keeps reusing its window on +// purpose: nothing keeps two standalone editors of one file in sync, so the +// second would silently overwrite the first's saves. async function openMarkdownFileFromOS( absPath: string, reuseMainWindow: boolean, + newWindow = false, ): Promise { let stat; try { @@ -794,7 +821,7 @@ async function openMarkdownFileFromOS( } // A dropped folder opens as a temporary, non-persisted session. if (stat.isDirectory()) { - return await openTemporaryFolder(absPath, reuseMainWindow); + return await openTemporaryFolder(absPath, reuseMainWindow, newWindow); } if (!stat.isFile() || !isMarkdownFilePath(absPath)) return false; @@ -811,7 +838,7 @@ async function openMarkdownFileFromOS( const target = resolveMarkdownOpenTarget(absPath, knownRoots); if (target.kind === "vault") { - const existing = findWindowForVaultRoot(target.vaultRoot); + const existing = newWindow ? null : findWindowForVaultRoot(target.vaultRoot); if (existing) { focusWindow(existing); queueNoteOpenForWindow(existing, target.relPath); @@ -868,13 +895,28 @@ async function openMarkdownFileViaDialog( async function openTemporaryFolder( dir: string, reuseMainWindow: boolean, + newWindow = false, ): Promise { const resolved = path.resolve(dir); const existing = findWindowForVaultRoot(resolved); - if (existing) { + if (existing && !newWindow) { focusWindow(existing); return true; } + if (existing && windowVaults.vaultForWindow(existing.id)?.temporary !== true) { + // `zn open -n ` on a vault a window has open for real (#815). A + // temporary session on that root is not an option: the ephemeral registry + // is keyed by root, not by window, so it would also switch the first + // window's workspace-state and settings writes off and send its deleted + // notes to the system Trash. Open a second real window on the vault + // instead, exactly what "Open Vault in New Window" does. + const win = await createWindow({ + initialVaultRoot: resolved, + persistInitialVault: true, + }); + if (!reuseMainWindow) focusWindow(win); + return true; + } // No markdown-content gate here anymore. It used to bail when a bounded scan // found no markdown, which read as protection against opening junk folders, // but every caller is an explicit ask (`zn open `, a folder handed to @@ -4608,6 +4650,16 @@ function registerIpc(): void { event.returnValue = null; } }); + // Same shape as CONFIG_GET_SYNC: the preload folds the OS and install + // format into getAppInfo(), which renderer code calls synchronously (#814). + ipcMain.on(IPC.APP_INSTALL_INFO_SYNC, (event) => { + try { + assertTrustedIpcEvent(event); + event.returnValue = describeInstall(); + } catch { + event.returnValue = null; + } + }); handle(IPC.CONFIG_SET, async (_event, next: AppConfigPortable) => { const previousTitleBar = getPortableConfigSnapshot().showWindowTitleBar !== false; @@ -5235,9 +5287,11 @@ async function runMenuUpdateCheck(): Promise { ? "ZenNotes is up to date." : state.phase === "unsupported" ? "Update checks are unavailable." - : state.phase === "error" - ? "Could not check for updates." - : "ZenNotes Updates", + : state.phase === "offline" + ? "ZenNotes can't reach GitHub right now." + : state.phase === "error" + ? "Could not check for updates." + : "ZenNotes Updates", detail: state.message, }); } diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 448ea078..0b66c303 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -1,11 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +// What Chromium's network change notifier would answer; tests flip it to +// play a link going down and coming back. +const network = vi.hoisted(() => ({ online: true })) + // updater.ts imports electron and electron-updater at module load. Stub both so // we can unit-test the pure Linux-install helpers without an Electron runtime. vi.mock('electron', () => ({ app: { getVersion: () => '2.0.2' }, BrowserWindow: { getAllWindows: () => [] }, Notification: { isSupported: () => false }, + net: { isOnline: () => network.online }, shell: {} })) vi.mock('electron-updater', () => ({ @@ -22,7 +27,9 @@ import FpmTarget from 'app-builder-lib/out/targets/FpmTarget' import electronUpdater from 'electron-updater' import { elevatedInstallScript, + installLabel, installedLinuxFormat, + isNetworkUnreachableError, isOfficialLinuxSystemPackage, linuxFormatFromOsRelease, linuxInstallMismatch, @@ -31,9 +38,81 @@ import { linuxUpdaterForFormat, linuxUpdaterFormat, manualInstallHint, - mismatchedUpdateMessage + mismatchedUpdateMessage, + offlineRetryDelayMs, + osReleasePrettyName, + OFFLINE_POLL_MS, + OFFLINE_RETRY_BASE_MS, + OFFLINE_RETRY_MAX_MS } from './updater' +describe('installLabel (:version, issue #814)', () => { + const packaged = { + isPackaged: true, + mas: false, + windowsStore: false, + portableExecutableDir: undefined, + linuxFormat: (): 'unknown' => 'unknown' + } + + it('calls an unpackaged checkout a development build on every platform', () => { + for (const platform of ['darwin', 'win32', 'linux'] as const) { + expect(installLabel({ ...packaged, platform, isPackaged: false })).toBe('development build') + } + }) + + it('tells the Mac App Store copy apart from the dmg one', () => { + expect(installLabel({ ...packaged, platform: 'darwin' })).toBe('macOS app bundle') + expect(installLabel({ ...packaged, platform: 'darwin', mas: true })).toBe('Mac App Store') + }) + + it('tells the portable exe apart from the NSIS install and the Store', () => { + expect(installLabel({ ...packaged, platform: 'win32' })).toBe('NSIS installer') + expect( + installLabel({ ...packaged, platform: 'win32', portableExecutableDir: 'D:\\apps' }) + ).toBe('portable exe') + expect(installLabel({ ...packaged, platform: 'win32', windowsStore: true })).toBe( + 'Microsoft Store' + ) + }) + + it('names the Linux format the updater itself detected', () => { + const linux = (format: ReturnType) => + installLabel({ ...packaged, platform: 'linux', linuxFormat: () => format }) + expect(linux('appimage')).toBe('AppImage') + expect(linux('deb')).toBe('deb package') + expect(linux('rpm')).toBe('rpm package') + expect(linux('pacman')).toBe('pacman package') + expect(linux('managed')).toBe('package manager or tarball (updates are reported, not installed)') + expect(linux('unknown')).toBe('Linux package (format unknown)') + }) + + it('does not consult the Linux detector off Linux', () => { + const linuxFormat = vi.fn((): 'deb' => 'deb') + installLabel({ ...packaged, platform: 'darwin', linuxFormat }) + installLabel({ ...packaged, platform: 'linux', isPackaged: false, linuxFormat }) + expect(linuxFormat).not.toHaveBeenCalled() + }) +}) + +describe('osReleasePrettyName', () => { + it('reads PRETTY_NAME and strips its quotes', () => { + expect( + osReleasePrettyName('NAME="Ubuntu"\nPRETTY_NAME="Ubuntu 24.04.1 LTS"\nID=ubuntu\n') + ).toBe('Ubuntu 24.04.1 LTS') + expect(osReleasePrettyName("PRETTY_NAME='Arch Linux'")).toBe('Arch Linux') + expect(osReleasePrettyName('PRETTY_NAME=Fedora Linux 40 (Workstation Edition)')).toBe( + 'Fedora Linux 40 (Workstation Edition)' + ) + }) + + it('ignores NAME and an empty PRETTY_NAME, and answers null without a file', () => { + expect(osReleasePrettyName('NAME="Debian GNU/Linux"\nVERSION_ID="12"')).toBeNull() + expect(osReleasePrettyName('PRETTY_NAME=""')).toBeNull() + expect(osReleasePrettyName(null)).toBeNull() + }) +}) + describe('linuxPackageFormat', () => { it('detects each packaged Linux format', () => { expect(linuxPackageFormat('/tmp/ZenNotes-2.0.5.AppImage')).toBe('appimage') @@ -251,34 +330,51 @@ describe('Linux updater build support', () => { }) }) +const FEED = (version: string) => + `version: ${version}\nfiles: []\nreleaseDate: '2026-09-02T15:28:11.000Z'\n` + +/** What undici's fetch throws with no network: the readable part is in `cause`. */ +function fetchFailed(code: string, detail: string): TypeError { + return new TypeError('fetch failed', { + cause: Object.assign(new Error(detail), { code }) + }) +} + +/** + * A fresh updater module on the package-manager (notify-only) path, its feed + * served by `feed`: a fixed body, a fixed error, or a function answering per + * call so a test can bring the network back partway through. + */ +async function loadManagedUpdater(feed: string | Error | (() => string | Error)) { + vi.resetModules() + process.env.ZENNOTES_UPDATER_FORMAT = 'managed' + process.env.ZENNOTES_UPDATE_FEED_URL = 'http://127.0.0.1:1/latest-linux.yml' + const fetchMock = vi.fn(async () => { + const body = typeof feed === 'function' ? feed() : feed + if (body instanceof Error) throw body + return { ok: true, status: 200, text: async () => body } + }) + vi.stubGlobal('fetch', fetchMock) + return { mod: await import('./updater'), fetchMock } +} + +function restoreUpdaterEnv(original: { format?: string; feed?: string }): void { + vi.unstubAllGlobals() + if (original.format === undefined) delete process.env.ZENNOTES_UPDATER_FORMAT + else process.env.ZENNOTES_UPDATER_FORMAT = original.format + if (original.feed === undefined) delete process.env.ZENNOTES_UPDATE_FEED_URL + else process.env.ZENNOTES_UPDATE_FEED_URL = original.feed +} + describe('checkForAppUpdates on a package-manager install', () => { - const FEED = (version: string) => `version: ${version}\nfiles: []\nreleaseDate: '2026-09-02T15:28:11.000Z'\n` const original = { format: process.env.ZENNOTES_UPDATER_FORMAT, feed: process.env.ZENNOTES_UPDATE_FEED_URL } - async function loadManagedUpdater(feedBody: string | Error) { - vi.resetModules() - process.env.ZENNOTES_UPDATER_FORMAT = 'managed' - process.env.ZENNOTES_UPDATE_FEED_URL = 'http://127.0.0.1:1/latest-linux.yml' - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - if (feedBody instanceof Error) throw feedBody - return { ok: true, status: 200, text: async () => feedBody } - }) - ) - return await import('./updater') - } - afterEach(() => { - vi.unstubAllGlobals() - if (original.format === undefined) delete process.env.ZENNOTES_UPDATER_FORMAT - else process.env.ZENNOTES_UPDATER_FORMAT = original.format - if (original.feed === undefined) delete process.env.ZENNOTES_UPDATE_FEED_URL - else process.env.ZENNOTES_UPDATE_FEED_URL = original.feed + restoreUpdaterEnv(original) }) it('reports a newer version without offering to install it', async () => { - const mod = await loadManagedUpdater(FEED('2.0.3')) + const { mod } = await loadManagedUpdater(FEED('2.0.3')) const state = await mod.checkForAppUpdates() expect(state.phase).toBe('available') expect(state.availableVersion).toBe('2.0.3') @@ -289,7 +385,7 @@ describe('checkForAppUpdates on a package-manager install', () => { }) it('says so when the running version is the newest', async () => { - const mod = await loadManagedUpdater(FEED('2.0.2')) + const { mod } = await loadManagedUpdater(FEED('2.0.2')) const state = await mod.checkForAppUpdates() expect(state.phase).toBe('not-available') expect(state.message).toBe("You're already on ZenNotes 2.0.2.") @@ -297,10 +393,158 @@ describe('checkForAppUpdates on a package-manager install', () => { }) it('surfaces a feed failure instead of staying on checking', async () => { - const mod = await loadManagedUpdater(new Error('getaddrinfo EAI_FAIL github.com')) + const { mod } = await loadManagedUpdater(new Error('GitHub answered 404 for the release feed.')) const state = await mod.checkForAppUpdates() expect(state.phase).toBe('error') - expect(state.message).toMatch(/EAI_FAIL/) + expect(state.message).toMatch(/404/) + }) +}) + +describe('isNetworkUnreachableError', () => { + it('recognizes no-network failures from both HTTP stacks, cause chain included', () => { + // undici (the package-manager feed check): the code is two levels down. + expect(isNetworkUnreachableError(fetchFailed('ENOTFOUND', 'getaddrinfo ENOTFOUND github.com'))).toBe(true) + expect(isNetworkUnreachableError(fetchFailed('ENETUNREACH', 'connect ENETUNREACH 140.82.121.4:443'))).toBe(true) + // Electron's net module (electron-updater): Chromium's name in the message. + expect(isNetworkUnreachableError(new Error('net::ERR_INTERNET_DISCONNECTED'))).toBe(true) + expect(isNetworkUnreachableError(new Error('net::ERR_NAME_NOT_RESOLVED'))).toBe(true) + // Plain Node errors, as a message or as a code. + expect(isNetworkUnreachableError(new Error('getaddrinfo EAI_AGAIN github.com'))).toBe(true) + expect(isNetworkUnreachableError(Object.assign(new Error('connect failed'), { code: 'ECONNREFUSED' }))).toBe(true) + }) + + it('leaves answers from GitHub, and everything else, to the error path', () => { + expect(isNetworkUnreachableError(new Error('GitHub answered 404 for the release feed.'))).toBe(false) + expect(isNetworkUnreachableError(new Error('HttpError: 503 Service Unavailable'))).toBe(false) + expect(isNetworkUnreachableError(new Error('The release feed carried no version.'))).toBe(false) + // "connection" in prose is not a connection error code. + expect(isNetworkUnreachableError(new Error('Could not verify the connection to the signing service'))).toBe(false) + expect(isNetworkUnreachableError(undefined)).toBe(false) + }) +}) + +describe('offlineRetryDelayMs', () => { + it('doubles from the base up to the cap', () => { + expect(offlineRetryDelayMs(1)).toBe(OFFLINE_RETRY_BASE_MS) + expect(offlineRetryDelayMs(2)).toBe(OFFLINE_RETRY_BASE_MS * 2) + expect(offlineRetryDelayMs(3)).toBe(OFFLINE_RETRY_BASE_MS * 4) + expect(offlineRetryDelayMs(6)).toBe(OFFLINE_RETRY_MAX_MS) + // Far past the cap, and past where 2 ** n stops being a safe integer. + expect(offlineRetryDelayMs(60)).toBe(OFFLINE_RETRY_MAX_MS) + expect(offlineRetryDelayMs(0)).toBe(OFFLINE_RETRY_BASE_MS) + }) +}) + +describe('checkForAppUpdates with no network (issue #812)', () => { + const original = { format: process.env.ZENNOTES_UPDATER_FORMAT, feed: process.env.ZENNOTES_UPDATE_FEED_URL } + const offline = fetchFailed('ENOTFOUND', 'getaddrinfo ENOTFOUND github.com') + + afterEach(() => { + vi.useRealTimers() + network.online = true + restoreUpdaterEnv(original) + }) + + it('waits instead of erroring, and checks again by itself when the link comes back', async () => { + vi.useFakeTimers() + network.online = false + let reachable = false + const { mod, fetchMock } = await loadManagedUpdater(() => (reachable ? FEED('2.0.3') : offline)) + + const state = await mod.checkForAppUpdates() + expect(state.phase).toBe('offline') + // The readable cause, not undici's "fetch failed". + expect(state.message).toContain('getaddrinfo ENOTFOUND github.com') + expect(state.message).toMatch(/check for updates again on its own/) + expect(fetchMock).toHaveBeenCalledTimes(1) + + // Link down for a while: no requests are wasted on it. + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(mod.getAppUpdateState().phase).toBe('offline') + + // The link returns: one poll later the check runs and gets its answer. + network.online = true + reachable = true + await vi.advanceTimersByTimeAsync(OFFLINE_POLL_MS) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(mod.getAppUpdateState().phase).toBe('available') + expect(mod.getAppUpdateState().availableVersion).toBe('2.0.3') + + // Answered: nothing keeps polling. + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('backs off while the link is up but GitHub stays out of reach, then stops once answered', async () => { + vi.useFakeTimers() + network.online = true + let reachable = false + const { mod, fetchMock } = await loadManagedUpdater(() => (reachable ? FEED('2.0.2') : offline)) + + expect((await mod.checkForAppUpdates()).phase).toBe('offline') + expect(fetchMock).toHaveBeenCalledTimes(1) + + // First retry after the base delay (polls land on 15 s marks). + await vi.advanceTimersByTimeAsync(OFFLINE_RETRY_BASE_MS - OFFLINE_POLL_MS) + expect(fetchMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(OFFLINE_POLL_MS) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(mod.getAppUpdateState().phase).toBe('offline') + + // Second retry waits twice as long: nothing at the base delay again. + await vi.advanceTimersByTimeAsync(OFFLINE_RETRY_BASE_MS) + expect(fetchMock).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(OFFLINE_RETRY_BASE_MS) + expect(fetchMock).toHaveBeenCalledTimes(3) + + // GitHub is back; the third retry (four times the base) succeeds and ends the wait. + reachable = true + await vi.advanceTimersByTimeAsync(OFFLINE_RETRY_BASE_MS * 4) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(mod.getAppUpdateState().phase).toBe('not-available') + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(fetchMock).toHaveBeenCalledTimes(4) + }) + + it('still makes a real attempt now and then if the notifier keeps saying offline', async () => { + vi.useFakeTimers() + network.online = false + const { mod, fetchMock } = await loadManagedUpdater(offline) + + expect((await mod.checkForAppUpdates()).phase).toBe('offline') + await vi.advanceTimersByTimeAsync(OFFLINE_RETRY_MAX_MS - OFFLINE_POLL_MS) + expect(fetchMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(OFFLINE_POLL_MS) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(mod.getAppUpdateState().phase).toBe('offline') + }) + + it('does not wait on errors that are not the network', async () => { + vi.useFakeTimers() + const { mod, fetchMock } = await loadManagedUpdater(new Error('GitHub answered 404 for the release feed.')) + + expect((await mod.checkForAppUpdates()).phase).toBe('error') + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('lets a manual check while waiting run at once, and keeps waiting if it fails the same way', async () => { + vi.useFakeTimers() + network.online = true + let reachable = false + const { mod, fetchMock } = await loadManagedUpdater(() => (reachable ? FEED('2.0.2') : offline)) + + expect((await mod.checkForAppUpdates()).phase).toBe('offline') + // The user presses Check for Updates before any retry is due. + expect((await mod.checkForAppUpdates()).phase).toBe('offline') + expect(fetchMock).toHaveBeenCalledTimes(2) + + // The wait survived the manual check and still recovers on its own. + reachable = true + await vi.advanceTimersByTimeAsync(OFFLINE_RETRY_MAX_MS) + expect(mod.getAppUpdateState().phase).toBe('not-available') + expect(fetchMock).toHaveBeenCalledTimes(3) }) }) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index c44d0334..530fbf6c 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Notification, shell } from 'electron' +import { app, BrowserWindow, net, Notification, shell } from 'electron' import { execFile } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { fetchLatestRelease, isNewerVersion } from './update-feed' @@ -16,6 +16,12 @@ const execFileAsync = promisify(execFile) const UPDATE_CHECK_MAX_ATTEMPTS = 3 const UPDATE_CHECK_RETRY_DELAY_MS = 1500 const BACKGROUND_UPDATE_CHECK_DELAY_MS = 8000 +/** How often a check that found no network looks at the link again. */ +export const OFFLINE_POLL_MS = 15_000 +/** First wait before re-checking when the link is up but GitHub still could + * not be reached; doubles per failure up to the cap. */ +export const OFFLINE_RETRY_BASE_MS = 30_000 +export const OFFLINE_RETRY_MAX_MS = 15 * 60_000 let initialized = false let updater: AppUpdater | null = null @@ -86,10 +92,54 @@ function nextStateFromInfo( }) } +/** + * Every message and code along an error's `cause` chain, innermost last. + * undici's fetch reports the network as `TypeError: fetch failed` and keeps + * the part worth reading (`getaddrinfo ENOTFOUND github.com`) in `cause`; + * Electron's net module puts it in the message (`net::ERR_INTERNET_DISCONNECTED`). + */ +function errorChainText(error: unknown): string[] { + const parts: string[] = [] + let current: unknown = error + for (let depth = 0; depth < 5 && current != null; depth += 1) { + if (current instanceof Error) { + parts.push(current.message) + const { code, cause } = current as { code?: unknown; cause?: unknown } + if (typeof code === 'string') parts.push(code) + current = cause + } else { + parts.push(String(current)) + break + } + } + return parts.map((part) => part.trim()).filter(Boolean) +} + +const NETWORK_UNREACHABLE_PATTERN = + /\b(ENOTFOUND|EAI_AGAIN|EAI_FAIL|EAI_NONAME|ENETUNREACH|ENETDOWN|EHOSTUNREACH|ECONNREFUSED|ECONNRESET|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)\b|net::ERR_(INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|NAME_RESOLUTION_FAILED|DNS_TIMED_OUT|ADDRESS_UNREACHABLE|NETWORK_CHANGED|NETWORK_IO_SUSPENDED|NETWORK_ACCESS_DENIED|PROXY_CONNECTION_FAILED|TIMED_OUT|CONNECTION_(REFUSED|RESET|CLOSED|ABORTED|FAILED|TIMED_OUT))\b|^fetch failed$/i + +/** + * True when the check never reached GitHub: no route, no DNS, nothing + * listening. GitHub answering badly (a 5xx, a 404) is not this; those are + * errors to show, not a connection to wait for. + */ +export function isNetworkUnreachableError(error: unknown): boolean { + return errorChainText(error).some((part) => NETWORK_UNREACHABLE_PATTERN.test(part)) +} + +/** The most specific line of an error chain, for the message the user reads: + * `getaddrinfo ENOTFOUND github.com` rather than `fetch failed`. */ +function describeError(error: unknown): string { + const parts = errorChainText(error).filter((part) => !/^fetch failed$/i.test(part)) + const messages = parts.filter((part) => /\s|::/.test(part)) + return messages.at(-1) ?? parts.at(-1) ?? 'Unknown updater error.' +} + function humanizeUpdateError(error: unknown): string { - const base = - error instanceof Error ? error.message.trim() : String(error).trim() - const message = base.length > 0 ? base : 'Unknown updater error.' + const message = describeError(error) + if (isNetworkUnreachableError(error)) { + return `${message} ZenNotes could not reach GitHub. Check the connection and try again.` + } if (/5\d\d|gateway time-?out|timed out|econnreset|eai_again|socket hang up/i.test(message)) { return `${message} GitHub returned a temporary network or server error while checking for updates. Try again in a moment, or open the latest release directly.` } @@ -122,9 +172,89 @@ function broadcastUpdateState(): void { function setUpdateState(next: AppUpdateState): void { updateState = next + // The wait for the network lives exactly as long as the phase it explains. + // A check in flight keeps it (its failure count carries into the next + // wait); any other outcome, from either a timer or the user, ends it. + if (next.phase !== 'offline' && next.phase !== 'checking') stopWaitingForNetwork() broadcastUpdateState() } +/** + * A check that could not reach GitHub at all (issue #812: the app launched + * offline, the startup check failed, and nothing ever tried again until the + * user pressed Check for Updates). Instead of reporting an error, wait for + * the network and check again on our own. + * + * Two triggers, both cheap: the link coming back (Chromium's own network + * change notifier, read through `net.isOnline()` every OFFLINE_POLL_MS; no + * request is made), and a retry timer for the case the link is up but GitHub + * still cannot be reached (captive portal, the router's WAN side down). The + * retry delay doubles per failure up to OFFLINE_RETRY_MAX_MS. + * + * `isOnline()` answering false is trusted enough to skip retries while the + * link is down, but never for good: a real attempt happens at least every + * OFFLINE_RETRY_MAX_MS, so a notifier that is wrong about this machine + * cannot silence the check forever. + */ +interface NetworkWait { + timer: NodeJS.Timeout + failures: number + lastAttemptAt: number + linkWasUp: boolean +} + +let networkWait: NetworkWait | null = null + +export function offlineRetryDelayMs(failures: number): number { + const doublings = Math.max(0, Math.min(failures - 1, 30)) + return Math.min(OFFLINE_RETRY_BASE_MS * 2 ** doublings, OFFLINE_RETRY_MAX_MS) +} + +function stopWaitingForNetwork(): void { + if (!networkWait) return + clearInterval(networkWait.timer) + networkWait = null +} + +function waitForNetwork(error: unknown): void { + const failures = (networkWait?.failures ?? 0) + 1 + stopWaitingForNetwork() + networkWait = { + failures, + lastAttemptAt: Date.now(), + linkWasUp: net.isOnline(), + timer: setInterval(pollForNetwork, OFFLINE_POLL_MS) + } + setUpdateState( + nextStateFromInfo( + 'offline', + lastInfo, + `ZenNotes can't reach GitHub right now (${describeError(error)}). It will check for updates again on its own once the connection is back.`, + { availableVersion: lastInfo?.version ?? updateState.availableVersion } + ) + ) +} + +function pollForNetwork(): void { + const wait = networkWait + if (!wait) return + if (updateState.phase !== 'offline') { + // A check the user started is running, or something else owns the state. + if (updateState.phase !== 'checking') stopWaitingForNetwork() + return + } + const linkUp = net.isOnline() + const linkRestored = linkUp && !wait.linkWasUp + wait.linkWasUp = linkUp + const sinceAttempt = Date.now() - wait.lastAttemptAt + const retryDue = sinceAttempt >= offlineRetryDelayMs(wait.failures) + const overdue = sinceAttempt >= OFFLINE_RETRY_MAX_MS + if (linkRestored || (retryDue && linkUp) || overdue) { + wait.lastAttemptAt = Date.now() + void checkForAppUpdates() + } +} + function focusAppAndOpenSettings(): void { const windows = BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()) const target = BrowserWindow.getFocusedWindow() ?? windows[0] ?? null @@ -270,6 +400,11 @@ export function initAppUpdater(): void { } }) updater.on('error', (error) => { + // electron-updater emits this and rejects the same promise. Let the check + // or download that owns the promise decide what an unreachable network + // means (a wait, not an error); reporting it here first would flash the + // error state through the renderer on the way. + if (isNetworkUnreachableError(error)) return setUpdateState( nextStateFromInfo('error', lastInfo, humanizeUpdateError(error)) ) @@ -307,6 +442,10 @@ export async function checkForAppUpdates(): Promise { continue } + if (isNetworkUnreachableError(error)) { + waitForNetwork(error) + break + } setUpdateState( nextStateFromInfo('error', lastInfo, humanizeUpdateError(error)) ) @@ -371,6 +510,10 @@ async function checkManagedInstallForUpdates(): Promise { await sleep(UPDATE_CHECK_RETRY_DELAY_MS) continue } + if (isNetworkUnreachableError(error)) { + waitForNetwork(error) + return getAppUpdateState() + } setUpdateState(makeState({ phase: 'error', message: humanizeUpdateError(error) })) return getAppUpdateState() } @@ -650,6 +793,101 @@ function readOsReleaseOrNull(): string | null { } } +/** How this copy was installed, in the words a bug report wants (#814). The + * Linux answer reuses the detection the updater itself relies on, so what + * `:version` prints is the format the updater will act on. */ +export function installLabel(input: { + isPackaged: boolean + platform: NodeJS.Platform + mas: boolean + windowsStore: boolean + portableExecutableDir: string | undefined + linuxFormat: () => LinuxPackageFormat +}): string { + if (!input.isPackaged) return 'development build' + switch (input.platform) { + case 'darwin': + return input.mas ? 'Mac App Store' : 'macOS app bundle' + case 'win32': + if (input.windowsStore) return 'Microsoft Store' + return input.portableExecutableDir ? 'portable exe' : 'NSIS installer' + case 'linux': + switch (input.linuxFormat()) { + case 'appimage': + return 'AppImage' + case 'deb': + return 'deb package' + case 'rpm': + return 'rpm package' + case 'pacman': + return 'pacman package' + case 'managed': + return 'package manager or tarball (updates are reported, not installed)' + default: + return 'Linux package (format unknown)' + } + default: + return input.platform + } +} + +/** PRETTY_NAME from os-release, e.g. `Ubuntu 24.04.1 LTS`, or null. */ +export function osReleasePrettyName(osRelease: string | null): string | null { + if (!osRelease) return null + for (const line of osRelease.split('\n')) { + const match = /^\s*PRETTY_NAME\s*=\s*(.*)$/.exec(line) + if (match) { + const value = match[1].trim().replace(/^["']|["']$/g, '') + if (value) return value + } + } + return null +} + +/** Operating system and install format for the app info the preload hands + * to the renderer (#814). Reads os-release once; nothing here may throw, + * because the preload asks for it synchronously while the window boots. */ +export function describeInstall(): { os: string; install: string } { + const systemVersion = (() => { + try { + return process.getSystemVersion() + } catch { + return '' + } + })() + const osRelease = process.platform === 'linux' ? readOsReleaseOrNull() : null + const os = + process.platform === 'darwin' + ? `macOS ${systemVersion}`.trim() + : process.platform === 'win32' + ? `Windows ${systemVersion}`.trim() + : process.platform === 'linux' + ? `${osReleasePrettyName(osRelease) ?? 'Linux'} (kernel ${systemVersion || 'unknown'})` + : `${process.platform} ${systemVersion}`.trim() + let install: string + try { + install = installLabel({ + isPackaged: app.isPackaged, + platform: process.platform, + mas: Boolean(process.mas), + windowsStore: Boolean(process.windowsStore), + portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR, + linuxFormat: () => + linuxUpdaterFormat({ + isAppImage: Boolean(process.env.APPIMAGE), + isOfficialSystemPackage: isOfficialLinuxSystemPackage( + process.resourcesPath, + existsSync(join(process.resourcesPath, 'package-type')) + ), + osRelease + }) + }) + } catch { + install = 'unknown' + } + return { os, install } +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 84ca5e2b..a64e11a5 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -1317,6 +1317,28 @@ describe('per-vault view settings round-trip (#292)', () => { expect((saved.view as Record | undefined)?.bogus).toBeUndefined() }) + // The renderer persists these three per vault too (#292, #730), but the + // main normalizer used to drop them on every save, so the choice never + // stuck. They also feed the Cloud settings comparison (#816), where a key + // dropped on one side shows up as a difference that is not there. + it('carries asset sort, kanban statuses and the kanban folder root', async () => { + const root = await makeTempDir('zennotes-vault-view-kanban-') + await ensureVaultLayout(root) + const base = await getVaultSettings(root) + await setVaultSettings(root, { + ...base, + view: { + assetSortOrder: 'modified-desc', + kanbanStatuses: ['todo', 42, 'done'], + kanbanFolderRoot: 'Projects' + } + } as Awaited>) + const saved = await getVaultSettings(root) + expect(saved.view?.assetSortOrder).toBe('modified-desc') + expect(saved.view?.kanbanStatuses).toEqual(['todo', 'done']) + expect(saved.view?.kanbanFolderRoot).toBe('Projects') + }) + it('omits the view block when there are no overrides', async () => { const root = await makeTempDir('zennotes-vault-noview-') await ensureVaultLayout(root) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 2809d28f..9c3cb375 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -1182,15 +1182,24 @@ function normalizeTasksSettings(raw: unknown): VaultSettings['tasks'] | undefine /** Carry the per-vault view overrides (#292) through the round-trip, keeping * only known keys. The renderer validates the values strictly; here we just - * preserve a clean object (or undefined when there are no overrides). */ + * preserve a clean object (or undefined when there are no overrides). Every + * key of VaultViewSettings must be listed: a key missing here is dropped on + * every save, so the renderer's per-vault choice silently never sticks. */ function normalizeVaultViewSettings(raw: unknown): VaultViewSettings | undefined { if (!raw || typeof raw !== 'object') return undefined const c = raw as Record const view: VaultViewSettings = {} if (typeof c.noteSortOrder === 'string') view.noteSortOrder = c.noteSortOrder + if (typeof c.assetSortOrder === 'string') view.assetSortOrder = c.assetSortOrder if (typeof c.groupByKind === 'boolean') view.groupByKind = c.groupByKind if (typeof c.tasksViewMode === 'string') view.tasksViewMode = c.tasksViewMode if (typeof c.kanbanGroupBy === 'string') view.kanbanGroupBy = c.kanbanGroupBy + if (typeof c.kanbanFolderRoot === 'string') view.kanbanFolderRoot = c.kanbanFolderRoot + if (Array.isArray(c.kanbanStatuses)) { + view.kanbanStatuses = c.kanbanStatuses.filter( + (status): status is string => typeof status === 'string' + ) + } if (c.kanbanColumnTitles && typeof c.kanbanColumnTitles === 'object') { view.kanbanColumnTitles = c.kanbanColumnTitles as Record } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 36850542..42f7b6c3 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -126,7 +126,27 @@ const DESKTOP_APP_INFO: ZenAppInfo = { description: appPackage.description, homepage: appPackage.homepage, runtime: 'desktop', - hostKind: 'desktop' + hostKind: 'desktop', + arch: process.arch, + engine: `Electron ${process.versions.electron} (Chromium ${process.versions.chrome}, Node ${process.versions.node})` +} + +// The OS version and install format live in main (os-release, resourcesPath, +// app.isPackaged). Fetched once, on the first getAppInfo() call, so the many +// `getAppInfo().runtime` checks at boot do not each pay for a sync IPC (#814). +let installInfo: Pick | null = null +function getInstallInfo(): Pick { + if (installInfo) return installInfo + try { + const result = ipcRenderer.sendSync(IPC.APP_INSTALL_INFO_SYNC) as { + os: string + install: string + } | null + installInfo = result ? { os: result.os, install: result.install } : {} + } catch { + installInfo = {} + } + return installInfo } let remoteWorkspaceInfo: RemoteWorkspaceInfo | null = null @@ -214,7 +234,7 @@ function remoteAssetUrl(assetPath: string): string | null { const api: ZenBridge = { getCapabilities: (): ZenCapabilities => DESKTOP_CAPABILITIES, - getAppInfo: (): ZenAppInfo => DESKTOP_APP_INFO, + getAppInfo: (): ZenAppInfo => ({ ...DESKTOP_APP_INFO, ...getInstallInfo() }), platform: (): Promise => ipcRenderer.invoke(IPC.APP_PLATFORM), platformSync: (): NodeJS.Platform => process.platform, listSystemFonts: (): Promise => ipcRenderer.invoke(IPC.APP_LIST_FONTS), diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 234c92e8..a3ffbfaf 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.2.0", - "commit": "909e66c3c1df4ded4e909a3ed607895f2486ea28", + "version": "0.4.0", + "commit": "6481d171916f6d84de05a50a81c93ffef4273ff7", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.2.0/zn_0.2.0_darwin_arm64.tar.gz", - "sha256": "45f54a5bd34bc2d5490b35c0daf3afeba019239682074c7a331feae90ed3213f" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_darwin_arm64.tar.gz", + "sha256": "1991df8d6893c4bab37ceb0eb34d3aab9026c7d69d265f12212e7ec05af887c3" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.2.0/zn_0.2.0_darwin_amd64.tar.gz", - "sha256": "af510fd44bc8b7d2f0e9cc0e4d9238b7046f30246c657f5d3f9e5f91e73d668a" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_darwin_amd64.tar.gz", + "sha256": "69861fbdfedca6710b0b44d9a39ab781ba3073bab6567cca5d92cedfed0c041f" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.2.0/zn_0.2.0_linux_arm64.tar.gz", - "sha256": "0cc01c1fd71402e0c0fe9e5a8ec0e14f9751aadb1f10efd9384f3b324931ab13" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_linux_arm64.tar.gz", + "sha256": "3fda655425d9f41b7961e1a8f119bb9282712c7877a31d19b3ac1548a478a2dd" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.2.0/zn_0.2.0_linux_amd64.tar.gz", - "sha256": "91fc9f2ab76d5444b7cd76285a1253d292adbaac96aad85d204571c6ae37d10f" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_linux_amd64.tar.gz", + "sha256": "03032ff80b14effcecb065533186e72416030b2b69b4c4d3fd67d8cadb73cede" } } } diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index cf8fd2d3..f631afa7 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.52.0", + "version": "2.53.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", @@ -13,9 +13,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@zennotes/app-core": "*", - "@zennotes/bridge-contract": "*", - "@zennotes/shared-domain": "*", "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.3.1", @@ -26,6 +23,9 @@ "@codemirror/view": "^6.35.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", "codemirror": "^6.0.1", "dompurify": "^3.3.4", "function-plot": "^1.25.3", diff --git a/apps/web/package.json b/apps/web/package.json index 297f12db..c4cc2252 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.52.0", + "version": "2.53.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 3381fc18..72781907 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -107,7 +107,12 @@ const WEB_APP_INFO: ZenAppInfo = { description: appPackage.description, homepage: appPackage.homepage, runtime: 'web', - hostKind: 'browser' + hostKind: 'browser', + // The user agent is the one line a browser can answer for `:version`; it + // carries the OS too, so no separate os field (#814). + ...(typeof navigator !== 'undefined' && navigator.userAgent + ? { engine: navigator.userAgent } + : {}) } // Base path under which the server is mounted (e.g. "/zennotes" when diff --git a/package-lock.json b/package-lock.json index a71e6c66..99b5b6ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.52.0", + "version": "2.53.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.52.0", + "version": "2.53.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.52.0", + "version": "2.53.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.52.0", + "version": "2.53.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.52.0", + "version": "2.53.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.52.0", + "version": "2.53.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.52.0", + "version": "2.53.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.52.0", + "version": "2.53.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.52.0" + "version": "2.53.0" } } } diff --git a/package.json b/package.json index 1dbe23b0..1d1d4455 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.52.0", + "version": "2.53.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 a4a8eb7f..b222eb0b 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.52.0", + "version": "2.53.0", "type": "module", "exports": { "./main": "./src/main.tsx", @@ -23,8 +23,6 @@ "./host": "./src/host.ts" }, "dependencies": { - "@zennotes/bridge-contract": "*", - "@zennotes/shared-domain": "*", "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-cpp": "^6.0.3", @@ -55,6 +53,8 @@ "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -84,15 +84,15 @@ "zustand": "^5.0.2" }, "devDependencies": { - "vite": "^6.4.3", - "vitest": "^3.2.6", - "typescript": "^5.7.2", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "autoprefixer": "^10.4.20", "postcss": "^8.5.10", "tailwindcss": "^3.4.17", - "autoprefixer": "^10.4.20", + "typescript": "^5.7.2", "vfile": "^6.0.3", - "@types/react": "^18.3.28", - "@types/react-dom": "^18.3.7" + "vite": "^6.4.3", + "vitest": "^3.2.6" }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 7edb77d3..d4385bce 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -24,6 +24,7 @@ import { ConfirmHost } from './components/ConfirmHost' import { DatePickerHost } from './components/DatePickerHost' import { PublishNoteHost } from './components/PublishNoteHost' import { CloudConflictReviewHost } from './components/CloudConflictReviewHost' +import { CloudSettingsConflictHost } from './components/CloudSettingsConflictHost' import { ServerDirectoryPickerHost } from './components/ServerDirectoryPickerHost' import { IconButton, ToastHost } from './components/ui' import { CloseIcon } from './components/icons' @@ -1260,6 +1261,7 @@ function App(): JSX.Element { + diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index e9192e30..fa5a7f12 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -1081,6 +1081,53 @@ describe("CloudSettings", () => { ); }); + // With the cloud's copy in hand the card says what differs and hands the + // per-setting choice to the shared prompt (#816). + it("names the settings that differ and opens the comparison prompt", async () => { + mocks.getCloudAccountStatus.mockResolvedValue(connected); + mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount); + mocks.getCloudVaultLink.mockResolvedValue({ + base_url: "https://zennotes.org", + vault_id: "vault-1", + vault_name: "Cloud Notes", + linked_at: "2026-08-10T12:00:00.000Z", + }); + mocks.listCloudVaults.mockResolvedValue([]); + const local = useStore.getState().vaultSettings; + mocks.getCloudSettingsConflict.mockResolvedValue({ + path: ".zennotes/vault.json", + cloud_path: ".zennotes/vault.cloud-conflict.json", + cloud_settings: { + ...JSON.parse(JSON.stringify(local)), + favorites: [...local.favorites, "inbox:Reading"], + folderColors: { ...local.folderColors, "inbox:Reading": "amber" }, + experimentalSpellcheck: { enabled: true }, + }, + }); + + await act(async () => + root.render( + createElement(CloudSettings, { + localVaultAvailable: true, + localVaultName: "Notes", + }), + ), + ); + + expect(host.textContent).toContain("What differs: Folder colors, Favorites."); + expect(host.textContent).toContain("settings this device does not use (experimentalSpellcheck)"); + + // Sync surfaced the question and opened the prompt; the card reopens it + // after a "Decide later". + useCloudSyncStatusStore.setState({ settingsConflictPromptOpen: false }); + const compare = [...host.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Compare and choose…", + ); + expect(compare).toBeTruthy(); + await act(async () => compare!.click()); + expect(useCloudSyncStatusStore.getState().settingsConflictPromptOpen).toBe(true); + }); + it("does not request vault data when sync is not included", async () => { mocks.getCloudAccountStatus.mockResolvedValue(connected); mocks.getCloudServiceAccount.mockResolvedValue({ diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index 8735772a..ed85aa4a 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -20,10 +20,17 @@ import { confirmApp } from "../lib/confirm-requests"; import { cloudSyncAttentionItems, cloudSyncAttentionMessage, + openCloudSettingsConflictPrompt, + refreshCloudSettingsConflict, + resolveCloudSettingsConflictWithStatus, useCloudSyncStatusStore, requestCloudAutoSync, syncCloudVaultWithStatus, } from "../lib/cloud-auto-sync"; +import { + describeVaultSettingsConflict, + VAULT_SETTINGS_SECTION_LABELS, +} from "../lib/vault-settings-conflict"; import { useToastStore } from "../lib/toast"; import { notifyPublishedNoteChanged } from "../lib/published-note-events"; import { requestPublishNote } from "../lib/publish-note-requests"; @@ -70,8 +77,9 @@ export function CloudSettings({ const [selectedVaultId, setSelectedVaultId] = useState(""); const [newVaultName, setNewVaultName] = useState(localVaultName); const [summary, setSummary] = useState(null); - const [settingsConflict, setSettingsConflict] = - useState(null); + // The settings question is the runtime's, not this panel's: sync raises it + // and the prompt answers it from anywhere, so this panel only shows it. + const settingsConflict = useCloudSyncStatusStore((state) => state.settingsConflict); const [backups, setBackups] = useState([]); const [backupSchedule, setBackupSchedule] = useState(null); @@ -105,7 +113,6 @@ export function CloudSettings({ setSelectedVaultId((selected) => remainingVaults.some((vault) => vault.id === selected) ? selected : (remainingVaults[0]?.id ?? "")); setSummary(null); - setSettingsConflict(null); setBackups([]); setBackupSchedule(null); setExpandedBackupId(null); @@ -383,24 +390,17 @@ export function CloudSettings({ }); }; - const loadSettingsConflict = useCallback(async (): Promise => { - try { - setSettingsConflict(await bridge.getCloudSettingsConflict()); - } catch { - // A host without the question (the web client) simply has none to ask. - setSettingsConflict(null); - } - }, [bridge]); - + // Opening Settings is a chance the runtime did not have: a question parked + // before this window existed (or while sync was off) shows up here too. useEffect(() => { - void loadSettingsConflict(); - }, [loadSettingsConflict]); + void refreshCloudSettingsConflict(bridge); + }, [bridge]); const syncVault = (): Promise => { setSummary(null); return runAction("sync", async () => { setSummary(await syncCloudVaultWithStatus(bridge, link?.vault_name)); - await loadSettingsConflict(); + await refreshCloudSettingsConflict(bridge); await refreshServiceAccount(); }); }; @@ -408,12 +408,8 @@ export function CloudSettings({ const resolveSettingsConflict = ( choice: CloudSyncSettingsChoice, ): Promise => - runAction( - choice === "cloud" ? "settings-cloud" : "settings-local", - async () => { - await bridge.resolveCloudSettingsConflict(choice); - await loadSettingsConflict(); - }, + runAction(choice === "cloud" ? "settings-cloud" : "settings-local", () => + resolveCloudSettingsConflictWithStatus(choice, bridge), ); const createBackup = (): Promise => @@ -1215,7 +1211,9 @@ function CloudVaultPanel({ {settingsConflict && ( )} {syncing && ( @@ -1924,15 +1922,28 @@ function numericLimit( * conflict copy to compare side by side, but settings are a single answer, and * a copy of them inside a hidden folder is not something anyone can act on. * This device's settings stay in use until the question is answered, so doing - * nothing keeps what is already working. + * nothing keeps what is already working. The card names the sections that + * differ and hands the per-section choice to the shared prompt; the two + * whole-file answers stay here for the common "just keep mine" case. */ function CloudSettingsConflictCard({ action, + conflict, onResolve, + onCompare, }: { action: CloudAction; + conflict: CloudSyncSettingsConflict; onResolve: (choice: CloudSyncSettingsChoice) => void; + onCompare: () => void; }): JSX.Element { + const localSettings = useStore((state) => state.vaultSettings); + const described = conflict.cloud_settings + ? describeVaultSettingsConflict(localSettings, conflict.cloud_settings) + : null; + const sections = described?.differences.map( + (difference) => VAULT_SETTINGS_SECTION_LABELS[difference.section], + ); return (
Vault settings differ from the cloud
- Another device saved different settings for this vault: favorites, - folder icons and colors, and where the built-in folders live. This - device’s settings are the ones in use. + {sections === undefined + ? "Another device saved different settings for this vault: favorites, folder icons and colors, and where the built-in folders live." + : sections.length === 0 + ? "Another device saved settings for this vault that this device reads the same way as its own." + : `Another device saved different settings for this vault. What differs: ${sections.join(", ")}.`}{" "} + This device’s settings are the ones in use. + {described !== null && described.unknownKeys.length > 0 && ( + <> + {" "} + The cloud’s copy also carries settings this device does not use ( + {described.unknownKeys.join(", ")}). + + )}
+ {sections !== undefined && sections.length > 0 && ( + + )} + +
+
+
+ {differences.map((difference) => ( + + setChoices((current) => ({ ...current, [difference.section]: side })) + } + /> + ))} +
+ + )} + {described !== null && described.unknownKeys.length > 0 && ( +
0 ? "mt-3" : ""}`} + > +
Not used on this device
+
{described.unknownKeys.join(", ")}
+
+ The cloud’s file also carries these settings from another app or a + newer version. This device has no such settings, so it cannot compare or + choose them. +
+
+ )} + {error && ( +
+ {error} +
+ )} + + +
+ This device’s settings stay in use until you decide. +
+ + {described === null && ( + // Without a readable copy there is nothing to pick from, so the + // whole-file alternative gets its own button. + + )} + +
+ + ); +} + +function describeQuestion( + vaultName: string, + described: ReturnType | null, + differences: VaultSettingsSectionDifference[], +): string { + if (described === null) { + return `Another device saved different settings for ${vaultName}, and the cloud's copy could not be read on this device, so the two cannot be compared here. Keep this device's settings or take the cloud's as a whole.`; + } + if (differences.length === 0) { + return `Another device saved settings for ${vaultName} that this device reads the same way as its own. Keep this device's settings to finish syncing.`; + } + const count = differences.length; + return `Another device saved different settings for ${vaultName}. ${count === 1 ? "One setting differs" : `${count} settings differ`}; both versions are shown so you can keep this device's or take the cloud's for each.`; +} + +function SectionRow({ + difference, + choice, + disabled, + onChoose, +}: { + difference: VaultSettingsSectionDifference; + choice: VaultSettingsSide; + disabled: boolean; + onChoose: (side: VaultSettingsSide) => void; +}): JSX.Element { + const label = VAULT_SETTINGS_SECTION_LABELS[difference.section]; + const shown = difference.fields.slice(0, FIELD_ROW_LIMIT); + const hidden = difference.fields.length - shown.length; + return ( +
+
+
{label}
+
+ onChoose("local")} + > + This device + + onChoose("cloud")} + > + Cloud + +
+
+ + + + + + + + + + {shown.map((field) => ( + + + + + + ))} + +
+ Setting + + This device + + Cloud +
+ {vaultSettingsFieldLabel(difference.section, field.path)} + + {formatVaultSettingsValue(difference.section, field.path, field.local)} + + {formatVaultSettingsValue(difference.section, field.path, field.cloud)} +
+ {hidden > 0 && ( +
+ and {hidden} more {hidden === 1 ? "difference" : "differences"} in this section +
+ )} +
+ ); +} + +function ChoiceButton({ + active, + disabled, + onClick, + children, +}: { + active: boolean; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}): JSX.Element { + return ( + + ); +} diff --git a/packages/app-core/src/components/CloudSettingsConflictHost.tsx b/packages/app-core/src/components/CloudSettingsConflictHost.tsx new file mode 100644 index 00000000..7b542c91 --- /dev/null +++ b/packages/app-core/src/components/CloudSettingsConflictHost.tsx @@ -0,0 +1,49 @@ +import type { VaultSettings } from "@shared/ipc"; +import { + closeCloudSettingsConflictPrompt, + resolveCloudSettingsConflictWithStatus, + useCloudSyncStatusStore, +} from "../lib/cloud-auto-sync"; +import { useStore } from "../store"; +import { CloudSettingsConflictDialog } from "./CloudSettingsConflictDialog"; + +/** + * Mounts the vault settings prompt for the whole app, beside the file + * conflict queue and for the same reason: sync raises the question from the + * runtime, and the status bar, palette and leader binding reopen it, so it + * cannot live inside Settings. The file queue takes the screen first when + * both are pending; the settings prompt waits for it to close. + */ +export function CloudSettingsConflictHost(): JSX.Element | null { + const conflict = useCloudSyncStatusStore((state) => state.settingsConflict); + const open = useCloudSyncStatusStore((state) => state.settingsConflictPromptOpen); + const fileReviewOpen = useCloudSyncStatusStore((state) => state.conflictReviewOpen); + const vaultName = useCloudSyncStatusStore((state) => state.vaultName); + const localSettings = useStore((state) => state.vaultSettings); + if (conflict === null || !open || fileReviewOpen) return null; + return ( + resolveCloudSettingsConflictWithStatus(choice)} + onApply={applyMergedVaultSettings} + /> + ); +} + +/** + * A per-section answer is this device's settings with some sections taken + * from the cloud. Saving them through the store gives them the same + * validation and pattern history as an edit in Settings; only once that save + * is on disk is the parked copy retired, as "keep this device's" (the merged + * file is now this device's, and the next run uploads it). + */ +async function applyMergedVaultSettings(settings: VaultSettings): Promise { + const saved = await useStore.getState().setVaultSettings(settings); + if (!saved) { + throw new Error("The settings could not be saved. The cloud's copy is still waiting."); + } + await resolveCloudSettingsConflictWithStatus("local"); +} diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 60c8ed91..333a6c92 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -310,12 +310,13 @@ export function CommandPalette(): JSX.Element { // closePalette's focus restore; the retry wins that race. Skipped when the // command opened Settings or another palette (search, vault text search, // outline, …) so we don't pull focus behind it, and likewise for the Cloud - // conflict queue and the Publish Note dialog, which claim focus themselves - // and are tracked outside the store. + // conflict queue, the Cloud settings prompt and the Publish Note dialog, + // which claim focus themselves and are tracked outside the store. if ( shouldRefocusEditorAfterCommand( useStore.getState(), useCloudSyncStatusStore.getState().conflictReviewOpen || + useCloudSyncStatusStore.getState().settingsConflictPromptOpen || getPublishNoteRequest() !== null ) ) diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 4e6ac8d7..107ff572 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -54,6 +54,8 @@ import { import { promptApp } from "../lib/prompt-requests"; import { offerCreateNoteFromLink } from "../lib/create-note-from-link"; import { openWikilinkAttachment } from "../lib/open-wikilink-attachment"; +import { buildVersionReport } from "../lib/version-report"; +import { writeClipboardText } from "../lib/clipboard-text"; import { externalFileLink, openExternalFileLink, @@ -359,6 +361,20 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { * unavailable. (#173) */ function alertEditorError(message: string): void { + showEditorNotification(message, { color: "red", duration: 4000 }); +} + +/** + * Bottom-of-editor Vim notification. Errors are red like codemirror-vim's + * own; informational output (`:version`) inherits the editor color, since + * red would read as "something failed". Multi-line text keeps its line + * breaks. Falls back to an alert (then refocuses) if the editor notification + * is unavailable. + */ +function showEditorNotification( + message: string, + opts: { color?: string; duration: number }, +): void { const view = useStore.getState().editorViewRef; const cm = view ? getCM(view) : null; const openNotification = ( @@ -372,16 +388,48 @@ function alertEditorError(message: string): void { if (cm && typeof openNotification === "function") { const el = document.createElement("div"); el.className = "cm-vim-message"; - el.style.color = "red"; + if (opts.color) el.style.color = opts.color; el.style.whiteSpace = "pre"; el.textContent = message; - openNotification.call(cm, el, { bottom: true, duration: 4000 }); + openNotification.call(cm, el, { bottom: true, duration: opts.duration }); return; } window.alert(message); focusEditorNormalMode(); } +/** + * `:version` prints the details a bug report needs (ZenNotes version, OS, + * engine, install format, remote server) instead of the stock + * codemirror-vim line that only named the Vim library (#814). `:version copy` + * or `:version!` also puts the text on the clipboard. + */ +function runVersionEx(argString: string): void { + const state = useStore.getState(); + const remote = + state.workspaceMode === "remote" + ? { + baseUrl: state.remoteWorkspaceInfo?.baseUrl ?? null, + version: state.remoteWorkspaceInfo?.capabilities?.version ?? null, + } + : null; + const lines = buildVersionReport({ + app: window.zen.getAppInfo(), + remoteServer: remote, + }); + const arg = argString.trim(); + const copy = arg === "!" || arg.toLowerCase() === "copy"; + const shown = [...lines]; + if (copy) { + shown.push( + writeClipboardText(lines.join("\n")) + ? "Copied to the clipboard" + : "Could not reach the clipboard", + ); + } + showEditorNotification(shown.join("\n"), { duration: 15000 }); +} + // Minimal shape of the CodeMirror-Vim adapter + state the display-line motion // touches (the package's own types don't surface these helpers). // The j/k display-line motion (#290) now lives in lib/cm-vim-display-line.ts, @@ -726,6 +774,16 @@ function registerVimCommands(): void { Vim.defineEx("template", "template", runTemplateEx); Vim.defineEx("tmpl", "tmpl", runTemplateEx); + // Replaces the library's own `:version`, which reported only the + // codemirror-vim version (#814). + Vim.defineEx( + "version", + "ve", + (_cm: unknown, params: { argString?: string } | undefined) => { + runVersionEx(params?.argString ?? ""); + }, + ); + Vim.defineEx("daily", "daily", () => { void useStore.getState().openTodayDailyNote(); }); diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 38d3b13c..1b2fdd5e 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -216,7 +216,9 @@ import { findRenderedHeadingForOutlineLine, nextOutlinePreviewSyncLockUntil, outlineHeadingTextOffset, + planPreviewJump, previewScrollTopForHeading, + previewShowsNote, scrollTopForElementRelativeTop, scrollTopForScrollRatio, shouldSyncPreviewFromEditorViewport @@ -1027,10 +1029,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // lets us tell our own restore scroll apart from a user scroll, so we never // yank a reader who scrolled during the render window. const previewRestoreTargetRef = useRef<{ path: string; top: number } | null>(null) - // Set when the user switches Edit/Split → Preview: the reading view opens on - // the line the cursor was on, instead of the top of the note. Applied (and - // cleared) once the preview has rendered blocks to anchor against. (#543) - const pendingPreviewCursorLineRef = useRef<{ path: string; line: number } | null>(null) + // A source line the reading view should open on once it has rendered blocks + // to anchor against: the cursor's line when the user switches Edit/Split → + // Preview (#543), or the target of a heading/block link followed while + // reading (android#74). Applied and cleared from `onRendered`. + const pendingPreviewLineRef = useRef<{ path: string; line: number } | null>(null) const lastProgrammaticPreviewTopRef = useRef(null) const lastRestoredPathRef = useRef(null) const vimCompartmentRef = useRef(null) @@ -1203,7 +1206,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { view && viewPathRef.current === activeTab ) { - pendingPreviewCursorLineRef.current = { + pendingPreviewLineRef.current = { path: activeTab, line: view.state.doc.lineAt(view.state.selection.main.head).number } @@ -1460,12 +1463,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return } // A mode switch out of editing carries the cursor's line into the - // reading view; the live editing position outranks a remembered - // preview offset from an earlier visit. (#543) - const cursorTarget = pendingPreviewCursorLineRef.current - if (cursorTarget && cursorTarget.path === content?.path) { - if (scrollPreviewToSourceLine(cursorTarget.line)) { - pendingPreviewCursorLineRef.current = null + // reading view, and a heading or block link followed while reading + // carries its target; either outranks a remembered preview offset from + // an earlier visit. (#543, android#74) + const lineTarget = pendingPreviewLineRef.current + if (lineTarget && lineTarget.path === content?.path) { + if (scrollPreviewToSourceLine(lineTarget.line)) { + pendingPreviewLineRef.current = null previewRestoreTargetRef.current = null return } @@ -1498,6 +1502,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { useEffect(() => { pendingPreviewOutlineJumpLineRef.current = null + // A line the previous note never got to render must not fire on a later + // visit. Declared before the pending-jump effect, so a jump that opens a + // note in reading mode still sets its line after this reset. + pendingPreviewLineRef.current = null outlinePreviewSyncLockUntilRef.current = 0 }, [content?.path]) @@ -2604,7 +2612,29 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (!isActive) return if (!content || !pendingJumpLocation || pendingJumpLocation.path !== content.path) return if (mode === 'preview') { - applyPaneMode('edit') + // Reading mode stays reading mode: a heading or block link, a search hit + // and Ctrl+O land the rendered preview on the spot instead of dropping + // the reader into the editor. Only a task jump still needs the editor, + // for the highlight it paints on the line. (android#74) + const plan = planPreviewJump(pendingJumpLocation, content.body) + if (plan.kind === 'edit') { + applyPaneMode('edit') + return + } + const previewEl = previewScrollRef.current + const rendered = previewShowsNote(previewEl, content.path) + if (plan.kind === 'restore') { + previewRestoreTargetRef.current = { path: content.path, top: plan.top } + if (rendered && previewEl) { + previewEl.scrollTop = plan.top + lastProgrammaticPreviewTopRef.current = previewEl.scrollTop + } + } else if (rendered && scrollPreviewToSourceLine(plan.line)) { + previewRestoreTargetRef.current = null + } else { + pendingPreviewLineRef.current = { path: content.path, line: plan.line } + } + clearPendingJumpLocation() return } const raf = requestAnimationFrame(() => { @@ -2649,7 +2679,15 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { clearPendingJumpLocation() }) return () => cancelAnimationFrame(raf) - }, [applyPaneMode, isActive, mode, content?.path, clearPendingJumpLocation, pendingJumpLocation]) + }, [ + applyPaneMode, + isActive, + mode, + content?.path, + clearPendingJumpLocation, + pendingJumpLocation, + scrollPreviewToSourceLine + ]) useEffect(() => { return () => { diff --git a/packages/app-core/src/components/HomeView.tsx b/packages/app-core/src/components/HomeView.tsx index b30994dc..ba117d0f 100644 --- a/packages/app-core/src/components/HomeView.tsx +++ b/packages/app-core/src/components/HomeView.tsx @@ -4,6 +4,15 @@ import { filterTasksForDisplay, type VaultTask } from '@shared/tasks' import { useStore } from '../store' import { computeTasksRender } from '../lib/tasks-filter' import { InlineMarkdown } from '../lib/inline-markdown' +import { getSystemFolderLabel } from '../lib/system-folder-labels' +import { + isPrimaryNotesAtRoot, + noteFolderSubpath, + resolveFavoriteItems, + type FavoriteItem +} from '../lib/vault-layout' +import { colorGlyphClassById, resolveFolderColorGlyphClass } from './FolderColors' +import { iconOptionById, resolveFolderIconOption } from './FolderIcons' import { ArrowUpRightIcon, CalendarIcon, @@ -13,6 +22,7 @@ import { ExcalidrawIcon, NotePlusIcon, PanelLeftIcon, + StarIcon, ZapIcon } from './icons' @@ -49,8 +59,8 @@ function timeAgo(ts: number, now: number): string { } /** A light landing view shown when no note is open: the few most recently - * edited notes plus the open tasks for today. Keyboard: ↑/↓ (and j/k in vim - * mode) move between rows, Enter opens. */ + * edited notes, the vault's favorites, plus the open tasks for today. + * Keyboard: ↑/↓ (and j/k in vim mode) move between rows, Enter opens. */ export function HomeView({ sidebarOpen, onShowSidebar @@ -59,11 +69,14 @@ export function HomeView({ onShowSidebar: () => void }): JSX.Element { const notes = useStore((s) => s.notes) + const folders = useStore((s) => s.folders) const vaultTasks = useStore((s) => s.vaultTasks) const showArchivedTasks = useStore((s) => s.showArchivedTasks) const tasksLoading = useStore((s) => s.tasksLoading) const vimMode = useStore((s) => s.vimMode) const selectNote = useStore((s) => s.selectNote) + const setView = useStore((s) => s.setView) + const systemFolderLabels = useStore((s) => s.systemFolderLabels) const openTaskAt = useStore((s) => s.openTaskAt) const toggleTaskFromList = useStore((s) => s.toggleTaskFromList) const refreshTasks = useStore((s) => s.refreshTasks) @@ -145,6 +158,55 @@ export function HomeView({ [notes] ) + // Favorites (#810) share the sidebar's resolver, so a stale key (note moved + // to trash, folder deleted) disappears from both surfaces at the same time. + // Stored order is kept: the user arranged the list, the home view shows it. + const favorites = useMemo( + () => resolveFavoriteItems(vaultSettings.favorites, notes, folders), + [vaultSettings.favorites, notes, folders] + ) + + // Where a favorited note lives, as the breadcrumb would spell it: the + // system-folder label (skipped for inbox notes kept at the vault root) and + // the subpath segments. Empty for a top-level note. + const favoriteNoteLocation = useCallback( + (path: string): string => { + const note = notes.find((n) => n.path === path) + if (!note) return '' + const segments: string[] = [] + if (!(note.folder === 'inbox' && isPrimaryNotesAtRoot(vaultSettings))) { + segments.push(getSystemFolderLabel(note.folder, systemFolderLabels)) + } + const sub = noteFolderSubpath(note, vaultSettings) + if (sub) segments.push(...sub.split('/')) + return segments.join(' / ') + }, + [notes, vaultSettings, systemFolderLabels] + ) + + // A favorited folder opens as the folder view, the same target the sidebar's + // Favorites row uses. Two things make that visible from here: the sidebar + // comes back if it was hidden, and the folder plus its ancestors expand in + // the tree. Folders start collapsed, and in the unified sidebar the tree is + // the only place the folder's notes appear, so a highlighted collapsed row + // would look like nothing happened. + const openFavoriteFolder = useCallback( + (item: Extract) => { + if (!sidebarOpen) onShowSidebar() + const chain = new Set([`${item.folder}:`]) + let acc = '' + for (const segment of item.subpath.split('/')) { + acc = acc ? `${acc}/${segment}` : segment + chain.add(`${item.folder}:${acc}`) + } + const { collapsedFolders, setCollapsedFolders } = useStore.getState() + const expanded = collapsedFolders.filter((key) => !chain.has(key)) + if (expanded.length !== collapsedFolders.length) setCollapsedFolders(expanded) + setView({ kind: 'folder', folder: item.folder, subpath: item.subpath }) + }, + [sidebarOpen, onShowSidebar, setView] + ) + const { today, overdueCount } = useMemo(() => { const render = computeTasksRender( filterTasksForDisplay(vaultTasks, showArchivedTasks), @@ -234,7 +296,7 @@ export function HomeView({ ))} -
+
} text="Recent" /> {recent.length > 0 ? (
    @@ -243,6 +305,7 @@ export function HomeView({
-
+ {favorites.length > 0 && ( +
+ } text="Favorites" /> +
    + {favorites.map((item) => { + // Same glyph and tint the sidebar gives the favorite, so the + // item is recognisable across both surfaces. + const icon = + item.kind === 'note' + ? vaultSettings.folderIcons[item.path] + ? iconOptionById(vaultSettings.folderIcons[item.path]).icon + : item.isDrawing + ? + : + : resolveFolderIconOption(item.folder, item.subpath, vaultSettings.folderIcons) + .icon + const colorClass = + item.kind === 'note' + ? colorGlyphClassById(vaultSettings.folderColors[item.path]) + : resolveFolderColorGlyphClass( + item.folder, + item.subpath, + vaultSettings.folderColors + ) + const label = item.kind === 'note' ? item.title || 'Untitled' : item.label + const location = item.kind === 'note' ? favoriteNoteLocation(item.path) : 'Folder' + return ( +
  • + +
  • + ) + })} +
+
+ )} + +
} text="Today" diff --git a/packages/app-core/src/components/OnboardingWizard.tsx b/packages/app-core/src/components/OnboardingWizard.tsx index 97ee438b..41d779c7 100644 --- a/packages/app-core/src/components/OnboardingWizard.tsx +++ b/packages/app-core/src/components/OnboardingWizard.tsx @@ -782,7 +782,7 @@ function LayoutStep({ primaryLocation: PrimaryNotesLocation dailyEnabled: boolean dailyDirectory: string - setVaultSettings: (next: VaultSettings) => Promise + setVaultSettings: (next: VaultSettings) => Promise hasVault: boolean onBack: () => void onNext: () => void diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 31315427..ebaa663d 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -860,6 +860,11 @@ export const Preview = memo(function Preview({ // not found" and zero-size boards (#68). Mermaid renders to inline SVG, so // it is safe to render in the detached buffer above. root.replaceChildren(...Array.from(stage.childNodes)); + // Which note these blocks belong to. Until this swap the article still + // shows the previous note, and a jump that lands while a render is in + // flight must wait for `onRendered` instead of scrolling against the + // wrong blocks (see previewShowsNote). + root.dataset.notePath = notePath; await renderDiagrams(root, { themeKey: diagramTheme.key, expanded: false }); if (cancelled) return; // Typst math (a no-op when the KaTeX renderer is active, since it emits no diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 80b1e33b..4f2f5477 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -125,7 +125,12 @@ import { type SettingsSearchCategory, } from "../lib/settings-search"; import { useAppUpdateState } from "../lib/app-update-state"; -import { getZenBridge } from "@zennotes/bridge-contract/bridge"; +import { buildVersionReport } from "../lib/version-report"; +import { writeClipboardText } from "../lib/clipboard-text"; +import { + getZenBridge, + type ZenAppInfo, +} from "@zennotes/bridge-contract/bridge"; import companyLogo from "../assets/lumary-labs-logo.svg"; import { confirmApp } from "../lib/confirm-requests"; import { promptApp } from "../lib/prompt-requests"; @@ -363,6 +368,8 @@ function formatUpdatePhaseLabel(phase: AppUpdateState["phase"]): string { return "Ready to install"; case "installing": return "Installing"; + case "offline": + return "Waiting for network"; case "error": return "Update error"; case "idle": @@ -382,6 +389,8 @@ function updatePhaseBadgeClass(phase: AppUpdateState["phase"]): string { return "border-paper-300/70 bg-paper-100/85 text-ink-700"; case "error": return "border-red-400/25 bg-red-500/10 text-red-700"; + case "offline": + return "border-amber-500/30 bg-amber-500/10 text-amber-700"; case "not-available": return "border-emerald-400/25 bg-emerald-500/10 text-emerald-700"; case "unsupported": @@ -808,7 +817,11 @@ export function SettingsModal(): JSX.Element { window.alert(state.message); return; } - if (state.phase === "unsupported" || state.phase === "error") { + if ( + state.phase === "unsupported" || + state.phase === "offline" || + state.phase === "error" + ) { window.alert(state.message); } }, @@ -5071,8 +5084,18 @@ export function SettingsModal(): JSX.Element { { id: "zen-notes-version", title: "ZenNotes version", - description: "App identity, current version, and product details.", - keywords: ["about", "version", "identity"], + description: + "App identity, current version, and the details to paste into a bug report.", + keywords: [ + "about", + "version", + "identity", + "bug report", + "electron", + "os", + "install", + "copy details", + ], }, { id: "updates", @@ -5115,6 +5138,7 @@ export function SettingsModal(): JSX.Element { v{appInfo.version} +
s.workspaceMode); + const remoteWorkspaceInfo = useStore((s) => s.remoteWorkspaceInfo); + const [copied, setCopied] = useState(false); + const lines = buildVersionReport({ + app: appInfo, + remoteServer: + workspaceMode === "remote" + ? { + baseUrl: remoteWorkspaceInfo?.baseUrl ?? null, + version: remoteWorkspaceInfo?.capabilities?.version ?? null, + } + : null, + }); + const text = lines.join("\n"); + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(timer); + }, [copied]); + return ( +
+
+
+ Version details +
+ +
+
+        {text}
+      
+

+ Paste these into a bug report. In Vim mode, :version{" "} + prints the same lines and :version copy copies them. +

+
+ ); +} + /** Desktop-only: surfaces the on-disk config file so users can find, copy, or * open the plain-text TOML they sync across machines (issue #203). */ function ConfigFileSection(): JSX.Element { diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 74ed703c..5ed3f528 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -79,13 +79,13 @@ import { dateNoteDirectoryDisplayLabel, favoriteFolderKey, folderIconKey, - isFavoriteFolderKey, isPrimaryNotesAtRoot, folderForVaultRelativePath, normalizeVaultSettings, noteFolderSubpath, - parseFavoriteFolderKey, + resolveFavoriteItems, sidebarRevealTarget, + type FavoriteItem, } from "../lib/vault-layout"; import { resolveFolderPath } from "@shared/system-folder-paths"; import { @@ -323,11 +323,6 @@ type SidebarSelectionItem = | { kind: "note"; path: string } | { kind: "folder"; folder: NoteFolder; subpath: string }; -/** A favorite resolved to a live note or folder for rendering. */ -type FavoriteItem = - | { kind: "note"; key: string; path: string; title: string; isDrawing: boolean } - | { kind: "folder"; key: string; folder: NoteFolder; subpath: string; label: string }; - function noteSelectionKey(path: string): string { return `note:${encodeURIComponent(path)}`; } @@ -1261,40 +1256,12 @@ export function Sidebar(): JSX.Element { return next; }, [notes, allFolders, assetFiles, vaultSettings]); - // Resolve favorite keys to live notes/folders. Keys whose target no longer - // exists (renamed away, deleted, trashed) are silently skipped — the Favorites - // section never shows a broken row. Order follows the stored favorites list. - const favoriteItems = useMemo(() => { - const out: FavoriteItem[] = []; - for (const key of vaultSettings.favorites) { - if (isFavoriteFolderKey(key)) { - const parsed = parseFavoriteFolderKey(key); - if (!parsed || !parsed.subpath) continue; - const exists = allFolders.some( - (f) => f.folder === parsed.folder && f.subpath === parsed.subpath, - ); - if (!exists) continue; - out.push({ - kind: "folder", - key, - folder: parsed.folder, - subpath: parsed.subpath, - label: parsed.subpath.split("/").slice(-1)[0], - }); - } else { - const note = notes.find((n) => n.path === key); - if (!note || note.folder === "trash") continue; - out.push({ - kind: "note", - key, - path: note.path, - title: note.title, - isDrawing: isExcalidrawPath(note.path), - }); - } - } - return out; - }, [vaultSettings.favorites, notes, allFolders]); + // Shared with the home view's Favorites section, so both surfaces agree on + // which keys still resolve to a live note or folder. + const favoriteItems = useMemo( + () => resolveFavoriteItems(vaultSettings.favorites, notes, allFolders), + [vaultSettings.favorites, notes, allFolders], + ); // Daily/weekly notes grouped for the pinned date-nav: daily by year → month → // day, weekly by year → week, all newest-first. diff --git a/packages/app-core/src/components/StatusBar.tsx b/packages/app-core/src/components/StatusBar.tsx index da776872..7f94dcc8 100644 --- a/packages/app-core/src/components/StatusBar.tsx +++ b/packages/app-core/src/components/StatusBar.tsx @@ -5,9 +5,11 @@ import { backlinksForNote } from "../lib/wikilinks"; import { countWords } from "../lib/word-count"; import { useHoveredLinkStore } from "../lib/hovered-link"; import { + cloudSyncAttentionIsSettingsOnly, connectCloudAccountFromStatusBar, formatRelativeSyncTime, openCloudConflictReview, + openCloudSettingsConflictPrompt, resolvableCloudConflictCount, syncCloudVaultWithStatus, type CloudSyncPhase, @@ -100,6 +102,7 @@ function CloudSyncStatus({ const lastSyncedAt = useCloudSyncStatusStore((state) => state.lastSyncedAt); const error = useCloudSyncStatusStore((state) => state.error); const lastSummary = useCloudSyncStatusStore((state) => state.lastSummary); + const settingsOnly = useCloudSyncStatusStore(cloudSyncAttentionIsSettingsOnly); const setSettingsOpen = useStore((state) => state.setSettingsOpen); const [now, setNow] = useState(() => Date.now()); const resolvableConflictCount = resolvableCloudConflictCount(lastSummary); @@ -127,7 +130,9 @@ function CloudSyncStatus({ : phase === "attention" ? hasResolvableConflict ? `${resolvableConflictCount} ${resolvableConflictCount === 1 ? "file needs" : "files need"} review` - : "Sync incomplete" + : settingsOnly + ? "Settings need review" + : "Sync incomplete" : phase === "error" ? "Sync failed" : lastSyncedAt === null @@ -195,6 +200,12 @@ function CloudSyncStatus({ openCloudConflictReview(); return; } + if (settingsOnly) { + // The prompt is where the settings decision is made; Settings only + // repeats the question. + openCloudSettingsConflictPrompt(); + return; + } if (phase === "attention") { openCloudSettings(); return; diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index ee938c35..69f4e7d5 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -44,8 +44,10 @@ category: Custom {{cursor}} ` +// The editor fills whatever height its container gives it: the container is +// what changes between the side-by-side and the stacked layout below. const editorTheme = EditorView.theme({ - '&': { height: '60vh', fontSize: '13px', backgroundColor: 'transparent' }, + '&': { height: '100%', fontSize: '13px', backgroundColor: 'transparent' }, '&.cm-focused': { outline: 'none' }, '.cm-scroller': { fontFamily: 'var(--font-mono, ui-monospace, monospace)', @@ -224,26 +226,35 @@ export function TemplateEditorModal({ {vimMode ? 'Vim · ' : ''}YAML frontmatter + markdown body
-
-
-
+ {/* Side by side from the md breakpoint up; below it (phones, a narrow + window) the two halves would each be too slim to use, so the editor + sits above the preview and takes the larger share of the height. + Decided by the viewport, not the platform. (android#78) */} +
+
+
{preview || Preview…}
-
- Variables + {/* One scrolling row of chips when stacked: wrapped, ten chips would + eat the height the editor just gained. */} +
+ Variables {TEMPLATE_VARIABLES.map((variable) => ( ))} - — or type {'{{'} to autocomplete + or type {'{{'} to autocomplete