Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
42 changes: 41 additions & 1 deletion apps/desktop/src/cli/args.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
})
})
23 changes: 21 additions & 2 deletions apps/desktop/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* zn <command> [<subcommand>] [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 {
Expand All @@ -14,6 +14,25 @@ export interface ParsedArgs {
flags: Map<string, string[]>
}

/**
* Long flags that are switches, never `--flag <value>`. 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<string> = 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<string, string[]>()
Expand All @@ -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 {
Expand Down
36 changes: 34 additions & 2 deletions apps/desktop/src/cli/commands/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}): 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
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 16 additions & 5 deletions apps/desktop/src/cli/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -106,13 +112,17 @@ export async function cmdOpen(vault: string, args: ParsedArgs): Promise<void> {
}

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
Expand Down Expand Up @@ -140,10 +150,11 @@ export async function cmdOpen(vault: string, args: ParsedArgs): Promise<void> {
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}`)
}
}
5 changes: 3 additions & 2 deletions apps/desktop/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [
{
heading: 'OPEN',
rows: [
{ name: 'open <path>', description: 'Open markdown files, or a folder / vault (a focused session), in the app' }
{ name: 'open <path>', description: 'Open markdown files, or a folder / vault (a focused session), in the app', flags: '-n, --new-window' }
]
},
{
Expand Down Expand Up @@ -215,7 +215,8 @@ const EXAMPLES: string[] = [
'zn comment list inbox/Plan.md',
'zn comment reply inbox/Plan.md <id> "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[] {
Expand Down
13 changes: 10 additions & 3 deletions apps/desktop/src/main/cloud-sync-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 () => {
Expand Down
39 changes: 28 additions & 11 deletions apps/desktop/src/main/cloud-sync-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,14 +507,21 @@ export class DesktopCloudSyncService {
* it by accident. */
async settingsConflict(localRoot: string): Promise<CloudSyncSettingsConflict | null> {
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 } : {})
}
}

Expand All @@ -528,17 +535,14 @@ export class DesktopCloudSyncService {
): Promise<void> {
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<typeof setVaultSettings>[1])
await setVaultSettings(
localRoot,
parsed as unknown as Parameters<typeof setVaultSettings>[1]
)
}
await fs.rm(parked, { force: true })
}
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown>
}

function isCloudVaultLink(value: unknown): value is CloudVaultLink {
if (!value || typeof value !== 'object') return false
const link = value as Partial<CloudVaultLink>
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/main/file-open.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'])
})
})
13 changes: 13 additions & 0 deletions apps/desktop/src/main/file-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading
Loading