Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
afa7839
Fix(editor): Enter in a list opens the next bullet right below, always
adibhanna Nov 1, 2026
c6b9689
Fix(vault): New Drawing opens the drawing in a Vault root vault set t…
adibhanna Nov 1, 2026
5115930
Fix(excalidraw): opening a drawing no longer logs 230 CSP font errors
adibhanna Nov 1, 2026
79b768f
Feat(settings): folder fields list the vault's folders as you type (#…
adibhanna Nov 1, 2026
3e56568
Feat(kanban): a column can carry a color, drawn in the theme's own sh…
adibhanna Nov 1, 2026
9f824b4
Feat(databases): New Kanban makes a board of its own for a folder (#1…
adibhanna Nov 1, 2026
8b57ef0
Feat(tasks): recurring tasks and reminders (#1036)
adibhanna Nov 1, 2026
5f9a17b
Merge claude/trusting-mirzakhani-a8eae2: opening a drawing no longer …
adibhanna Nov 1, 2026
30ba76c
Merge claude/issue-1033-folder-picker-93f968: Settings folder fields …
adibhanna Nov 1, 2026
1f70c3e
Merge claude/issue-1037-kanban-column-colors-ea41c5: a Kanban column …
adibhanna Nov 1, 2026
6591379
Merge claude/issue-1038-new-kanban-8ce2ee: New Kanban makes a board o…
adibhanna Nov 1, 2026
777e39d
Fix(tasks): a recurring task's record cancels the subtasks left open …
adibhanna Nov 1, 2026
f3e6007
Merge claude/issue-1036-recurring-tasks-c90bbb: recurring tasks and r…
adibhanna Nov 1, 2026
429defe
Fix(config): config.toml keeps the settings only zn uses
adibhanna Nov 1, 2026
6f97895
Merge fix/config-toml-keeps-unknown: config.toml keeps the settings o…
adibhanna Nov 1, 2026
2795350
Release(cli): bundle the verified 0.14.0 terminal release
adibhanna Nov 1, 2026
164f84e
Docs(whats-new): 2.81.0 brings recurring tasks, a Kanban board for an…
adibhanna Nov 1, 2026
f5ecdef
Release: align desktop and shared packages at 2.81.0
adibhanna Nov 1, 2026
8bc2209
Test(lists): the Enter test drops its caret marker by index, not by p…
adibhanna Nov 1, 2026
d9e7c36
Fix(settings): the phones and the web app leave out reminder settings…
adibhanna Nov 1, 2026
08e46c8
Fix(databases): a database opened in the same pane starts at its own …
adibhanna Nov 1, 2026
dc7f3ee
Feat(browse): the phones can make a Kanban in a folder, as the deskto…
adibhanna Nov 1, 2026
2dc9388
Fix(settings): the databases location says what it does, not where th…
adibhanna Nov 1, 2026
507f117
Fix(menus): a phone's menus leave out keys it has no keyboard for
adibhanna Nov 1, 2026
c79731f
Fix(databases): a narrow pane keeps the database's name in its header
adibhanna Nov 1, 2026
99553fc
Fix(tasks): Back from a note opened from Tasks returns to Tasks
adibhanna Nov 1, 2026
5250018
Fix(settings): the databases location's search result says what its r…
adibhanna Nov 1, 2026
ec836bc
Fix(window): a cancelled drag no longer leaves the title bar unable t…
adibhanna Nov 1, 2026
0628c9e
Fix(databases): a phone's Board opens without the keyboard's cursor ring
adibhanna Nov 1, 2026
bc24828
Fix(settings): the location settings sit under Default Locations
adibhanna Nov 1, 2026
1673446
Fix(tasks): a phone's Tasks list and board draw no keyboard cursor
adibhanna Nov 1, 2026
046aaa9
Fix(settings): Default tasks location says what it does in one line
adibhanna Nov 1, 2026
b0a40d6
Refactor(menus): ContextMenu asks its own coarse-pointer check for ke…
adibhanna Nov 1, 2026
bcce61e
Fix(views): no keyboard cursor on a phone in the database Table, the …
adibhanna Nov 1, 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
31 changes: 30 additions & 1 deletion apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import { harperWasmAsset } from '../../tooling/vite/harper-wasm-asset'
import { EXCALIDRAW_ASSET_PATH } from './src/renderer/excalidraw-asset-path'

const INTERNAL_WORKSPACE_PACKAGES = [
'@zennotes/app-core',
Expand Down Expand Up @@ -88,6 +89,29 @@ function onigurumaDataUrl(): Plugin {
}
}

// Excalidraw lists a CDN fallback (esm.sh) after EXCALIDRAW_ASSET_PATH in every
// FontFace it builds, and Chromium checks each listed source against the CSP
// when the FontFace is constructed, not when it loads. font-src does not allow
// esm.sh, so opening a drawing logged a CSP error for each of its 230 faces
// although every font loaded from zen-excalidraw: (#324), and the noise read as
// a font regression. Pointing the fallback at the same local base leaves every
// source CSP-allowed and keeps the fonts offline even if the global were unset.
// An Excalidraw upgrade that reshapes the fallback fails the build here rather
// than quietly bringing the noise back.
function excalidrawLocalFontFallback(base: string): Plugin {
const cdnFallback = /`https:\/\/esm\.sh\/\$\{.*?\}\/dist\/prod\/`/
return {
name: 'zennotes-excalidraw-local-font-fallback',
transform(code, id) {
if (!code.includes('ASSETS_FALLBACK_URL')) return null
if (!cdnFallback.test(code)) {
this.error(`Excalidraw's esm.sh font fallback changed shape in ${id}`)
}
return { code: code.replace(cdnFallback, JSON.stringify(base)), map: null }
}
}
}

function rendererManualChunk(id: string): string | undefined {
const normalizedId = id.split('\\').join('/')
if (normalizedId.endsWith('/packages/app-core/src/lib/wikilinks.ts')) {
Expand Down Expand Up @@ -307,6 +331,11 @@ export default defineConfig({
'@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
}
},
plugins: [onigurumaDataUrl(), harperWasmAsset(), react()]
plugins: [
onigurumaDataUrl(),
harperWasmAsset(),
excalidrawLocalFontFallback(EXCALIDRAW_ASSET_PATH),
react()
]
}
})
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.80.1",
"version": "2.81.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/cli/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
splitTaskId,
toggleFileTaskInBody,
toggleTask,
toggleTaskInBody,
toggleTaskBody,
unarchiveNote,
writeNote,
writeNoteComments,
Expand Down Expand Up @@ -475,20 +475,25 @@ class RemoteBackend implements VaultBackend {
// explicit ask, and ids for excluded tasks only circulate via
// `zn task list --include-excluded`.
let nextBody: string | null
// A recurring task checked off moves down under its next occurrence
// (#1036); the reply names the finished task, wherever it went.
let resultId = taskId
if (indexStr === 'task') {
const current = (
await this.client.scanTasksForPath(rel, { includeExcluded: true })
).find((t) => t.id === taskId)
nextBody = current ? toggleFileTaskInBody(note.body, current.checked) : null
} else {
nextBody = toggleTaskInBody(note.body, parseTaskIndex(taskId, indexStr))
const toggled = toggleTaskBody(note.body, parseTaskIndex(taskId, indexStr))
nextBody = toggled?.body ?? null
if (toggled) resultId = `${rel}#${toggled.index}`
}
if (nextBody == null) return null

await this.client.writeNote(rel, nextBody)
return (
(await this.client.scanTasksForPath(rel, { includeExcluded: true })).find(
(t) => t.id === taskId
(t) => t.id === resultId
) ?? null
)
}
Expand Down
264 changes: 264 additions & 0 deletions apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
stopAppConfigWatcher
} from './app-config'
import { CONFIG_VERSION, PORTABLE_PREF_KEYS, type AppConfigPortable } from '@shared/app-config'
import { parse as parseToml } from 'smol-toml'

