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
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- ubuntu-24.04-arm
- macos-latest
- windows-latest
# And its Windows counterpart, where the ARM64 release build comes
# from (#928).
- windows-11-arm
steps:
- name: Check out repository
uses: actions/checkout@v6
Expand All @@ -56,7 +59,7 @@ jobs:
run: npm ci

- name: Verify terminal artifacts and legacy launchers
if: matrix.os != 'windows-latest'
if: runner.os != 'Windows'
run: node --test tooling/scripts/terminal-artifact.test.mjs tooling/scripts/terminal-launcher.test.mjs

- name: Verify standalone shared packages
Expand Down Expand Up @@ -97,3 +100,13 @@ jobs:

- name: Typecheck and build app
run: npm run build:prod

- name: Package the ARM64 Windows app and check it launches
if: matrix.os == 'windows-11-arm'
shell: bash
# No maintainer has an ARM64 Windows machine, so this is where the
# native build is proven to start before a release ships it (#928):
# a CDP page target, the app drawn, and an ARM64 executable.
run: |
npm exec --workspace @zennotes/desktop -- electron-builder --win dir --arm64 --publish never
node tooling/scripts/packaged-launch-check.mjs dist/win-arm64-unpacked/ZenNotes.exe --expect-arch arm64
31 changes: 30 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ on:
- all
- macOS
- Windows
- Windows arm64
- Linux x64
- Linux arm64
keep:
Expand Down Expand Up @@ -66,11 +67,15 @@ jobs:
env:
PLATFORM: ${{ inputs.platform || 'all' }}
# "Linux arm64" is GitHub's free arm64 Linux runner for public repos:
# native aarch64 artifacts with no cross-compilation.
# native aarch64 artifacts with no cross-compilation. "Windows arm64"
# is its Windows counterpart (#928). Its script names the architecture
# (dist:win:arm64), so a toolchain that came up emulated as x64 cannot
# build an x64 copy that lands on the x64 job's file names.
run: |
all='[
{"name":"macOS","os":"macos-latest","command":"npm run dist:mac"},
{"name":"Windows","os":"windows-latest","command":"npm run dist:win"},
{"name":"Windows arm64","os":"windows-11-arm","command":"npm run dist:win:arm64 --workspace @zennotes/desktop"},
{"name":"Linux x64","os":"ubuntu-latest","command":"npm run dist:linux"},
{"name":"Linux arm64","os":"ubuntu-24.04-arm","command":"npm run dist:linux"}
]'
Expand Down Expand Up @@ -178,6 +183,8 @@ jobs:
- name: Collect release artifacts
id: collect
shell: bash
env:
PLATFORM_NAME: ${{ matrix.name }}
run: |
mkdir -p release-assets
find dist -maxdepth 1 -type f \
Expand All @@ -187,6 +194,28 @@ jobs:
echo "No release artifacts found in dist/" >&2
exit 1
fi
# The two Windows jobs upload to the same release. Each must hold
# only its own architecture, or one overwrites the other's files.
case "${PLATFORM_NAME}" in
Windows) foreign='-win-arm64' ;;
"Windows arm64") foreign='-win-x64' ;;
*) foreign='' ;;
esac
if [ -n "${foreign}" ] && ls release-assets | grep -q -- "${foreign}"; then
echo "The ${PLATFORM_NAME} build produced ${foreign} files:" >&2
ls release-assets >&2
exit 1
fi
if [ "${PLATFORM_NAME}" = "Windows arm64" ]; then
# electron-builder names every Windows feed latest.yml whatever the
# architecture; that name is the x64 job's. The ARM64 feed goes out
# as latest-arm64.yml, which the ARM64 app reads (updater.ts).
if [ ! -f release-assets/latest.yml ]; then
echo "The Windows arm64 build wrote no latest.yml." >&2
exit 1
fi
mv release-assets/latest.yml release-assets/latest-arm64.yml
fi
ls -l release-assets

- name: Keep the build as a workflow artifact
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ Or download the `.dmg` for your chip, open it, and drag **ZenNotes** to Applicat

### Windows

Download and run `ZenNotes-<version>-win-x64.exe`.
Download and run the installer for your PC:

