Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
14ad759
Test(store): every note-integrity test settles its dirty buffers befo…
adibhanna Sep 24, 2026
eada4bc
Fix(tooling): the Vim editor smoke puts focus back before each check …
adibhanna Sep 24, 2026
94c05d2
Test(store): name the loaded store module's type directly
adibhanna Sep 24, 2026
f8b24c0
Fix(vault): a host whose root is a label names the folder itself (#692)
adibhanna Sep 24, 2026
02ef25c
Fix(shell): the snapshot carries the vault's folder name (#692)
adibhanna Sep 25, 2026
5f8095b
Fix(mcp): the bundled MCP follows the app to another vault, and never…
adibhanna Sep 27, 2026
4d56f22
Fix(split): the editor and the preview stay on the same content, whic…
adibhanna Sep 28, 2026
08ec14a
Chore(terminal): pin ZenNotes CLI 0.4.1 for the bundled zn
adibhanna Sep 28, 2026
db8eca7
Fix(editor): F3 brings the find bar back, and Mod+F follows its bindi…
adibhanna Sep 28, 2026
67a65b9
Fix(atlas): notes added after the map was drawn land beside their lin…
adibhanna Sep 28, 2026
21ac81d
Fix(home): a note opened or created from Home takes the keyboard (#863)
adibhanna Sep 28, 2026
5a7a40d
Fix(notes): a note replaced from outside stays open, unsaved text and…
adibhanna Sep 28, 2026
6f18169
Fix(notes): a note opened from Connections, Assets, the Atlas or Clou…
adibhanna Sep 28, 2026
4326847
Feat(editor): list items and tasks fold like headings, in Edit and Pr…
adibhanna Sep 28, 2026
0ef69fc
Feat(callouts): > [!type]- starts collapsed and + expanded, as in Obs…
adibhanna Sep 28, 2026
2071ce1
Fix(mcp): keep case-sensitive server vaults behind the switch guard
adibhanna Sep 28, 2026
a23bcd5
Fix(editor): Fold All keeps code blocks and other syntax folds
adibhanna Sep 28, 2026
2e10d06
Fix(editor): pinned references follow search keymap changes immediately
adibhanna Sep 28, 2026
1a062de
Fix(split): keep both pane ends reachable at boundary anchors
adibhanna Sep 28, 2026
08096c0
Release: align desktop and shared packages at 2.57.0
adibhanna Sep 28, 2026
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.56.0",
"version": "2.57.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
58 changes: 57 additions & 1 deletion apps/desktop/src/cli/vault-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
resolveDefaultTarget,
resolveTarget,
resolveServerTarget,
resolveVaultTarget
resolveVaultTarget,
sameVault,
type VaultTarget
} from './vault-target'

let tmpDir: string
Expand Down Expand Up @@ -290,3 +292,57 @@ describe('the default target follows the workspace the app has open (#688)', ()
expect(await resolveDefaultTarget(process.env)).toEqual({ kind: 'local', root: workVault })
})
})