const tempDirs: string[] = []
async function tmp(prefix: string): Promise<string> {
Expand Down Expand Up @@ -136,6 +137,7 @@ describe('TOML serialization', () => {
kanbanLanes: 'field:sprint',
hideEmptyTasks: false,
kanbanWipLimits: { 'status:in-progress': 3, 'field:status:review': 2 },
kanbanColumnColors: { 'status:in-progress': 'blue', 'field:status:done': 'green' },
tasksListOrder: 'priority',
timelineRange: 'month',
gallerySort: 'title',
Expand Down Expand Up @@ -185,6 +187,14 @@ describe('TOML serialization', () => {
expect(round.hideEmptyTasks).toBe(false)
expect(text).toMatch(/\[kanban_wip_limits\]\n"status:in-progress" = 3\n"field:status:review" = 2/)
expect(round.kanbanWipLimits).toEqual({ 'status:in-progress': 3, 'field:status:review': 2 })
// #1037: column colours travel as palette names, next to the titles.
expect(text).toMatch(
/\[kanban_column_colors\]\n"status:in-progress" = "blue"\n"field:status:done" = "green"/
)
expect(round.kanbanColumnColors).toEqual({
'status:in-progress': 'blue',
'field:status:done': 'green'
})
expect(text).toContain('tasks_list_order = "priority"')
expect(round.tasksListOrder).toBe('priority')
// The Timeline panel's reach travels as [view].timeline_range (#1007).
Expand Down Expand Up @@ -565,3 +575,257 @@ describe('command keys in [keymaps.commands] (#971)', () => {
expect(onDisk.split('[keymaps.commands]')[0]).not.toMatch(/^"note\.trash"/m)
})
})

// zn, the terminal app, reads config.toml too and keeps settings of its own
// there. The app rebuilt the file from the options it knows, so zn's
// [terminal] table and keys like [vim] motions vanished at every launch and
// every settings change.
describe('settings the app does not write, for zn', () => {
const sharedFile = [
'config_version = 1',
'zn_only = "kept" # a root key only zn reads',
'',
'[vim]',
'enabled = false',
'# zn: vim | helix',
'motions = "helix" # Helix-style selections in zn tui',
'',
'[editor]',
'font_size = 19',
'',
'[keymaps]',
'"global.searchNotes" = "Mod+K"',
'',
'# zn tui only',
'[terminal]',
'# preview_style is a Glamour style name, or "zen"',
'preview_style = "dracula"',
'mouse = false',
'banner = """',
'[not.a.table]',
'just text"""',
'images = [',
' "kitty", # first choice',
' "off",',
']',
' [terminal.view_mode_rules]',
' "page-status=done" = "preview"',
'',
'[[terminal.profiles]]',
'name = "work"',
''
].join('\n')

const terminalBlock = [
'# zn tui only',
'[terminal]',
'# preview_style is a Glamour style name, or "zen"',
'preview_style = "dracula"',
'mouse = false',
'banner = """',
'[not.a.table]',
'just text"""',
'images = [',
' "kitty", # first choice',
' "off",',
']'
].join('\n')

const sharedTerminal = {
preview_style: 'dracula',
mouse: false,
banner: '[not.a.table]\njust text',
images: ['kitty', 'off'],
view_mode_rules: { 'page-status=done': 'preview' },
profiles: [{ name: 'work' }]
}

it('keeps them through a launch, as written, comments included', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-')
await writeFile(getConfigFilePath(), sharedFile, 'utf8')
await initAppConfig(() => {})

const snap = getPortableConfigSnapshot()
expect(snap.vimMode).toBe(false)
expect(snap.editorFontSize).toBe(19)
expect(snap.keymapOverrides).toEqual({ 'global.searchNotes': 'Mod+K' })

const text = await readFile(getConfigFilePath(), 'utf8')
// The file is the app's canonical one again, every option documented...
expect(text).toContain('theme_mode = "dark" # light | dark | auto')
// ...with zn's tables after its own, as they were written.
expect(text).toContain(`${terminalBlock}\n\n [terminal.view_mode_rules]\n "page-status=done" = "preview"\n\n[[terminal.profiles]]\nname = "work"\n`)
expect(text.indexOf('# zn tui only')).toBeGreaterThan(text.indexOf('[saved_filters]'))
// A key zn keeps in a table the app writes stays in that table, with its comments.
expect(text).toMatch(
/\n\[vim\]\n(?:[a-z_]+ = .*\n)+# zn: vim \| helix\nmotions = "helix" # Helix-style selections in zn tui\n/
)
expect(text).toContain('config_version = 1\nzn_only = "kept" # a root key only zn reads\n')

const parsed = parseToml(text) as Record<string, any>
expect(parsed.terminal).toEqual(sharedTerminal)
expect(parsed.vim.motions).toBe('helix')
expect(parsed.zn_only).toBe('kept')

// The next launch finds nothing to change: no rewrite on every start.
await stopAppConfigWatcher()
await initAppConfig(() => {})
expect(await readFile(getConfigFilePath(), 'utf8')).toBe(text)
})

it('keeps them when a setting changes in the app', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-set-')
await writeFile(getConfigFilePath(), sharedFile, 'utf8')
await initAppConfig(() => {})

await setPortableConfig({ editorFontSize: 22, vimMode: true })

const text = await readFile(getConfigFilePath(), 'utf8')
expect(text).toContain('font_size = 22')
expect(text).toContain(terminalBlock)
const parsed = parseToml(text) as Record<string, any>
expect(parsed.vim).toMatchObject({ enabled: true, motions: 'helix' })
expect(parsed.terminal).toEqual(sharedTerminal)
})

it('keeps what zn saved after the app last read the file', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-race-')
await writeFile(getConfigFilePath(), serializeConfig({ editorFontSize: 18 }), 'utf8')
await initAppConfig(() => {})

// zn's writer (BurntSushi/toml) sorts keys, indents, and drops comments.
// A settings change lands before the watcher has read this.
await writeFile(
getConfigFilePath(),
[
'config_version = 1',
'',
'[editor]',
' font_size = 18',
'',
'[terminal]',
' math = "latex"',
' view_mode_field = "view-mode"',
' [terminal.view_mode_rules]',
' "page-status=done" = "preview"',
'',
'[vim]',
' enabled = true',
''
].join('\n'),
'utf8'
)
await setPortableConfig({ themeMode: 'light' })

const text = await readFile(getConfigFilePath(), 'utf8')
expect(text).toContain('theme_mode = "light"')
expect(text).toContain(
'[terminal]\n math = "latex"\n view_mode_field = "view-mode"\n\n [terminal.view_mode_rules]\n "page-status=done" = "preview"\n'
)
})

it('still loads when a table it does not use is broken, and leaves that table for zn', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-broken-')
const broken = [
'config_version = 1',
'',
'[editor]',
'font_size = 19',
'',
'[terminal]',
'mouse = yes',
'math = "latex"',
''
].join('\n')
expect(() => parseToml(broken)).toThrow()
await writeFile(getConfigFilePath(), broken, 'utf8')

await initAppConfig(() => {})
expect(getPortableConfigSnapshot().editorFontSize).toBe(19)
expect(await readFile(getConfigFilePath(), 'utf8')).toContain('[terminal]\nmouse = yes\nmath = "latex"\n')

await setPortableConfig({ editorFontSize: 21 })
const text = await readFile(getConfigFilePath(), 'utf8')
expect(text).toContain('font_size = 21')
expect(text).toContain('[terminal]\nmouse = yes\nmath = "latex"\n')
// Fixing the typo by hand makes the file plain TOML again.
expect(() => parseToml(text.replace('mouse = yes', 'mouse = true'))).not.toThrow()
})

it('keeps zn tables through the next save when the app options themselves are broken', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-own-broken-')
await writeFile(
getConfigFilePath(),
'config_version = 1\n\n[editor]\nfont_size = 1 9\n\n[terminal]\nmouse = false\n',
'utf8'
)
// As before: a typo in the app's own options leaves them unread.
await initAppConfig(() => {})
expect(getPortableConfigSnapshot()).toEqual({})

await setPortableConfig({ editorFontSize: 20 })
const parsed = parseToml(await readFile(getConfigFilePath(), 'utf8')) as Record<string, any>
expect(parsed.editor.font_size).toBe(20)
expect(parsed.terminal).toEqual({ mouse: false })
})

