Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
383a2e9
fix(desktop): prevent large vaults exhausting macOS file watchers
MDerman Oct 27, 2026
c82c334
Feat(browse): the phones can open a folder's Gallery from its menu
adibhanna Oct 29, 2026
3c8fb85
Fix(cloud): one file Cloud refuses no longer stops the whole sync (#1…
adibhanna Oct 29, 2026
3c8023f
Feat(database): Delete or Backspace deletes the selected rows, after …
adibhanna Oct 29, 2026
cf8cb79
Fix(editor): with Vim mode off on macOS, Shift+F10 opens the editor's…
adibhanna Oct 29, 2026
95f04bd
Fix(cli): a row zn base add makes joins the picks of the view it open…
adibhanna Oct 29, 2026
1956a8a
Fix(focus): a phone keeps its keyboard down where core focused out of…
adibhanna Oct 29, 2026
cdd3171
Test(cloud): Settings names a file Cloud refused and keeps Cloud's an…
adibhanna Oct 29, 2026
d177d84
Test(vim): the editor smoke's window no longer takes the keyboard fro…
adibhanna Oct 29, 2026
5587a42
Refactor(cloud): the refused-path loop no longer checks for a refused…
adibhanna Oct 29, 2026
c8493d0
Fix(cloud): a file set aside while the clock ran ahead is offered aga…
adibhanna Oct 29, 2026
66af53e
Fix(cloud): a note moved to a path Cloud refuses stays in Cloud until…
adibhanna Oct 29, 2026
eaab18c
Fix(cloud): a file set aside no longer leaves sync stuck on "needs at…
adibhanna Oct 29, 2026
1b49dcd
Fix(cloud): a note Cloud won't take says to rename it, and a too-long…
adibhanna Oct 29, 2026
228a1d9
Fix(mcp): assistants stay inside the note folders
adibhanna Oct 29, 2026
0ad42e5
Fix(database): Backspace while editing a view's picks no longer offer…
adibhanna Oct 29, 2026
e34ba1f
Fix(database): after dd deletes a row, the next key still moves in th…
adibhanna Oct 29, 2026
fb5c74d
Fix(database): a row deleted while its confirm was open no longer bla…
adibhanna Oct 29, 2026
14e3885
Fix(focus): closing the sidebar on a phone leaves its keys with the e…
adibhanna Oct 29, 2026
695a923
Fix(dialogs): Enter in a confirmation presses the button that has the…
adibhanna Oct 29, 2026
0a3e79c
Merge #999: fix(desktop): prevent macOS freezes when opening large va…
adibhanna Oct 29, 2026
9c27db1
Fix(watcher): one unreadable folder no longer ends live updates for t…
adibhanna Oct 29, 2026
6bf2604
Fix(watcher): an unreadable folder no longer ends live theme, snippet…
adibhanna Oct 29, 2026
dd51358
Fix(cloud): a note of yours that Cloud holds back keeps sync asking u…
adibhanna Oct 29, 2026
4bed24c
Fix(menu): a / pressed as a menu opens keeps the find field
adibhanna Oct 30, 2026
b71b8c2
Test(workflows): the applier's tests get a budget for a slow Windows …
adibhanna Oct 30, 2026
f641306
Fix(daily notes): today's note from a template takes the rolled tasks…
adibhanna Oct 30, 2026
156269b
Fix(notes): an edit to a note no pane shows is saved even when the no…
adibhanna Oct 30, 2026
6197e50
Fix(tour): the start note's numbered list keeps its text in one colum…
adibhanna Oct 30, 2026
cd35961
Test(watcher): the macOS echo of the test vault's own folder is not t…
adibhanna Oct 30, 2026
f3d7476
Feat(editor): a numbered list padded to line up its numbers stays lin…
adibhanna Oct 30, 2026
4bbea2c
Merge fix/demo-tour-list-padding: the tour's numbered list keeps its …
adibhanna Oct 30, 2026
fca7f07
Merge fix/padded-ordered-lists: a numbered list padded to line up its…
adibhanna Oct 30, 2026
969f50e
Feat(mcp): the bundled MCP server can share part of a vault, read-onl…
adibhanna Oct 29, 2026
df340cb
Test(mcp): the scope contract finds a Windows vault path however a to…
adibhanna Oct 30, 2026
6a07ef8
Release(cli): bundle the verified 0.13.0 terminal release
adibhanna Oct 30, 2026
d0fe299
Fix(mcp): a scoped assistant's error names a Windows path with forwar…
adibhanna Oct 30, 2026
0cfcb3a
Fix(editor): the last header button's tooltip is no longer cut off at…
adibhanna Oct 30, 2026
c3bd22e
Test(editor): the header tooltip test no longer imports vi it does no…
adibhanna Oct 30, 2026
8b42b54
Feat(templates): a folder of your vault can hold your templates (#1024)
adibhanna Oct 30, 2026
aebdeab
Feat(tasks): a task knows which checkbox it is a subtask of (#1028)
adibhanna Oct 30, 2026
44a9946
Feat(kanban): subtasks fold into their card, with a progress bar (#1028)
adibhanna Oct 30, 2026
c71a7dc
Test(tasks): the subtask contract pins which lines count as blank
adibhanna Oct 30, 2026
3b48e94
Feat(tasks): empty tasks stay out of the Tasks views, and the keys mo…
adibhanna Oct 30, 2026
fa5ba5a
Feat(kanban): a column can carry a WIP limit, and turns red past it (…
adibhanna Oct 30, 2026
a6ead45
Merge feat/template-folder: a folder of your vault can hold your temp…
adibhanna Oct 30, 2026
8d0d990
Merge feat/kanban-empty-wip: empty tasks stay out of the Tasks views,…
adibhanna Oct 30, 2026
de8a419
Merge feat/kanban-subtasks: subtasks fold into their card with a prog…
adibhanna Oct 30, 2026
7a6e3d3
Fix(kanban): a card sent to In progress takes the cursor with it
adibhanna Oct 30, 2026
7018966
Feat(tasks): a task line can take a new first tag, the field a tag la…
adibhanna Oct 30, 2026
f3ad9a3
Feat(kanban): swimlanes split the board into rows by a second field (…
adibhanna Oct 30, 2026
a069a4b
Merge feat/kanban-swimlanes: swimlanes split the board into rows by a…
adibhanna Oct 30, 2026
5ce55fa
Docs(whats-new): 2.80.0 brings swimlanes, subtasks on cards, WIP limi…
adibhanna Oct 30, 2026
0e8a921
Fix(tasks): a line that only continues a task's text keeps its subtas…
adibhanna Oct 30, 2026
eca34b2
Fix(tasks): Esc puts back a task the calendar picked up
adibhanna Oct 30, 2026
38a2045
Fix(templates): a template's tags stay out of the vault's tags, and l…
adibhanna Oct 30, 2026
94a527b
Fix(tasks): the Calendar and Timeline panels and Home leave out empty…
adibhanna Oct 30, 2026
4fcf919
Fix(tasks): `a` on the calendar always adds to the selected day
adibhanna Oct 30, 2026
c52ab73
Merge fix/release-2.80-gaps: subtasks under a wrapped line, the calen…
adibhanna Oct 30, 2026
8c23f3d
Release: align desktop and shared packages at 2.80.0
adibhanna Oct 30, 2026
3152081
Release(cli): bundle the verified 0.13.1 terminal release
adibhanna Oct 30, 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ Read [docs/monorepo-architecture.md](docs/monorepo-architecture.md) for the arch

### Requirements

- Node.js 22+
- Node.js 22.22 or newer
- npm
- Go 1.22+ for the server build path
- Docker optional, for self-hosting
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,18 @@ const MAIN_BUNDLED_MARKDOWN_PACKAGES = [
'remark-math'
]

// The file watchers (vault, config.toml, themes, CSS snippets, code
// languages), bundled for the same reason. Chokidar 6 is pure ESM with
// readdirp 5 below it; left external, main would load it from the asar and
// count on electron-builder putting readdirp where Node looks for it, the bet
// 2.20.0 lost. Neither has native pieces, so Rollup inlines both.
const MAIN_BUNDLED_WATCHER_PACKAGES = ['chokidar', 'readdirp']

const MAIN_EXTERNALIZE_EXCLUSIONS = [
...INTERNAL_WORKSPACE_PACKAGES,
...PACKAGED_CLI_RUNTIME_PACKAGES,
...MAIN_BUNDLED_MARKDOWN_PACKAGES
...MAIN_BUNDLED_MARKDOWN_PACKAGES,
...MAIN_BUNDLED_WATCHER_PACKAGES
]

function onigurumaDataUrl(): Plugin {
Expand Down
6 changes: 3 additions & 3 deletions 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.79.0",
"version": "2.80.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand All @@ -19,7 +19,7 @@
},
"homepage": "https://github.com/ZenNotes/zennotes/releases/latest",
"engines": {
"node": ">=22"
"node": ">=22.22"
},
"scripts": {
"dev": "electron-vite dev",
Expand Down Expand Up @@ -58,7 +58,7 @@
"@zennotes/app-core": "*",
"@zennotes/bridge-contract": "*",
"@zennotes/shared-domain": "*",
"chokidar": "^4.0.3",
"chokidar": "^6.0.0",
"codemirror": "^6.0.1",
"docx": "^9.7.1",
"dompurify": "^3.3.4",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([
'meta',
'new-window',
'page',
'read-only',
'reopen',
'unchecked',
'yes'
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/cli/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
readNote,
readNoteComments,
readPrimaryNotesLocation,
readTemplateFolder,
readVaultFileTextOrNull,
renameFolder,
renameNote,
Expand Down Expand Up @@ -73,6 +74,8 @@ import {
type DatabaseVaultLayout
} from '@shared/database-ops'
import { createAbsenceAwareReader } from '@shared/remote-absence'
import { normalizeSystemFolderPaths, type SystemFolderPaths } from '@shared/system-folder-paths'
import { resolveTemplateFolder } from '@shared/template-folder'
import { setCell } from '@shared/database-records'
import {
csvPathForFormDir,
Expand Down Expand Up @@ -107,6 +110,11 @@ export type VaultDescription =
authConfigured: boolean
}

export interface VaultLayout {
primaryNotesLocation: PrimaryNotesLocation
systemFolderPaths: SystemFolderPaths
}

export interface VaultBackend {
readonly kind: 'local' | 'remote'
/** How to name this vault in output and errors. */
Expand All @@ -116,6 +124,13 @@ export interface VaultBackend {
readonly root: string

describe(): Promise<VaultDescription>
/** Where the system folders live and whether the inbox is the vault root:
* what classifying a vault-relative path needs, for a scoped MCP client
* (ZenNotes/tui#21). */
vaultLayout(): Promise<VaultLayout>
/** The vault's template folder (#1024) as its settings name it, or null:
* the tag tools and commands leave its notes out, as the app does. */
templateFolder(): Promise<string | null>
listNotes(): Promise<NoteMeta[]>
listAssets(): Promise<VaultAssetMeta[]>
listFolders(): Promise<{ folder: NoteFolder; subpath: string }[]>
Expand Down Expand Up @@ -227,6 +242,14 @@ class LocalBackend implements VaultBackend {
root: this.root,
primaryNotesLocation: await readPrimaryNotesLocation(this.root)
})
vaultLayout = async (): Promise<VaultLayout> => {
const layout = await readDatabaseVaultLayout(this.root)
return {
primaryNotesLocation: layout.primaryNotesAtRoot ? 'root' : 'inbox',
systemFolderPaths: layout.systemFolderPaths ?? {}
}
}
templateFolder = (): Promise<string | null> => readTemplateFolder(this.root)
listNotes = (): Promise<NoteMeta[]> => listNotes(this.root)
listAssets = (): Promise<VaultAssetMeta[]> => listAssets(this.root)
listFolders = (): Promise<{ folder: NoteFolder; subpath: string }[]> => listFolders(this.root)
Expand Down Expand Up @@ -330,6 +353,15 @@ class RemoteBackend implements VaultBackend {
authConfigured: !!this.client.authToken
}
}
vaultLayout = async (): Promise<VaultLayout> => {
const settings = await this.client.getVaultSettings()
return {
primaryNotesLocation: settings.primaryNotesLocation === 'root' ? 'root' : 'inbox',
systemFolderPaths: normalizeSystemFolderPaths(settings.systemFolderPaths)
}
}
templateFolder = async (): Promise<string | null> =>
resolveTemplateFolder(await this.client.getVaultSettings())
listNotes = (): Promise<NoteMeta[]> => this.client.listNotes()
listAssets = async (): Promise<VaultAssetMeta[]> =>
(await this.client.listAssets()).map((asset) => ({
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/cli/commands/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,67 @@ describe('zn base', () => {
expect(page).toContain('Summary of the retro.')
})

it('adds the row to the picks of the view the database opens on, and nowhere else', async () => {
const dir = path.join(root, 'inbox', 'Reading.base')
await fsp.mkdir(dir, { recursive: true })
await fsp.writeFile(path.join(dir, 'data.csv'), 'id,Title\nr1,Dune\nr2,Emma\n')
const views = (picks: Record<string, string[] | undefined>) =>
Object.entries(picks).map(([id, pickedRowIds]) => ({
id,
name: id === 'shelf' ? 'Shelf' : id === 'all' ? 'All' : 'Later',
type: 'table',
filters: [],
sorts: [],
...(pickedRowIds ? { pickedRowIds } : {})
}))
const writeSchema = (activeViewId: string) =>
fsp.writeFile(
path.join(dir, 'schema.json'),
JSON.stringify({
version: 1,
idFieldId: 'f_id',
fields: [
{ id: 'f_id', name: 'id', type: 'text', hidden: true },
{ id: 'f_title', name: 'Title', type: 'text' }
],
views: views({ shelf: ['r1'], all: undefined, later: ['r2'] }),
activeViewId
})
)
const readViews = async () =>
(JSON.parse(await fsp.readFile(path.join(dir, 'schema.json'), 'utf8')) as {
views: Array<{ id: string; pickedRowIds?: string[] }>
}).views

// The database opens on Shelf, which shows only Dune: the new row joins it.
await writeSchema('shelf')
await cmdBaseAdd(backend(), makeArgs(['Reading'], [['set', 'Title=Ulysses'], ['json', 'true']]))
const added = lastJson()
expect(added.pickedInView).toBe('Shelf')
const after = await readViews()
expect(after.find((v) => v.id === 'shelf')?.pickedRowIds).toEqual(['r1', added.id])
// A view with picks the user is not looking at keeps them.
expect(after.find((v) => v.id === 'later')?.pickedRowIds).toEqual(['r2'])
expect(after.find((v) => v.id === 'all')).not.toHaveProperty('pickedRowIds')

// Opening on a view of every row, the add changes no view's picks.
await writeSchema('all')
out = []
await cmdBaseAdd(backend(), makeArgs(['Reading'], [['set', 'Title=Middlemarch']]))
expect(out.join('')).not.toContain('picked rows')
const plain = await readViews()
expect(plain.find((v) => v.id === 'shelf')?.pickedRowIds).toEqual(['r1'])
expect(plain.find((v) => v.id === 'later')?.pickedRowIds).toEqual(['r2'])
const csv = await fsp.readFile(path.join(dir, 'data.csv'), 'utf8')
expect(csv).toContain('Middlemarch')

// The text output says where the row went.
await writeSchema('later')
out = []
await cmdBaseAdd(backend(), makeArgs(['Reading'], [['set', 'Title=Persuasion']]))
expect(out.join('')).toContain('added to the picked rows of the Later view')
})

it('creates a database and rejects unknown fields helpfully', async () => {
await cmdBaseCreate(backend(), makeArgs(['Logbook'], [['json', 'true']]))
const created = lastJson()
Expand Down
25 changes: 17 additions & 8 deletions apps/desktop/src/cli/commands/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import {
addRow,
addRowInView,
composePageBody,
ensureSelectOption,
recordTitle,
Expand Down Expand Up @@ -239,12 +239,16 @@ export async function cmdBaseAdd(vault: VaultBackend, args: ParsedArgs): Promise
const body = bodyFlag === '-' ? (await readStdin()).trim() || null : bodyFlag ?? null
const wantPage = body != null || getBool(args, 'page')

let doc = await ops.openDatabase(csvPath)
doc = addRow(doc)
const row = doc.rows[doc.rows.length - 1]
const applied = applyAssignments(doc, row.id, assignments)
doc = applied.doc
let schemaChanged = applied.schemaChanged
// As + Row does in the grid: when the view the database opens on shows
// picked rows, the new row joins them, or it would be missing from that
// view the next time the database is opened. Other views keep their picks.
const opened = await ops.openDatabase(csvPath)
const added = addRowInView(opened, opened.activeViewId)
const pickedIn = added.picksChanged ? opened.views.find((v) => v.id === opened.activeViewId)?.name ?? null : null
const row = added.doc.rows.find((r) => r.id === added.rowId)!
const applied = applyAssignments(added.doc, row.id, assignments)
let doc = applied.doc
let schemaChanged = applied.schemaChanged || added.picksChanged

let pagePath: string | null = null
if (wantPage) {
Expand All @@ -259,10 +263,15 @@ export async function cmdBaseAdd(vault: VaultBackend, args: ParsedArgs): Promise

const finalRow = doc.rows.find((r) => r.id === row.id)!
if (getBool(args, 'json')) {
emitJson({ ok: true, ...rowView({ ...doc, pages: pagePath ? { [row.id]: pagePath } : doc.pages }, finalRow) })
emitJson({
ok: true,
...rowView({ ...doc, pages: pagePath ? { [row.id]: pagePath } : doc.pages }, finalRow),
...(pickedIn !== null ? { pickedInView: pickedIn } : {})
})
return
}
emitOk(`Added ${recordTitle(doc, finalRow)} (${row.id})`)
if (pickedIn !== null) emitLine(` added to the picked rows of the ${pickedIn} view`)
if (pagePath) emitLine(` page: ${pagePath}`)
}

Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/src/cli/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@
* names nothing is reported on stderr at startup and the server still
* starts; the CLI's usual "resolve, then fail the command" would leave
* the MCP client with a dead server and no message.
*
* --allow-path, --deny-path, --read-only and --access narrow what the
* client may see and change (ZenNotes/tui#21); the server enforces them
* on every tool. A bad entry is the one flag that stops the server before
* it serves: starting anyway would share more than the user asked for.
*/

import { mcpScopeFromFlags, type McpScope } from '@shared/mcp-scope'
import { runMcpServer } from '../../mcp/server.js'
import type { ParsedArgs } from '../args.js'
import { getBool, getMany, getString, type ParsedArgs } from '../args.js'
import { resolveTarget } from '../vault-target.js'

export async function cmdMcp(args: ParsedArgs): Promise<void> {
await runMcpServer({ resolveTarget: () => resolveTarget(args) })
const scope = mcpScopeFromArgs(args)
await runMcpServer({ resolveTarget: () => resolveTarget(args), scope })
// The MCP SDK's connect() returns once stdin/stdout listeners are
// wired up. Node's event loop keeps the process alive while those
// listeners exist, but the CLI dispatcher would otherwise see this
Expand All @@ -29,3 +36,14 @@ export async function cmdMcp(args: ParsedArgs): Promise<void> {
// (Claude Code / Desktop / Codex) chooses to give it.
await new Promise<void>(() => {})
}

/** The scope the flags ask for; null when none were given, full access. The
* legacy out/main/mcp.js entry reads the same flags off its own argv. */
export function mcpScopeFromArgs(args: ParsedArgs): McpScope | null {
return mcpScopeFromFlags({
allow: getMany(args, 'allow-path'),
deny: getMany(args, 'deny-path'),
access: getString(args, 'access'),
readOnly: getBool(args, 'read-only')
})
}
4 changes: 3 additions & 1 deletion apps/desktop/src/cli/commands/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* round-trip, works whether the desktop app is open or closed.
*/

import { outsideTemplateFolder } from '@shared/template-folder'
import type { NoteFolder, NoteMeta } from '../../mcp/vault-ops.js'
import type { VaultBackend } from '../backend.js'
import {
Expand Down Expand Up @@ -37,7 +38,8 @@ export async function cmdList(vault: VaultBackend, args: ParsedArgs): Promise<vo
? notes.filter((n) => n.folder === folder)
: notes.filter((n) => n.folder !== 'trash')
if (tag) {
notes = notes.filter((n) =>
// A tag finds no template (#1024): its tags are the note it will make.
notes = outsideTemplateFolder(notes, await vault.templateFolder()).filter((n) =>
n.tags.map((t) => t.toLowerCase()).includes(tag.replace(/^#/, ''))
)
}
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/cli/commands/tags.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
/**
* `zn tag list` and `zn tag find` — enumerate and discover #tags
* across live notes (excluding trash).
* across live notes (excluding trash and the vault's template folder, whose
* tags are the notes its templates will make, #1024).
*/

import { outsideTemplateFolder } from '@shared/template-folder'
import type { VaultBackend } from '../backend.js'
import { getBool, getNumber, getString, type ParsedArgs } from '../args.js'
import { emitJson, emitLine, pad } from '../format.js'

export async function cmdTagList(vault: VaultBackend, args: ParsedArgs): Promise<void> {
const all = await vault.listNotes()
const all = outsideTemplateFolder(await vault.listNotes(), await vault.templateFolder())
const counts = new Map<string, number>()
for (const n of all) {
if (n.folder === 'trash') continue
Expand Down Expand Up @@ -38,7 +40,7 @@ export async function cmdTagFind(vault: VaultBackend, args: ParsedArgs): Promise
const tag = (getString(args, 'tag') ?? args.positionals[0])?.replace(/^#/, '').toLowerCase()
if (!tag) throw new Error('zn tag find requires a tag name (e.g. `zn tag find idea`).')
const limit = getNumber(args, 'limit') ?? 200
const all = await vault.listNotes()
const all = outsideTemplateFolder(await vault.listNotes(), await vault.templateFolder())
const matches = all
.filter(
(n) => n.folder !== 'trash' && n.tags.map((x) => x.toLowerCase()).includes(tag)
Expand Down
Loading
Loading