describe('sameVault: a vault switch is a different vault, not new credentials', () => {
const local = (root: string): VaultTarget => ({ kind: 'local', root })
const server = (baseUrl: string, name = '', authToken: string | null = null): VaultTarget => ({
kind: 'remote',
name,
baseUrl,
authToken
})

it('matches a directory however its path is spelled, symlinks included', async () => {
const other = path.join(tmpDir, 'other')
await fsp.mkdir(other, { recursive: true })
// Windows only allows symlinks with developer mode or elevation.
const link = path.join(tmpDir, 'work-link')
const linked = await fsp.symlink(workVault, link, 'dir').then(
() => link,
() => workVault
)
expect(await sameVault(local(workVault), local(workVault))).toBe(true)
expect(await sameVault(local(workVault), local(`${other}/../work/`))).toBe(true)
expect(await sameVault(local(workVault), local(linked))).toBe(true)
expect(await sameVault(local(workVault), local(other))).toBe(false)
expect(await sameVault(local(path.join(tmpDir, 'a')), local(path.join(tmpDir, 'b')))).toBe(false)
})

it('matches a server by URL whatever the token or profile name', async () => {
expect(
await sameVault(
server('https://notes.example.com', 'home', 'old'),
server('https://notes.example.com', '', 'new')
)
).toBe(true)
expect(await sameVault(server('https://Notes.Example.com/'), server('https://notes.example.com'))).toBe(true)
expect(await sameVault(server('notes.example.com:7878'), server('http://notes.example.com:7878'))).toBe(true)
expect(await sameVault(server('http://notes.example.com:7878'), server('http://notes.example.com:7879'))).toBe(false)
expect(await sameVault(local(workVault), server('https://notes.example.com'))).toBe(false)
})

it('normalizes the origin without changing the case of a reverse-proxy path', async () => {
expect(
await sameVault(server('https://notes.example.com/Work'), server('https://notes.example.com/work'))
).toBe(false)
expect(
await sameVault(server('HTTPS://Notes.Example.com:443/Work/'), server('https://notes.example.com/Work'))
).toBe(true)
})

it('keeps query values case-sensitive too', async () => {
expect(
await sameVault(server('https://notes.example.com/?vault=Work'), server('https://notes.example.com/?vault=work'))
).toBe(false)
})
})
27 changes: 27 additions & 0 deletions apps/desktop/src/cli/vault-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* is immediately nameable from the CLI with nothing else to configure.
*/

