Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e06770c
Fix(kanban): a dragged card or column scrolls the board at its edge (…
adibhanna Sep 23, 2026
ecc7431
Fix(rename): a new note takes the name you give it, and a refused ren…
adibhanna Sep 23, 2026
1cdb0e8
Fix(workflows): a moved note keeps its comments and creation date, an…
adibhanna Sep 23, 2026
25f4040
Fix(workflows): undoing a move of a symlinked note puts the link back…
adibhanna Sep 23, 2026
57acec4
Fix(workflows): a step that edits or creates a note leaves the note's…
adibhanna Sep 23, 2026
ab600cb
Fix(workflows): a run on a remote vault carries each note's comments
adibhanna Sep 23, 2026
e723266
Fix(notes): a symlinked note moved to another folder keeps pointing a…
adibhanna Sep 23, 2026
dd58fe4
Fix(workflows): a "Notes in folder" step can name the vault root (#840)
adibhanna Sep 23, 2026
1488f50
Fix(search): the New note form marks its keyboard hints for the phone…
adibhanna Sep 23, 2026
c852528
Feat(workflows): event triggers fire for the edits made in the app (#…
adibhanna Sep 23, 2026
6f42358
Fix(notes): the move prompt speaks the sidebar's language (#844)
adibhanna Sep 23, 2026
cdccd55
Fix(ui): with Vim mode off, nothing on screen names a Vim key
adibhanna Sep 23, 2026
98e9ebd
Docs(help): the Tasks keyboard section says whose keys they are
adibhanna Sep 23, 2026
b251ff0
Release: align desktop and shared packages at 2.55.0
adibhanna Sep 23, 2026
00db703
Test(workflows): read a restored link's text past Windows' backslashes
adibhanna Sep 23, 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.54.1",
"version": "2.55.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
comment:
'opt in to the Workflows view, its sidebar row, command, and leader shortcut (off by default)'
},
workflowEventTriggers: {
section: 'view',
tomlKey: 'workflow_event_triggers',
comment:
'let active workflows with "trigger: on <event>" run on this device when you save, create, move or tag a note (on by default; needs workflows_enabled)'
},
assetSortOrder: {
section: 'view',
tomlKey: 'asset_sort_order',
Expand Down
55 changes: 5 additions & 50 deletions apps/desktop/src/main/note-creation-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { randomInt } from 'node:crypto'
const metadataDirectory = '.zennotes/note-metadata'
const metadataSuffix = '.metadata.json'

export function noteMetadataRoot(root: string): string {
return path.resolve(root, metadataDirectory)
}

export async function noteMetadataPath(
root: string,
rel: string,
directory = false,
): Promise<string> {
const base = path.resolve(root, metadataDirectory)
const base = noteMetadataRoot(root)
const target = path.resolve(base, rel + (directory ? '' : metadataSuffix))
if (target === base || !target.startsWith(base + path.sep))
throw new Error('Path escapes note metadata')
Expand Down Expand Up @@ -124,52 +128,3 @@ export async function removeNoteCreation(
recursive: directory,
})
}

export async function moveWithCreationMetadata(
root: string,
from: string,
to: string,
directory = false,
): Promise<void> {
if (from === to) return
const source = await noteMetadataPath(
root,
path.relative(root, from),
directory,
)
const target = await noteMetadataPath(
root,
path.relative(root, to),
directory,
)
const exists = async (abs: string): Promise<boolean> =>
fs.lstat(abs).then(
() => true,
(error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return false
throw error
},
)
const hasMetadata = await exists(source)
if (await exists(target))
throw new Error(`Destination metadata already exists: ${target}`)
if (hasMetadata) {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(source, target)
}
try {
await fs.rename(from, to)
} catch (error) {
if (hasMetadata) {
try {
await fs.rename(target, source)
} catch (rollback) {
throw new AggregateError(
[error, rollback],
'Note move and metadata rollback failed; reload before editing',
)
}
}
throw error
}
}
177 changes: 177 additions & 0 deletions apps/desktop/src/main/note-sidecars.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'

const environment = vi.hoisted(() => ({ userData: '' }))
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: () => {
if (!environment.userData) throw new Error('Vault test has not initialized app data')
return environment.userData
}
}
}))

import * as desktop from './vault'
import * as mcp from '../mcp/vault-ops'

const roots: string[] = []

async function makeVault(): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'zn-note-sidecars-'))
roots.push(root)
environment.userData = path.join(root, '.test-app')
await mkdir(environment.userData, { recursive: true })
vi.stubEnv('ZENNOTES_USER_DATA_PATH', environment.userData)
vi.stubEnv('ZENNOTES_CONFIG_DIR', environment.userData)
return root
}

afterEach(async () => {
for (const root of roots.splice(0)) {
desktop.invalidateNoteMetaCache(root)
desktop.invalidateVaultSettingsCache(root)
await rm(root, { recursive: true, force: true })
}
environment.userData = ''
vi.unstubAllEnvs()
})

function comment(notePath: string, body: string) {
return { notePath, anchorStart: 0, anchorEnd: 0, anchorText: '', id: body, body, createdAt: 1, updatedAt: 1 }
}

function commentsFile(root: string, notePath: string): string {
return path.join(root, '.zennotes', 'comments', `${notePath}.comments.json`)
}

async function missing(abs: string): Promise<boolean> {
return stat(abs).then(
() => false,
(error: NodeJS.ErrnoException) => error.code === 'ENOENT'
)
}

