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
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.29.0",
"version": "2.30.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
tomlKey: 'font_size',
comment: 'editor + preview font size (px)'
},
mathFontScale: {
section: 'editor',
tomlKey: 'math_font_scale',
comment: 'math size as a percentage (50-200); scales $…$ and $$…$$ in editor and preview'
},
editorLineHeight: { section: 'editor', tomlKey: 'line_height', comment: 'line-height multiplier' },
editorTabSize: {
section: 'editor',
Expand Down Expand Up @@ -276,6 +281,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
comment: 'code / monospace font; empty = system default'
},
// view
atlasEnabled: {
section: 'view',
tomlKey: 'atlas_enabled',
comment: 'the Atlas map view of the vault (sidebar row, command, Space g); on by default'
},
workflowsEnabled: {
section: 'view',
tomlKey: 'workflows_enabled',
Expand Down
47 changes: 37 additions & 10 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2990,25 +2990,39 @@ function registerIpc(): void {

// Workflows are authored as files in the vault, so remote workspaces (which
// have no local `.zennotes/workflows`) simply have none.
// Remote workspaces delegate every workflow call to the server's journalled
// workflow API from #608 when it is advertised; older servers stay
// read-only, matching the web client. (#618)
const requireRemoteWorkflows = async () => {
const client = requireRemoteWorkspaceClient();
if (!(await client.supportsWorkflows())) {
throw new Error(
"This ZenNotes server does not support workflows yet. Update the server and reconnect.",
);
}
return client;
};

handle(IPC.VAULT_LIST_WORKFLOWS, async () => {
if (isRemoteWorkspaceActive()) return [];
if (isRemoteWorkspaceActive()) {
const client = requireRemoteWorkspaceClient();
return (await client.supportsWorkflows()) ? await client.listWorkflows() : [];
}
const v = requireVault();
return await listWorkflowFiles(v.root);
});

// Authoring needs the local filesystem, so remote workspaces reject rather
// than resolve: a silent success would leave the editor believing it saved.
handle(IPC.VAULT_WRITE_WORKFLOW, async (_e, input: WriteWorkflowInput) => {
if (isRemoteWorkspaceActive()) {
throw new Error("Workflows are unavailable on remote vaults");
return await (await requireRemoteWorkflows()).writeWorkflow(input);
}
const v = requireVault();
return await writeWorkflowFile(v.root, input);
});

handle(IPC.VAULT_DELETE_WORKFLOW, async (_e, sourcePath: string) => {
if (isRemoteWorkspaceActive()) {
throw new Error("Workflows are unavailable on remote vaults");
return await (await requireRemoteWorkflows()).deleteWorkflow(sourcePath);
}
const v = requireVault();
return await deleteWorkflowFile(v.root, sourcePath);
Expand Down Expand Up @@ -3080,7 +3094,7 @@ function registerIpc(): void {
// the dry run and asked for it here.
handle(IPC.VAULT_APPLY_WORKFLOW, async (_e, input: ApplyWorkflowInput) => {
if (isRemoteWorkspaceActive()) {
throw new Error("Workflows are unavailable on remote vaults");
return await (await requireRemoteWorkflows()).applyWorkflow(input);
}
const v = requireVault();
return await applyWorkflowOps(v.root, input);
Expand All @@ -3091,7 +3105,7 @@ function registerIpc(): void {
// is unknown or already undone.
handle(IPC.VAULT_UNDO_WORKFLOW_RUN, async (_e, runId: string) => {
if (isRemoteWorkspaceActive()) {
throw new Error("Workflows are unavailable on remote vaults");
return await (await requireRemoteWorkflows()).undoWorkflowRun(runId);
}
const v = requireVault();
return await undoWorkflowRun(v.root, runId);
Expand All @@ -3100,13 +3114,21 @@ function registerIpc(): void {
// Run history is read from files in the vault, so a remote workspace simply
// has none, matching how it reports workflows themselves.
handle(IPC.VAULT_LIST_WORKFLOW_RUNS, async () => {
if (isRemoteWorkspaceActive()) return [];
if (isRemoteWorkspaceActive()) {
const client = requireRemoteWorkspaceClient();
return (await client.supportsWorkflows()) ? await client.listWorkflowRuns() : [];
}
const v = requireVault();
return await listWorkflowRuns(v.root);
});

handle(IPC.VAULT_DELETE_WORKFLOW_RUNS, async (_e, workflowId: string) => {
if (isRemoteWorkspaceActive()) return 0;
if (isRemoteWorkspaceActive()) {
if (typeof workflowId !== "string" || !workflowId) {
throw new Error("deleteWorkflowRuns needs a workflow id");
}
return await (await requireRemoteWorkflows()).deleteWorkflowRuns(workflowId);
}
if (typeof workflowId !== "string" || !workflowId) {
throw new Error("deleteWorkflowRuns needs a workflow id");
}
Expand Down Expand Up @@ -5051,7 +5073,12 @@ app.whenReady().then(async () => {

try {
const cfg = await loadConfig();
const desired = cfg.quickCaptureHotkey || DEFAULT_QUICK_CAPTURE_HOTKEY;
// loadConfig always yields a normalized string here, and empty string is
// the user's explicit "disabled" choice — registerQuickCaptureHotkey("")
// is a clean no-op. Falling back to the default on falsey re-registered
// the shortcut on every launch, which on Wayland invoked the
// global-shortcuts portal and popped GNOME's shortcut dialog. (#615)
const desired = cfg.quickCaptureHotkey;
const result = registerQuickCaptureHotkey(desired);
if (!result.ok) console.warn(result.error ?? `Failed to bind ${desired}`);
} catch (err) {
Expand Down
70 changes: 70 additions & 0 deletions apps/desktop/src/main/remote/server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export { connectionErrorMessage }

/** The server never answered: DNS/refused/timeout. The workspace may be
* fine; the network is not. Callers must never read this as "absent". */
import type {
ApplyWorkflowInput,
WorkflowFile,
WorkflowRunReceipt,
WorkflowRunSummary,
WorkflowUndoResult,
WriteWorkflowInput
} from '@zennotes/bridge-contract/workflows'
import { prepareWorkflowRun } from '@shared/workflows/prepare-run'

export class RemoteConnectionError extends Error {}

/** The server answered with a non-2xx status: it is alive and made a
Expand Down Expand Up @@ -131,6 +141,66 @@ export class RemoteServerClient {
return this.jsonRequest<VaultTextSearchMatch[]>(`/api/search/text?${params.toString()}`)
}

/** True when the connected server advertises the journalled workflow API
* from #608. Older servers stay read-only, exactly like the web client. */
async supportsWorkflows(): Promise<boolean> {
const caps = await this.getCapabilities()
return (caps as { supportsWorkflows?: boolean } | null)?.supportsWorkflows === true
}

async listWorkflows(): Promise<WorkflowFile[]> {
return this.jsonRequest<WorkflowFile[]>('/api/workflows')
}

async writeWorkflow(input: WriteWorkflowInput): Promise<WorkflowFile> {
return this.jsonRequest<WorkflowFile>('/api/workflows/write', {
method: 'POST',
body: input as unknown as Record<string, unknown>
})
}

async deleteWorkflow(sourcePath: string): Promise<void> {
await this.jsonRequest('/api/workflows/delete', { method: 'POST', body: { sourcePath } })
}

/** Prepare on this side (reads through the server), apply transactionally on
* the server — the same split the web bridge ships for #608. */
async applyWorkflow(input: ApplyWorkflowInput): Promise<WorkflowRunReceipt> {
const settings = await this.getVaultSettings()
const prepared = await prepareWorkflowRun(input, {
read: async (path: string) => {
try {
return (await this.readNote(path)).body
} catch {
return null
}
},
systemFolderDirs: settings.systemFolderPaths ?? {}
})
return this.jsonRequest<WorkflowRunReceipt>('/api/workflows/apply', {
method: 'POST',
body: prepared as unknown as Record<string, unknown>
})
}

async undoWorkflowRun(runId: string): Promise<WorkflowUndoResult> {
return this.jsonRequest<WorkflowUndoResult>('/api/workflows/undo', {
method: 'POST',
body: { runId }
})
}

async listWorkflowRuns(): Promise<WorkflowRunSummary[]> {
return this.jsonRequest<WorkflowRunSummary[]>('/api/workflows/runs')
}

async deleteWorkflowRuns(workflowId: string): Promise<number> {
return this.jsonRequest<number>('/api/workflows/runs/delete', {
method: 'POST',
body: { workflowId }
})
}

async readNote(relPath: string): Promise<NoteContent> {
return this.jsonRequest<NoteContent>(`/api/notes/read?path=${encodeURIComponent(relPath)}`)
}
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/tasklists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ describe('toggleTaskAtIndex', () => {
const md = '- [ ] only one'
expect(toggleTaskAtIndex(md, 5, true)).toBe(md)
})

it('checks an in-progress task off to done', () => {
expect(toggleTaskAtIndex('- [/] started', 0, true)).toBe('- [x] started')
})

it('unchecking keeps an in-progress task in progress (#599)', () => {
// set-checked:false arrives from Kanban drops between live columns;
// in-progress already is "not done", so the `/` must survive.
expect(toggleTaskAtIndex('- [/] started', 0, false)).toBe('- [/] started')
})
})

describe('setTaskCheckedAtIndex', () => {
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/mcp/vault-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { parseOpenNoteDeepLink } from '../main/deep-links'
import { createNote, listNotes, renameNote, scanAllTasks, searchText } from './vault-ops'
import {
createNote,
listNotes,
renameNote,
scanAllTasks,
searchText,
toggleTaskInBody
} from './vault-ops'

// Every note-shaped MCP result carries `link`, the zennotes:// deep link a
// model renders as a markdown link so the user can click from chat straight
Expand Down Expand Up @@ -79,6 +86,15 @@ describe('mcp task states', () => {
expect(byContent.get('open')?.inProgress).toBe(false)
expect(byContent.get('done')?.checked).toBe(true)
})

it('toggle follows the app rules: [/] checks off, records stay (#599)', () => {
const body = '- [ ] open\n- [/] started\n- [x] done\n- [-] scrapped\n- [>] gone\n'
expect(toggleTaskInBody(body, 0)).toContain('- [x] open')
expect(toggleTaskInBody(body, 1)).toContain('- [x] started')
expect(toggleTaskInBody(body, 2)).toContain('- [ ] done')
expect(toggleTaskInBody(body, 3)).toContain('- [-] scrapped')
expect(toggleTaskInBody(body, 4)).toContain('- [>] gone')
})
})

describe('remapped system folders (#398)', () => {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/mcp/vault-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1743,7 +1743,12 @@ export function toggleTaskInBody(body: string, targetIndex: number): string | nu
(_m, ch: string, tail: string) => {
const fullMatch = original.match(TASK_LINE_RE)!
const bracketIdx = original.indexOf('[' + ch + ']')
const next = ch === ' ' ? 'x' : ' '
// Same rules as the app's toggle (cm-toggle-checkbox / toggleTaskAtIndex):
// open and done flip, in-progress `[/]` checks off to done, and the
// forwarded / cancelled record markers are left alone. `[/]` used to fall
// into the "anything else opens" branch, silently erasing it. (#599)
if (ch === '>' || ch === '-') return fullMatch[0]
const next = /[xX]/.test(ch) ? ' ' : 'x'
// Preserve the full prefix (list marker, whitespace) by splicing only
// the single character inside the brackets.
if (bracketIdx >= 0) {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/server",
"private": true,
"version": "2.29.0",
"version": "2.30.0",
"scripts": {
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.29.0",
"version": "2.30.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
Loading
Loading