import { promises as fsp } from 'node:fs'
import path from 'node:path'
import { normalizeBaseUrl } from '../main/remote/connection.js'
import {
readActiveWorkspaceFromConfig,
Expand All @@ -32,6 +34,31 @@ export type VaultTarget =
| { kind: 'local'; root: string }
| { kind: 'remote'; name: string; baseUrl: string; authToken: string | null }

/** Two targets reach the same vault: the same server URL, or the same
* directory however its path is spelled. Tokens and profile names do not
* matter. */
export async function sameVault(a: VaultTarget, b: VaultTarget): Promise<boolean> {
if (a.kind === 'remote' || b.kind === 'remote') {
// URL normalizes the origin while preserving case-sensitive paths and
// query values: a reverse proxy may serve different vaults at /Work and /work.
return (
a.kind === 'remote' &&
b.kind === 'remote' &&
new URL(normalizeBaseUrl(a.baseUrl)).href === new URL(normalizeBaseUrl(b.baseUrl)).href
)
}
if (path.resolve(a.root) === path.resolve(b.root)) return true
try {
const [left, right] = await Promise.all([
fsp.stat(a.root, { bigint: true }),
fsp.stat(b.root, { bigint: true })
])
return left.dev === right.dev && left.ino === right.ino
} catch {
return false
}
}

/** Token precedence, loudest first: an explicit `--token`, then the
* environment (the CI / headless case, where nothing is stored on disk),
* then whatever the saved profile carries. Mirrors how `--vault` beats
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/mcp/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ when the folder is \`Linear Algebra/\`), synonyms, and feeling tags
\`due:YYYY-MM-DD\`, \`!high\` / \`!med\` / \`!low\`, \`@waiting\`, and
\`#tag\`.

**Callouts**: \`> [!note]\`, \`> [!tip]\`, \`> [!warning]\`.
**Callouts**: \`> [!note]\`, \`> [!tip]\`, \`> [!warning]\`. A \`-\` right after the
type (\`> [!example]- Screenshots\`) makes the callout start collapsed, a \`+\`
starts it expanded; use \`-\` for long blocks a reader opens on demand.

## Tool etiquette

Expand Down
142 changes: 132 additions & 10 deletions apps/desktop/src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
describeToolError,
listToolNames,
runMcpServer,
VaultSwitchedError,
type McpServerOptions
} from './server'

Expand Down Expand Up @@ -154,6 +155,19 @@ describe('describeToolError', () => {
})
})

describe('VaultSwitchedError', () => {
it('names a saved server by its quoted name and URL, and never the token', () => {
const err = new VaultSwitchedError(
{ kind: 'remote', name: 'Server Vault (10.0.0.5:7878)', baseUrl: 'http://10.0.0.5:7878', authToken: 'tok-secret' },
{ kind: 'local', root: '/notes' }
)
expect(err.message).toContain(
'it was the server "Server Vault (10.0.0.5:7878)" at http://10.0.0.5:7878 and is now the local vault /notes.'
)
expect(err.message).not.toContain('tok-secret')
})
})

describe('comment tools (#738)', () => {
it('lists the four comment tools', () => {
const names = listToolNames()
Expand Down Expand Up @@ -270,12 +284,16 @@ describe('runMcpServer follows the target it is given (#831)', () => {
await runMcpServer({ ...options, transport: serverTransport })
const client = new Client({ name: 'server-test', version: '0' })
await client.connect(clientTransport)
const call = async (name: string, args: Record<string, unknown> = {}) => {
const result = await client.callTool({ name, arguments: args })
return { text: (result.content as Array<{ text: string }>)[0].text, isError: !!result.isError }
}
return {
stderr,
call,
vaultInfo: async () => {
const result = await client.callTool({ name: 'vault_info', arguments: {} })
const text = (result.content as Array<{ text: string }>)[0].text
return result.isError ? { error: text } : { info: JSON.parse(text) as Record<string, unknown> }
const { text, isError } = await call('vault_info')
return isError ? { error: text } : { info: JSON.parse(text) as Record<string, unknown> }
},
close: () => client.close()
}
Expand Down Expand Up @@ -347,13 +365,13 @@ describe('runMcpServer follows the target it is given (#831)', () => {
}
})

it('pins the first vault that resolves and retries only after a failure', async () => {
// One attempt at startup (warned), one per failing tool call, then the
// session keeps the first vault that resolved.
it('retries until a vault resolves, and starts the session at the first call it serves', async () => {
// One attempt at startup (warned), then one per tool call.
const outcomes: Array<Error | VaultTarget> = [
new Error('not at startup'),
new Error('not yet'),
{ kind: 'local', root: beta },
{ kind: 'local', root: beta },
{ kind: 'local', root: alpha }
]
let calls = 0
Expand All @@ -366,10 +384,114 @@ describe('runMcpServer follows the target it is given (#831)', () => {
})
expect(s.stderr.join('')).toContain('not at startup')
expect((await s.vaultInfo()).error).toBe('Error: not yet')
expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta })
// A further call must not move the session to alpha: the target is pinned.
expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta })
expect(calls).toBe(3)
// Nothing was served before, so beta is no switch.
const first = (await s.vaultInfo()).info
expect(first).toMatchObject({ vaultRoot: beta })
expect(String(first?.notes)).not.toContain('The vault changed')
expect((await s.call('list_notes')).isError).toBe(false)
// The app moved to alpha: the next call stops instead of running there.
const moved = await s.call('list_notes')
expect(moved.isError).toBe(true)
expect(moved.text).toContain(`it was the local vault ${beta} and is now the local vault ${alpha}`)
expect(calls).toBe(5)
await s.close()
})

it('a vault the app left before the first call is no switch', async () => {
const s = await session()
await writeConfig({ vaultRoot: beta, localVaults: [{ root: beta, name: 'beta' }] })
const listed = await s.call('list_notes')
expect(listed.isError).toBe(false)
await s.close()
})

it('refuses writes between case-sensitive server paths until vault_info confirms the switch', async () => {
const writes: string[] = []
const fake = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost')
const prefix = url.pathname.startsWith('/Work/') ? '/Work' : '/work'
const route = url.pathname.slice(prefix.length)
if (req.method === 'POST') writes.push(url.pathname)
const bodies: Record<string, unknown> = {
'/api/vault': { root: `/srv${prefix}`, name: prefix.slice(1) },
'/api/vault/settings': { primaryNotesLocation: 'inbox', systemFolderPaths: null },
'/api/folders': [],
'/api/notes/write': { path: 'inbox/Plan.md', title: 'Plan' }
}
res.writeHead(route in bodies ? 200 : 404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(bodies[route] ?? null))
})
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve))
const address = fake.address()
if (address == null || typeof address === 'string') throw new Error('no port')
const origin = `http://127.0.0.1:${address.port}`
let target: VaultTarget = { kind: 'remote', baseUrl: `${origin}/Work`, name: '', authToken: null }
let s: Awaited<ReturnType<typeof session>> | undefined

try {
s = await session({ resolveTarget: async () => target })
expect((await s.vaultInfo()).info).toMatchObject({ server: `${origin}/Work` })

target = { ...target, baseUrl: `${origin}/work` }
const refused = await s.call('write_note', { path: 'inbox/Plan.md', body: 'planned for Work' })
expect(writes).toEqual([])
expect(refused.isError).toBe(true)
expect(refused.text).toContain('The ZenNotes vault changed')

const { info } = await s.vaultInfo()
expect(info).toMatchObject({ server: `${origin}/work` })
expect(String(info?.notes)).toContain(`until this call the session worked in the server at ${origin}/Work`)
expect((await s.call('write_note', { path: 'inbox/Plan.md', body: 'planned for work' })).isError).toBe(false)
expect(writes).toEqual(['/work/api/notes/write'])
} finally {
await s?.close()
await new Promise<void>((resolve) => fake.close(() => resolve()))
}
})

it('follows the app to another vault only after vault_info confirms it', async () => {
await fsp.writeFile(path.join(alpha, 'inbox', 'Alpha.md'), '# Alpha\n')
await fsp.writeFile(path.join(beta, 'inbox', 'Beta.md'), '# Beta\n')
try {
const s = await session()
const before = await s.call('list_notes')
expect(before.isError).toBe(false)
expect(before.text).toContain('inbox/Alpha.md')

await writeConfig({ vaultRoot: beta, localVaults: [{ root: beta, name: 'beta' }] })
// A write planned against alpha must not land in beta, and a whole
// batch stops, not only its first call.
const batch = await Promise.all([
s.call('write_note', { path: 'inbox/Alpha.md', body: 'planned for alpha' }),
s.call('list_notes'),
s.call('read_note', { path: 'inbox/Alpha.md' })
])
for (const result of batch) {
expect(result.isError).toBe(true)
expect(result.text).toContain('The ZenNotes vault changed')
expect(result.text).toContain(alpha)
expect(result.text).toContain(beta)
expect(result.text).toContain('vault_info')
}
await expect(fsp.access(path.join(beta, 'inbox', 'Alpha.md'))).rejects.toThrow()
expect(await fsp.readFile(path.join(alpha, 'inbox', 'Alpha.md'), 'utf8')).toBe('# Alpha\n')

const { info } = await s.vaultInfo()
expect(info).toMatchObject({ kind: 'local', vaultRoot: beta })
expect(
String(info?.notes).startsWith(
`The vault changed: until this call the session worked in the local vault ${alpha}.`
)
).toBe(true)
const after = await s.call('list_notes')
expect(after.isError).toBe(false)
expect(after.text).toContain('inbox/Beta.md')
expect(after.text).not.toContain('Alpha.md')
expect(String((await s.vaultInfo()).info?.notes)).not.toContain('The vault changed')
await s.close()
} finally {
await fsp.rm(path.join(alpha, 'inbox', 'Alpha.md'), { force: true })
await fsp.rm(path.join(beta, 'inbox', 'Beta.md'), { force: true })
}
})
})
Loading
Loading