- Intel and AMD: `ZenNotes-<version>-win-x64.exe`
- ARM (Snapdragon and other Windows on ARM): `ZenNotes-<version>-win-arm64.exe`

### Linux

Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ function rendererManualChunk(id: string): string | undefined {
return 'vendor-editor-languages'
}

// The merge view only serves reviewing an assistant's edits, which
// cm-agent-review.ts imports on demand. The rule below would pull it into
// vendor-editor, onto every launch; left alone it stays in that lazy chunk.
if (id.includes('/@codemirror/merge/')) return undefined

if (
id.includes('/@codemirror/') ||
id.includes('/codemirror/') ||
Expand Down
3 changes: 2 additions & 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.67.0",
"version": "2.68.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down Expand Up @@ -34,6 +34,7 @@
"pack": "npm run build:prod && electron-builder --dir",
"dist:mac": "npm run build:prod && electron-builder --mac --publish never",
"dist:win": "npm run build:prod && electron-builder --win --publish never",
"dist:win:arm64": "npm run build:prod && electron-builder --win --arm64 --publish never",
"dist:linux": "npm run build:prod && electron-builder --linux --publish never"
},
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/cli/commands/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function cmdTaskToggle(vault: VaultBackend, args: ParsedArgs): Prom
const next = await vault.toggleTask(id)
if (next == null) {
throw new Error(
`Task ${id} no longer exists at that location — the file may have changed. Run \`zn task list\` again.`
`Task ${id} no longer exists at that location (the file may have changed). Run \`zn task list\` again.`
)
}
if (getBool(args, 'json')) {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const SECTIONS: Array<{ heading: string; rows: CommandRow[] }> = [
{ name: 'list', description: 'List notes, most recent first', flags: '--folder <f> --tag <t> --limit <n> --json' },
{ name: 'read <path>', description: 'Print a note body to stdout', flags: '--meta --json' },
{ name: 'create', description: 'Create a new note. Body from --body or stdin', flags: '--title <t> --folder inbox|quick|archive --subpath <p> --tag <t> --body "..."|-' },
{ name: 'write <path>', description: 'Replace a note body. Destructive — prefer append', flags: '--body "..."|-' },
{ name: 'write <path>', description: 'Replace a note body. Destructive, so prefer append', flags: '--body "..."|-' },
{ name: 'append <path>', description: 'Append text to the end of a note', flags: '--body "..."|-' },
{ name: 'prepend <path>', description: 'Insert text at the top, after frontmatter', flags: '--body "..."|-' },
{ name: 'rename <path>', description: 'Rename a note (filename only)', flags: '--to <new title>' },
Expand Down
155 changes: 155 additions & 0 deletions apps/desktop/src/main/agent-edit-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Agent edit records on disk: `.zennotes/agent-edits/<note>.json`, one per
// note an assistant changed through MCP and nobody has reviewed yet (see
// @shared/agent-edits and docs/ideas/agent-edit-review.md). The MCP server
// writes them and the app reviews them, and the MCP server cannot import
// vault.ts (it pulls in Electron), so this stays free of Electron like
// note-sidecars.ts.
import { promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import {
AGENT_EDITS_DIR,
AGENT_EDITS_SUFFIX,
latestAgentEdit,
parseAgentEditRecord,
serializeAgentEditRecord,
type AgentEditRecord
} from '@shared/agent-edits'
import type { AgentEditSummary } from '@shared/ipc'
import { renameWithRetry } from './atomic-write'

const INTERNAL_VAULT_DIR = '.zennotes'

function toPosix(rel: string): string {
return rel.split(path.sep).join('/')
}

export function agentEditsRoot(root: string): string {
return path.join(root, INTERNAL_VAULT_DIR, AGENT_EDITS_DIR)
}

/** The record file for a vault-relative note path, refusing any escape. */
export function agentEditPath(root: string, rel: string): string {
const base = path.resolve(agentEditsRoot(root))
const abs = path.resolve(base, `${toPosix(rel)}${AGENT_EDITS_SUFFIX}`)
if (!abs.startsWith(base + path.sep)) throw new Error(`Path escapes the vault: ${rel}`)
return abs
}

/** The note's record, or null when there is none or it is not readable. */
export async function readAgentEditRecord(root: string, rel: string): Promise<AgentEditRecord | null> {
try {
return parseAgentEditRecord(JSON.parse(await fs.readFile(agentEditPath(root, rel), 'utf8')))
} catch {
return null
}
}

/** Write the record (temp file, fsync, rename), or remove it for `null`. */
export async function writeAgentEditRecord(
root: string,
rel: string,
record: AgentEditRecord | null
): Promise<void> {
const file = agentEditPath(root, rel)
if (record === null) {
await removeAgentEditFile(root, file)
return
}
await fs.mkdir(path.dirname(file), { recursive: true })
const temporary = `${file}.${randomUUID()}.tmp`
try {
const handle = await fs.open(temporary, 'wx')
try {
await handle.writeFile(serializeAgentEditRecord(record))
await handle.sync()
} finally {
await handle.close()
}
await renameWithRetry(temporary, file)
} finally {
await fs.rm(temporary, { force: true })
}
}

/** The folder holding the records of every note under `folderRel`, for
* moving or deleting a whole folder with its notes. */
export function agentEditFolderPath(root: string, folderRel: string): string {
const base = path.resolve(agentEditsRoot(root))
const abs = path.resolve(base, toPosix(folderRel))
if (!abs.startsWith(base + path.sep)) throw new Error(`Path escapes the vault: ${folderRel}`)
return abs
}

/** Remove a note's record, or with `directory` every record under a folder:
* a deleted note has nothing left to review. */
export async function removeAgentEditRecord(
root: string,
rel: string,
directory = false
): Promise<void> {
if (!directory) {
await removeAgentEditFile(root, agentEditPath(root, rel))
return
}
const folder = agentEditFolderPath(root, rel)
await fs.rm(folder, { recursive: true, force: true })
await pruneEmptyDirs(root, path.dirname(folder))
}

async function removeAgentEditFile(root: string, file: string): Promise<void> {
await fs.rm(file, { force: true })
await pruneEmptyDirs(root, path.dirname(file))
}

/** Leave no empty folders behind, agent-edits itself included: a vault whose
* changes have all been reviewed carries no trace of them. */
async function pruneEmptyDirs(root: string, from: string): Promise<void> {
const stop = path.resolve(agentEditsRoot(root))
for (let dir = path.resolve(from); dir === stop || dir.startsWith(stop + path.sep); dir = path.dirname(dir)) {
try {
await fs.rmdir(dir)
} catch {
break
}
}
}

/** Every record in the vault, newest edit first. Unreadable files are skipped. */
export async function listAgentEditRecords(root: string): Promise<AgentEditSummary[]> {
const base = agentEditsRoot(root)
const found: AgentEditSummary[] = []
async function walk(dir: string): Promise<void> {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const abs = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(abs)
continue
}
if (!entry.isFile() || !entry.name.endsWith(AGENT_EDITS_SUFFIX)) continue
const notePath = toPosix(path.relative(base, abs)).slice(0, -AGENT_EDITS_SUFFIX.length)
let record: AgentEditRecord | null = null
try {
record = parseAgentEditRecord(JSON.parse(await fs.readFile(abs, 'utf8')))
} catch {
record = null
}
if (!record) continue
const latest = latestAgentEdit(record)
found.push({
notePath,
agent: latest?.agent ?? 'Assistant',
at: latest?.at ?? 0,
edits: record.edits.length
})
}
}
await walk(base)
return found.sort((a, b) => b.at - a.at)
}
2 changes: 1 addition & 1 deletion apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ describe('TOML serialization', () => {
)
expect(text).toContain('[vim]')
expect(text).toContain(
'wrapped_line_motions = "display" # display | logical — how $, I, A and dependent operators treat soft-wrapped lines'
'wrapped_line_motions = "display" # display | logical: how $, I, A and dependent operators treat soft-wrapped lines'
)
expect(text).toContain('[view]')
// Keymaps: every action listed as a commented, grouped default reference.
Expand Down
Loading
Loading