it('carries a dotted key into a table it writes as data, inside that table', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-dotted-')
await writeFile(
getConfigFilePath(),
'config_version = 1\nvim.motions = "helix"\nterminal.mouse = false\n\n[editor]\nfont_size = 18\n',
'utf8'
)
await initAppConfig(() => {})

const text = await readFile(getConfigFilePath(), 'utf8')
expect(text.match(/^\[vim\]$/gm)).toHaveLength(1)
const parsed = parseToml(text) as Record<string, any>
expect(parsed.vim.motions).toBe('helix')
expect(parsed.terminal).toEqual({ mouse: false })
expect(parsed.editor.font_size).toBe(18)
})

// The scan reads only the layout, so it must not take a bracket or a hash
// inside a value for a table or a comment.
it.each([
[
'literal strings',
`[terminal]\na = 'C:\\path #not a comment [x]'\nb = "say \\"hi\\" # still the string"\nc = '''\n[not.a.table]\n# not a comment\n'''`
],
['a multi-line array', `[terminal]\nlist = [\n "a]", # ]\n 'b[',\n """c\n]""",\n]\nafter = 1`],
[
'headers with comments and quoted keys',
`[ terminal ] # zn\nmouse = true\n\n[ terminal . "rules.x" ] # sub\n"k=v" = 1`
],
['dates and special numbers', `[terminal]\nd = 1979-05-27T07:32:00Z\nt = 07:32:00\nf = inf\nn = nan\nh = 0xff`]
])('carries %s as written', async (_name, block) => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-shape-')
await writeFile(getConfigFilePath(), `config_version = 1\n\n${block}\n`, 'utf8')
await initAppConfig(() => {})
await setPortableConfig({ editorLineHeight: 1.7 })
const text = await readFile(getConfigFilePath(), 'utf8')
expect(text.endsWith(`\n\n${block}\n`)).toBe(true)
expect(() => parseToml(text)).not.toThrow()
})

it('lets an entry set in the app replace a carried one with the same key', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-zn-collide-')
await writeFile(
getConfigFilePath(),
'config_version = 1\n\n[folder_labels]\ninbox = true\narchive = "Old"\n',
'utf8'
)
await initAppConfig(() => {})
// Not a string, so not a label the app writes: carried as it is.
expect(await readFile(getConfigFilePath(), 'utf8')).toMatch(/\[folder_labels\]\narchive = "Old"\ninbox = true\n/)

await setPortableConfig({ systemFolderLabels: { inbox: 'Notes', archive: 'Old' } })
const text = await readFile(getConfigFilePath(), 'utf8')
expect(text).not.toContain('inbox = true')
expect((parseToml(text) as Record<string, any>).folder_labels).toEqual({
inbox: 'Notes',
archive: 'Old'
})
})
})
Loading
Loading