// The desktop and the MCP server move notes through the same shared core
// (note-sidecars.ts), so they must agree on where a note's discussion goes.
// Before, the MCP server moved the Markdown alone and left the comments at
// the old name, where the next note given that name took them over.
const clients = [
['desktop', desktop],
['MCP', mcp]
] as const

describe.each(clients)('%s: a note keeps its comments', (_name, client) => {
async function seed(root: string, notePath: string): Promise<void> {
await mkdir(path.dirname(path.join(root, notePath)), { recursive: true })
await client.writeNote(root, notePath, '# Note\n\nBody.\n')
await client.writeNoteComments(root, notePath, [comment(notePath, 'Keep this discussion')])
}

it('through a rename', async () => {
const root = await makeVault()
await seed(root, 'inbox/One.md')

await client.renameNote(root, 'inbox/One.md', 'Two')

expect((await client.readNoteComments(root, 'inbox/Two.md')).map((c) => c.body)).toEqual(['Keep this discussion'])
expect(await missing(commentsFile(root, 'inbox/One.md'))).toBe(true)
})

it('through a move to another folder', async () => {
const root = await makeVault()
await seed(root, 'inbox/One.md')

const moved = await client.moveNote(root, 'inbox/One.md', 'inbox', 'Work')

expect(moved.path).toBe('inbox/Work/One.md')
expect((await client.readNoteComments(root, moved.path)).map((c) => c.body)).toEqual(['Keep this discussion'])
expect(await missing(commentsFile(root, 'inbox/One.md'))).toBe(true)
})

it('through Trash and back', async () => {
const root = await makeVault()
await seed(root, 'inbox/One.md')

const trashed = await client.moveToTrash(root, 'inbox/One.md')
expect((await client.readNoteComments(root, trashed.path)).map((c) => c.body)).toEqual(['Keep this discussion'])
const restored = await client.restoreFromTrash(root, trashed.path)

expect(restored.path).toBe('inbox/One.md')
expect((await client.readNoteComments(root, restored.path)).map((c) => c.body)).toEqual(['Keep this discussion'])
expect(await missing(commentsFile(root, trashed.path))).toBe(true)
})

it('through a folder rename', async () => {
const root = await makeVault()
await seed(root, 'inbox/Work/One.md')

await client.renameFolder(root, 'inbox', 'Work', 'Projects')

expect((await client.readNoteComments(root, 'inbox/Projects/One.md')).map((c) => c.body)).toEqual(['Keep this discussion'])
expect(await missing(commentsFile(root, 'inbox/Work/One.md'))).toBe(true)
})

it('refuses a rename onto an earlier note’s comments, and names the file', async () => {
const root = await makeVault()
await seed(root, 'inbox/One.md')
await client.writeNoteComments(root, 'inbox/Two.md', [comment('inbox/Two.md', 'An earlier discussion')])

await expect(client.renameNote(root, 'inbox/One.md', 'Two')).rejects.toThrow(
'Comments from an earlier note named “Two” are still in .zennotes/comments/inbox/Two.md.comments.json'
)

expect(await readFile(path.join(root, 'inbox/One.md'), 'utf8')).toContain('Body.')
expect((await client.readNoteComments(root, 'inbox/One.md')).map((c) => c.body)).toEqual(['Keep this discussion'])
expect((await client.readNoteComments(root, 'inbox/Two.md')).map((c) => c.body)).toEqual(['An earlier discussion'])
})

it('and a deleted note takes them with it, so a new note of that name starts clean', async () => {
const root = await makeVault()
await seed(root, 'inbox/One.md')

await client.deleteNote(root, 'inbox/One.md')
await client.writeNote(root, 'inbox/One.md', '# One again\n')

expect(await client.readNoteComments(root, 'inbox/One.md')).toEqual([])
})

it('Empty Trash takes the trashed notes’ comments', async () => {
const root = await makeVault()
await seed(root, 'inbox/One.md')
const trashed = await client.moveToTrash(root, 'inbox/One.md')
expect(await missing(commentsFile(root, trashed.path))).toBe(false)

await client.emptyTrash(root)

expect(await missing(commentsFile(root, trashed.path))).toBe(true)
})

it('a note that is a relative link keeps pointing at its file when moved deeper', async () => {
const root = await makeVault()
// Inside inbox, so the vault stays in its default layout.
await mkdir(path.join(root, 'inbox', 'sources'), { recursive: true })
await writeFile(path.join(root, 'inbox', 'sources', 'Real.md'), '# Real\n\nElsewhere.\n')
await symlink('sources/Real.md', path.join(root, 'inbox', 'Rel.md'))

const moved = await client.moveNote(root, 'inbox/Rel.md', 'inbox', 'Topics')

// Moved verbatim, `sources/Real.md` from inbox/Topics would name nothing.
expect(moved.path).toBe('inbox/Topics/Rel.md')
expect(await readlink(path.join(root, 'inbox', 'Topics', 'Rel.md'))).toBe(path.join('..', 'sources', 'Real.md'))
expect(await readFile(path.join(root, 'inbox', 'Topics', 'Rel.md'), 'utf8')).toBe('# Real\n\nElsewhere.\n')
})

it('deleting a folder takes its notes’ comments', async () => {
const root = await makeVault()
await seed(root, 'inbox/Work/One.md')
await writeFile(path.join(root, 'inbox/Keep.md'), '# Keep\n')

await client.deleteFolder(root, 'inbox', 'Work')

expect(await missing(commentsFile(root, 'inbox/Work/One.md'))).toBe(true)
expect(await readFile(path.join(root, 'inbox/Keep.md'), 'utf8')).toBe('# Keep\n')
})
})
Loading
Loading