diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cdd52695..c73ea65e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.55.0", + "version": "2.56.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/cli/commands/vault.test.ts b/apps/desktop/src/cli/commands/vault.test.ts index 3187765e..4fb220d3 100644 --- a/apps/desktop/src/cli/commands/vault.test.ts +++ b/apps/desktop/src/cli/commands/vault.test.ts @@ -66,6 +66,28 @@ describe('resolveVaultSelector', () => { expect(await resolveVaultSelector(personalVault)).toBe(personalVault) }) + it('a vault renamed in the app answers to its display name and to its folder name (#692)', async () => { + await writeConfig({ + vaultRoot: workVault, + localVaults: [ + { root: workVault, name: 'Acme API docs', lastOpenedAt: 2 }, + { root: personalVault, name: 'personal', lastOpenedAt: 1 } + ] + }) + expect(await resolveVaultSelector('acme api docs')).toBe(workVault) + expect(await resolveVaultSelector('Work Vault')).toBe(workVault) + // The display name wins over another vault's folder name. + await fsp.mkdir(path.join(tmpDir, 'Acme API docs'), { recursive: true }) + await writeConfig({ + vaultRoot: workVault, + localVaults: [ + { root: workVault, name: 'Acme API docs', lastOpenedAt: 2 }, + { root: path.join(tmpDir, 'Acme API docs'), name: 'Other', lastOpenedAt: 1 } + ] + }) + expect(await resolveVaultSelector('Acme API docs')).toBe(workVault) + }) + it('lists known vault names on a bad selector', async () => { await expect(resolveVaultSelector('nope')).rejects.toThrow( /No vault named "nope".*Work Vault, personal/ diff --git a/apps/desktop/src/main/database-domain.test.ts b/apps/desktop/src/main/database-domain.test.ts index 5ca775a4..0b9dbbc3 100644 --- a/apps/desktop/src/main/database-domain.test.ts +++ b/apps/desktop/src/main/database-domain.test.ts @@ -5,6 +5,7 @@ import { parseRows, serializeRows, inferFields, + initialDatabaseContents, buildDefaultViews, type GenId } from '@shared/database-csv' @@ -142,6 +143,98 @@ describe('inferFields', () => { }) }) +describe('initialDatabaseContents', () => { + it('without a seed is the empty id + Name grid', () => { + const { sidecar, rows } = initialDatabaseContents(undefined, counterGenId()) + expect(rows).toEqual([]) + expect(sidecar.fields.map((f) => [f.name, f.type, f.hidden ?? false])).toEqual([ + ['id', 'text', true], + ['Name', 'text', false] + ]) + expect(sidecar.idFieldId).toBe(sidecar.fields[0].id) + expect(sidecar.views[0].hiddenFieldIds).toEqual([sidecar.idFieldId]) + expect(serializeRows(rows, sidecar.fields)).toBe('id,Name\n') + }) + + it('types a seeded table like an adopted CSV and keys rows by field id (#832)', () => { + const { sidecar, rows } = initialDatabaseContents( + { + headers: ['Task', 'Done', 'Due', 'Hours'], + rows: [ + ['Write **draft**', 'x', '2026-03-01', '2'], + ['Ship, then rest', '', '2026-03-09', '0.5'] + ] + }, + counterGenId() + ) + const byName = new Map(sidecar.fields.map((f) => [f.name, f])) + // A hidden id field is synthesized in front of the table's own columns. + expect(sidecar.fields[0].name).toBe('id') + expect(sidecar.fields[0].id).toBe(sidecar.idFieldId) + expect(byName.get('Task')!.type).toBe('text') + expect(byName.get('Done')!.type).toBe('checkbox') + expect(byName.get('Due')!.type).toBe('date') + expect(byName.get('Hours')!.type).toBe('number') + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.id).toBeTruthy() + expect(row.cells[sidecar.idFieldId]).toBe(row.id) + } + expect(rows[0].cells[byName.get('Task')!.id]).toBe('Write **draft**') + expect(rows[1].cells[byName.get('Done')!.id]).toBe('') + // What lands in data.csv: RFC 4180 quoting for the comma, markdown kept verbatim. + const csv = serializeRows(rows, sidecar.fields) + expect(csv.split('\n')[0]).toBe('id,Task,Done,Due,Hours') + expect(csv).toContain(',"Ship, then rest",,2026-03-09,0.5\n') + expect(parseRows(csv, sidecar.fields, sidecar.idFieldId)).toEqual(rows) + }) + + it('adopts an all-unique id column from the seed instead of synthesizing one', () => { + const { sidecar, rows } = initialDatabaseContents( + { headers: ['id', 'Name'], rows: [['a1', 'Alpha'], ['b2', 'Beta']] }, + counterGenId() + ) + expect(sidecar.fields.map((f) => f.name)).toEqual(['id', 'Name']) + expect(sidecar.fields[0].hidden).toBe(true) + expect(rows.map((r) => r.id)).toEqual(['a1', 'b2']) + }) + + it('renames blank and duplicate headers and pads short rows', () => { + const { sidecar, rows } = initialDatabaseContents( + { headers: ['', 'Name', 'Name'], rows: [['only one cell']] }, + counterGenId() + ) + expect(sidecar.fields.map((f) => f.name)).toEqual(['id', 'Column 1', 'Name', 'Name (2)']) + const [, c1, n1, n2] = sidecar.fields + expect(rows[0].cells[c1.id]).toBe('only one cell') + expect(rows[0].cells[n1.id]).toBe('') + expect(rows[0].cells[n2.id]).toBe('') + }) + + it('carries positive column widths onto the matching fields and drops the rest', () => { + const { sidecar } = initialDatabaseContents( + { + headers: ['A', 'B', 'C'], + rows: [], + columnWidths: [120, null, -4] + }, + counterGenId() + ) + const widths = sidecar.fields.map((f) => f.width) + expect(widths).toEqual([undefined, 120, undefined, undefined]) + }) + + it('a header-only seed makes an empty database with those fields', () => { + const { sidecar, rows } = initialDatabaseContents( + { headers: ['Title', 'Notes'], rows: [] }, + counterGenId() + ) + expect(rows).toEqual([]) + expect(sidecar.fields.map((f) => f.name)).toEqual(['id', 'Title', 'Notes']) + expect(sidecar.fields.slice(1).every((f) => f.type === 'text')).toBe(true) + }) +}) + describe('transforms', () => { const fields: DbField[] = [ { id: 'name', name: 'Name', type: 'text' }, diff --git a/apps/desktop/src/main/databases.test.ts b/apps/desktop/src/main/databases.test.ts index 4a40089b..70bd93f6 100644 --- a/apps/desktop/src/main/databases.test.ts +++ b/apps/desktop/src/main/databases.test.ts @@ -53,6 +53,38 @@ describe('createDatabase + readDatabase', () => { const b = await createDatabase(root, 'inbox', '', 'Notes') expect(a.path).not.toBe(b.path) }) + + it('writes a seeded database with the seed rows and inferred types (#832)', async () => { + const root = await makeVault() + const doc = await createDatabase(root, 'inbox', 'Work', 'Roadmap', { + headers: ['Item', 'Done', 'Estimate'], + rows: [ + ['Write "spec", v1', 'yes', '3'], + ['Ship', 'no', '1'] + ], + columnWidths: [240, null, null] + }) + expect(doc.path).toBe('inbox/Work/Roadmap.base/data.csv') + expect(doc.fields.map((f) => [f.name, f.type])).toEqual([ + ['id', 'text'], + ['Item', 'text'], + ['Done', 'checkbox'], + ['Estimate', 'number'] + ]) + expect(doc.fields[1].width).toBe(240) + expect(doc.rows).toHaveLength(2) + + const csv = await readFile(path.join(root, doc.path), 'utf8') + const lines = csv.trimEnd().split('\n') + expect(lines[0]).toBe('id,Item,Done,Estimate') + expect(lines[1]).toBe(`${doc.rows[0].id},"Write ""spec"", v1",yes,3`) + expect(lines[2]).toBe(`${doc.rows[1].id},Ship,no,1`) + + // Reopening through the sidecar yields the same rows and ids. + const reopened = await readDatabase(root, doc.path) + expect(reopened.rows).toEqual(doc.rows) + expect(reopened.fields).toEqual(doc.fields) + }) }) describe('writeDatabaseRows round-trip', () => { diff --git a/apps/desktop/src/main/databases.ts b/apps/desktop/src/main/databases.ts index 4817fdad..fb7ea89f 100644 --- a/apps/desktop/src/main/databases.ts +++ b/apps/desktop/src/main/databases.ts @@ -10,6 +10,7 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' import { inferFields, + initialDatabaseContents, buildDefaultViews, parseCsv, parseRows, @@ -23,6 +24,7 @@ import { formTitleFromCsvPath, isFormDirName, type DatabaseDoc, + type DatabaseSeed, type DatabaseSidecar, type DatabaseSummary, type DbField, @@ -250,15 +252,18 @@ export async function writeDatabaseSchema( } /** - * Create a new empty database (`id` + `Name` fields) under `folder`/`subpath` - * and return it hydrated. Uses `folderRoot` so a root-mode vault creates at the - * vault root rather than inventing an `inbox/` directory. + * Create a new database under `folder`/`subpath` and return it hydrated: empty + * (`id` + `Name` fields) by default, or holding the `seed`'s columns and rows + * when one is given (a Markdown table converted in place, #832). Uses + * `folderRoot` so a root-mode vault creates at the vault root rather than + * inventing an `inbox/` directory. */ export async function createDatabase( root: string, folder: NoteFolder, subpath: string, - title?: string + title?: string, + seed?: DatabaseSeed ): Promise { const safeTitle = (title ?? 'Untitled Database').trim() || 'Untitled Database' const baseName = safeTitle.replace(/[\\/:*?"<>|]/g, '-') @@ -281,22 +286,12 @@ export async function createDatabase( } const rel = csvPathForFormDir(formDirRel) - const idField: DbField = { id: randomUUID(), name: 'id', type: 'text', hidden: true } - const nameField: DbField = { id: randomUUID(), name: 'Name', type: 'text' } - const fields = [idField, nameField] - const { views, activeViewId } = buildDefaultViews(fields) - const sidecar: DatabaseSidecar = { - version: 1, - idFieldId: idField.id, - fields, - views, - activeViewId - } + const { sidecar, rows } = initialDatabaseContents(seed, randomUUID) // Create the folder, then the two data files. await fs.mkdir(databaseDataPath(root, formDirRel), { recursive: true }) await persistSidecar(root, rel, sidecar) - await writeFileAtomic(databaseDataPath(root, rel), serializeRows([], fields)) - return hydrate(rel, sidecar, []) + await writeFileAtomic(databaseDataPath(root, rel), serializeRows(rows, sidecar.fields)) + return hydrate(rel, sidecar, rows) } /** diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e5a00e13..ecb78a47 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -62,6 +62,7 @@ import { DEFAULT_QUICK_CAPTURE_HOTKEY, deleteFolder, deleteNote, + describeVault, duplicateAsset, duplicateFolder, duplicateNote, @@ -107,6 +108,7 @@ import { type PersistedRemoteWorkspaceProfile, type PersistedWindowState, rememberLocalVault, + renameLocalVault, updateConfig, unarchiveNote, vaultInfo, @@ -210,7 +212,11 @@ import { type DatabaseOps as SharedDatabaseOps, } from "@shared/database-ops"; import { createAbsenceAwareReader } from "@shared/remote-absence"; -import type { DatabaseSidecar, DbRow } from "@shared/databases"; +import type { + DatabaseSeed, + DatabaseSidecar, + DbRow, +} from "@shared/databases"; import { VaultWatcher } from "./watcher"; import { WindowVaultRegistry } from "./window-vaults"; import { registerEphemeralRoot, isEphemeralRoot } from "./ephemeral-vaults"; @@ -1954,7 +1960,7 @@ async function setVaultForWindow( await ensureVaultLayout(root); } const vault = { - ...vaultInfo(path.resolve(root)), + ...(await describeVault(path.resolve(root))), temporary: options.ephemeral === true, }; windowVaults.setLocalVault(win.id, vault); @@ -1984,7 +1990,7 @@ async function setVault(root: string): Promise { if (win && !win.isDestroyed()) return await setVaultForWindow(win, root); await ensureVaultLayout(root); - const vault = vaultInfo(path.resolve(root)); + const vault = await describeVault(path.resolve(root)); currentVault = vault; currentWorkspaceMode = "local"; remoteWorkspaceClient = null; @@ -2057,6 +2063,14 @@ async function closeLocalVaultForWindow(): Promise { } async function listLocalVaults(): Promise { + // The asking window's vault first: a display name that changed on disk + // (#692) reaches the remembered list here, so the switcher and the sidebar + // header, which read that list, never lag behind the vault they show. + const win = currentIpcWindow(); + const open = win ? windowVaults.vaultForWindow(win.id) : currentVault; + if (open && (win ? windowVaults.modeForWindow(win.id) : currentWorkspaceMode) === "local") { + await syncLocalVaultName(open.root); + } const cfg = await loadConfig(); let entries = cfg.localVaults; if ( @@ -2066,7 +2080,7 @@ async function listLocalVaults(): Promise { ) ) { try { - entries = rememberLocalVault(entries, vaultInfo(cfg.vaultRoot), 0); + entries = rememberLocalVault(entries, await describeVault(cfg.vaultRoot), 0); } catch { entries = [ { @@ -2632,13 +2646,51 @@ async function connectRemoteWorkspaceProfile( return result; } +/** + * Re-read a local vault's display name (#692) and give every window on the + * root, `currentVault` and the remembered-vaults list the result. After a + * settings save this is what makes a rename land everywhere at once; on a + * renderer's getCurrentVault and on every listing it is what makes an edit + * to vault.json from outside (a git pull, another machine) show up. The + * remembered list is compared on its own, not inferred from the windows: a + * boot opens the vault without persisting, so the windows can already carry + * the name while the list still holds the one from the last visit, and the + * sidebar header reads the list. Returns the name in effect. + */ +async function syncLocalVaultName(root: string): Promise { + const { name } = await describeVault(root); + windowVaults.renameLocalVault(root, name); + if ( + currentVault && + currentWorkspaceMode === "local" && + path.resolve(currentVault.root) === path.resolve(root) && + currentVault.name !== name + ) { + currentVault = { ...currentVault, name }; + } + const remembered = (await loadConfig()).localVaults.find( + (entry) => path.resolve(entry.root) === path.resolve(root), + ); + if (remembered && remembered.name !== name) { + await updateConfig((cfg) => ({ + ...cfg, + localVaults: renameLocalVault(cfg.localVaults, root, name), + })); + } + return name; +} + async function loadCurrentVaultFromConfig(): Promise { const win = currentIpcWindow() ?? mainWindow; if (win && !win.isDestroyed()) { const existing = windowVaults.vaultForWindow(win.id); - if (existing) return existing; + if (existing) { + if (windowVaults.modeForWindow(win.id) !== "local") return existing; + return { ...existing, name: await syncLocalVaultName(existing.root) }; + } } else if (currentVault) { - return currentVault; + if (currentWorkspaceMode !== "local") return currentVault; + return { ...currentVault, name: await syncLocalVaultName(currentVault.root) }; } const cfg = await loadConfig(); remoteWorkspaceConfig = cfg.remoteWorkspace; @@ -3266,7 +3318,11 @@ function registerIpc(): void { return await requireRemoteWorkspaceClient().setVaultSettings(next); } const v = requireVault(); - return await setVaultSettings(v.root, next); + const saved = await setVaultSettings(v.root, next); + // The display name may have moved with this save (#692); the windows on + // this root and the remembered list follow before the renderer hears back. + await syncLocalVaultName(v.root); + return saved; }); // Per-vault workspace state (#292): open tabs, pane layout, sidebar, cursors. @@ -3809,15 +3865,28 @@ function registerIpc(): void { handle( IPC.VAULT_CREATE_DATABASE, - async (_e, folder: NoteFolder, subpath: string, title?: string) => { + async ( + _e, + folder: NoteFolder, + subpath: string, + title?: string, + seed?: DatabaseSeed, + ) => { if (isRemoteWorkspaceActive()) { return await databaseOpsForRemote().createDatabase( folder, subpath, title, + seed, ); } - return await createDatabase(requireVault().root, folder, subpath, title); + return await createDatabase( + requireVault().root, + folder, + subpath, + title, + seed, + ); }, ); diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 397c3a3d..42b68bd3 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -10,6 +10,7 @@ import { archiveNote, deleteAsset, deleteNote, + describeVault, duplicateAsset, emptyDeletedAssets, emptyTrash, @@ -34,6 +35,7 @@ import { purgeDeletedAsset, renameAsset, renameFolder, + renameLocalVault, deleteFolder, readNoteComments, writeNoteComments, @@ -134,6 +136,49 @@ describe('daily-notes task settings round-trip (#288)', () => { }) }) +describe('vault display name (#692)', () => { + it('round-trips through set/get, normalized, and leaves vault.json without the key when cleared', async () => { + const root = await makeTempDir('zennotes-vault-displayname-') + const base = await getVaultSettings(root) + expect(base.displayName).toBeUndefined() + const returned = await setVaultSettings(root, { ...base, displayName: ' Acme API docs ' }) + expect(returned.displayName).toBe('Acme API docs') + expect((await getVaultSettings(root)).displayName).toBe('Acme API docs') + const onDisk = JSON.parse(await readFile(path.join(root, '.zennotes', 'vault.json'), 'utf8')) + expect(onDisk.displayName).toBe('Acme API docs') + + await setVaultSettings(root, { ...returned, displayName: ' ' }) + expect((await getVaultSettings(root)).displayName).toBeUndefined() + const cleared = JSON.parse(await readFile(path.join(root, '.zennotes', 'vault.json'), 'utf8')) + expect('displayName' in cleared).toBe(false) + }) + + it('describeVault names the vault by its display name, else by its folder', async () => { + const root = await makeTempDir('zennotes-vault-describe-') + expect(await describeVault(root)).toEqual({ root, name: path.basename(root) }) + const base = await getVaultSettings(root) + await setVaultSettings(root, { ...base, displayName: 'Acme API docs' }) + expect(await describeVault(root)).toEqual({ root, name: 'Acme API docs' }) + // A folder that is gone still describes itself rather than throwing. + const missing = path.join(root, 'nope') + expect(await describeVault(missing)).toEqual({ root: missing, name: 'nope' }) + }) + + it('renameLocalVault changes one entry in place and is not a visit', () => { + const entries = [ + { root: '/vaults/a', name: 'a', lastOpenedAt: 30 }, + { root: '/vaults/docs', name: 'docs', lastOpenedAt: 20 }, + { root: '/vaults/c', name: 'c', lastOpenedAt: 10 } + ] + expect(renameLocalVault(entries, '/vaults/docs/', 'Acme API docs')).toEqual([ + { root: '/vaults/a', name: 'a', lastOpenedAt: 30 }, + { root: '/vaults/docs', name: 'Acme API docs', lastOpenedAt: 20 }, + { root: '/vaults/c', name: 'c', lastOpenedAt: 10 } + ]) + expect(renameLocalVault(entries, '/vaults/unknown', 'x')).toEqual(entries) + }) +}) + describe('file-location settings round-trip (#446)', () => { it('persists tasksLocation through set/get (drawings/databases already worked)', async () => { const root = await makeTempDir('zennotes-vault-tasksloc-') diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 3018d248..9f443205 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -92,6 +92,7 @@ import { type SystemFolderPaths } from '@shared/system-folder-paths' import { normalizeTasksExcludedFolders } from '@shared/tasks-excluded-folders' +import { normalizeVaultDisplayName, resolveVaultName } from '@shared/vault-display-name' import { isTypstPreamblePath, normalizeTypstPreambleSettings, @@ -489,6 +490,22 @@ export function forgetLocalVault( return entries.filter((entry) => path.resolve(entry.root) !== target) } +/** + * The remembered list with one vault's name replaced, in place: order and + * `lastOpenedAt` are untouched, because a rename is not a visit (#692). A + * vault the list does not hold is left for the next open to add. + */ +export function renameLocalVault( + entries: PersistedLocalVault[], + root: string, + name: string +): PersistedLocalVault[] { + const target = path.resolve(root) + return entries.map((entry) => + path.resolve(entry.root) === target ? { ...entry, name } : entry + ) +} + function configBackupPath(): string { return `${configPath()}.bak` } @@ -799,6 +816,7 @@ export function databaseSidecarPath(root: string, rel: string): string { function cloneVaultSettings(settings: VaultSettings): VaultSettings { return { + ...(settings.displayName ? { displayName: settings.displayName } : {}), primaryNotesLocation: settings.primaryNotesLocation, dailyNotes: { enabled: settings.dailyNotes.enabled, @@ -1066,6 +1084,7 @@ function normalizeVaultSettings( } } const candidate = value as { + displayName?: unknown primaryNotesLocation?: unknown dailyNotes?: { enabled?: unknown @@ -1113,6 +1132,9 @@ function normalizeVaultSettings( } } return { + // Undefined when unset, which JSON.stringify leaves out, so a vault that + // never named itself keeps a vault.json without the key (#692). + displayName: normalizeVaultDisplayName(candidate.displayName), primaryNotesLocation: normalizePrimaryNotesLocation( candidate.primaryNotesLocation ?? fallbackPrimary ), @@ -1870,10 +1892,28 @@ async function migrateOneLegacyDatabase( return true } +/** A vault by its folder: the name is the directory's own. Cheap and sync; + * where the app shows the vault, `describeVault` is the one to call. */ export function vaultInfo(root: string): VaultInfo { return { root, name: path.basename(root) } } +/** + * The vault as the app names it: its display name from vault.json when it + * has one (#692), else the folder name. Reads through the settings cache, so + * after the first open it costs one stat. A folder that cannot be read still + * describes itself by name, the way it always did. + */ +export async function describeVault(root: string): Promise { + const info = vaultInfo(root) + try { + const settings = await getVaultSettings(root) + return { ...info, name: resolveVaultName(settings.displayName, info.name) } + } catch { + return info + } +} + function toPosix(p: string): string { return p.split(path.sep).join('/') } diff --git a/apps/desktop/src/main/window-vaults.test.ts b/apps/desktop/src/main/window-vaults.test.ts index 629a6374..5df7f735 100644 --- a/apps/desktop/src/main/window-vaults.test.ts +++ b/apps/desktop/src/main/window-vaults.test.ts @@ -135,4 +135,32 @@ describe('WindowVaultRegistry', () => { expect(registry.localVaultsExcept(rootA)).toEqual([{ root: rootB, name: 'B' }]) }) + + it('renames a local vault in every window on that root without touching its watcher (#692)', () => { + const watchers: TestWatcher[] = [] + const registry = new WindowVaultRegistry({ + makeWatcher: () => { + const watcher = new TestWatcher() + watchers.push(watcher) + return watcher + }, + invalidateVault: () => {}, + sendVaultChange: () => {} + }) + const docs = path.resolve('/tmp/zennotes-rename-docs') + const other = path.resolve('/tmp/zennotes-rename-other') + registry.setLocalVault(1, { root: docs, name: 'docs' }) + registry.setLocalVault(2, { root: docs, name: 'docs' }) + registry.setLocalVault(3, { root: other, name: 'other' }) + registry.setRemoteVault(4, { root: docs, name: 'docs' }) + + expect(registry.renameLocalVault(`${docs}/`, 'Acme API docs').sort()).toEqual([1, 2]) + expect(registry.vaultForWindow(1)?.name).toBe('Acme API docs') + expect(registry.vaultForWindow(2)?.name).toBe('Acme API docs') + expect(registry.vaultForWindow(3)?.name).toBe('other') + expect(registry.vaultForWindow(4)?.name).toBe('docs') + expect(watchers.every((w) => !w.stopped)).toBe(true) + // Already named: nothing to report. + expect(registry.renameLocalVault(docs, 'Acme API docs')).toEqual([]) + }) }) diff --git a/apps/desktop/src/main/window-vaults.ts b/apps/desktop/src/main/window-vaults.ts index 7f6a65e2..7112f53a 100644 --- a/apps/desktop/src/main/window-vaults.ts +++ b/apps/desktop/src/main/window-vaults.ts @@ -84,6 +84,21 @@ export class WindowVaultRegistry { return this.sessions.get(windowId)?.vault ?? null } + /** Give every window on a local root the vault's new name (#692). The + * watchers stay as they are: the folder did not move. Returns the windows + * that changed, so the caller can tell each renderer. */ + renameLocalVault(root: string, name: string): number[] { + const target = normalizeRoot(root) + const changed: number[] = [] + for (const [windowId, session] of this.sessions) { + if (session.mode !== 'local' || !session.vault) continue + if (normalizeRoot(session.vault.root) !== target || session.vault.name === name) continue + session.vault = { ...session.vault, name } + changed.push(windowId) + } + return changed + } + localVaultsExcept(root: string): VaultInfo[] { const excluded = normalizeRoot(root) const out = new Map() diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index d1396ffc..a965b2d5 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -533,7 +533,15 @@ export async function resolveVaultSelector(selector: string): Promise { const trimmed = selector.trim() const known = await readKnownVaultsFromConfig() - const byName = known.filter((vault) => vault.name.toLowerCase() === trimmed.toLowerCase()) + // The name the app shows, then the folder's own: a vault renamed in the + // app (#692) answers to both, so a script written before the rename keeps + // working. Same ambiguity rule for either. + const wanted = trimmed.toLowerCase() + const byDisplayName = known.filter((vault) => vault.name.toLowerCase() === wanted) + const byName = + byDisplayName.length > 0 + ? byDisplayName + : known.filter((vault) => path.basename(vault.root).toLowerCase() === wanted) if (byName.length === 1) { const root = byName[0].root try { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 42f7b6c3..37f40b6e 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -93,7 +93,13 @@ import type { VaultTextSearchToolPaths } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' -import type { DatabaseDoc, DatabaseSidecar, DatabaseSummary, DbRow } from '@shared/databases' +import type { + DatabaseDoc, + DatabaseSeed, + DatabaseSidecar, + DatabaseSummary, + DbRow +} from '@shared/databases' import type { McpClientId, McpClientStatus, @@ -520,8 +526,13 @@ const api: ZenBridge = { rows: DbRow[] ): Promise => ipcRenderer.invoke(IPC.VAULT_WRITE_DATABASE_SCHEMA, relPath, sidecar, rows), - createDatabase: (folder: NoteFolder, subpath: string, title?: string): Promise => - ipcRenderer.invoke(IPC.VAULT_CREATE_DATABASE, folder, subpath, title), + createDatabase: ( + folder: NoteFolder, + subpath: string, + title?: string, + seed?: DatabaseSeed + ): Promise => + ipcRenderer.invoke(IPC.VAULT_CREATE_DATABASE, folder, subpath, title, seed), renameDatabase: (csvPath: string, newTitle: string): Promise => ipcRenderer.invoke(IPC.VAULT_RENAME_DATABASE, csvPath, newTitle), createRecordPage: (csvPath: string, title: string, body: string): Promise => diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 5301ee0f..de598711 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.55.0", + "version": "2.56.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index e62d0858..833ec664 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.55.0", + "version": "2.56.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index a7ef01bd..76bbe24b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.55.0", + "version": "2.56.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.55.0", + "version": "2.56.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.55.0", + "version": "2.56.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.55.0", + "version": "2.56.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.55.0", + "version": "2.56.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.55.0", + "version": "2.56.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.55.0", + "version": "2.56.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.55.0", + "version": "2.56.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.55.0" + "version": "2.56.0" } } } diff --git a/package.json b/package.json index d0227805..891815a5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.55.0", + "version": "2.56.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 4934a5d8..db9d902c 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.55.0", + "version": "2.56.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index d4385bce..8656e07d 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -798,6 +798,23 @@ function App(): JSX.Element { return } } + // The template pickers as plain shortcuts, so they no longer need Vim's + // leader (#847). Same overlay rule as tab selection: a picker opened + // under Settings or another palette would strand the user behind it. + if (!tabSelectBlocked && matchesShortcut(e, overrides, 'global.newNoteFromTemplate')) { + e.preventDefault() + state.setTemplatePaletteOpen(true) + return + } + if ( + !tabSelectBlocked && + state.activeNote && + matchesShortcut(e, overrides, 'global.insertTemplate') + ) { + e.preventDefault() + state.openTemplatePaletteForInsert() + return + } if (matchesShortcut(e, overrides, 'global.exportNotePdf')) { e.preventDefault() void state.exportActiveNotePdf() diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts index 971d57ce..e59fefee 100644 --- a/packages/app-core/src/browse-actions.test.ts +++ b/packages/app-core/src/browse-actions.test.ts @@ -19,7 +19,7 @@ async function setup() { const create = vi.fn(async () => {}) const rename = vi.fn(async () => {}) const remove = vi.fn(async () => {}) - const createDatabase = vi.fn(async () => {}) + const createDatabase = vi.fn(async () => undefined) const renameDatabase = vi.fn(async () => {}) useStore.setState({ vault: { root: '/test', name: 'Test' }, diff --git a/packages/app-core/src/components/ContextMenu.test.ts b/packages/app-core/src/components/ContextMenu.test.ts new file mode 100644 index 00000000..dbdd5e56 --- /dev/null +++ b/packages/app-core/src/components/ContextMenu.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ContextMenu } from './ContextMenu' + +describe('ContextMenu focus', () => { + let host: HTMLDivElement + let root: Root + let opener: HTMLButtonElement + + const menu = () => + createElement(ContextMenu, { + x: 10, + y: 10, + items: [{ label: "Move into Sep 25's note", onSelect: () => {} }], + onClose: () => {} + }) + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + opener = document.createElement('button') + document.body.append(opener) + opener.focus() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + opener.remove() + }) + + it('holds the keyboard as soon as it mounts, not a frame later (#850)', async () => { + await act(async () => root.render(menu())) + expect(document.activeElement?.closest('[data-ctx-menu]')).toBeTruthy() + }) + + it('hands focus back to what held it when it closes', async () => { + await act(async () => root.render(menu())) + await act(async () => root.render(createElement('div'))) + expect(document.activeElement).toBe(opener) + }) +}) diff --git a/packages/app-core/src/components/ContextMenu.tsx b/packages/app-core/src/components/ContextMenu.tsx index d01e94b7..131ad242 100644 --- a/packages/app-core/src/components/ContextMenu.tsx +++ b/packages/app-core/src/components/ContextMenu.tsx @@ -83,8 +83,13 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { setPos({ left, top }) }, [x, y]) + // Take the keyboard as the menu mounts, so a key pressed before the next + // frame cannot reach whatever held focus (the calendar panel, after a + // keyboard drop), and again on that frame in case the event that opened the + // menu moves focus after mount (#850). Closing hands focus back. useEffect(() => { previousFocusRef.current = document.activeElement as HTMLElement | null + ref.current?.focus({ preventScroll: true }) const id = requestAnimationFrame(() => { ref.current?.focus({ preventScroll: true }) }) diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 34484b87..e586cad6 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -1420,7 +1420,13 @@ function registerCommandPaletteEx(): void { }; const names = new Set(MANUAL_EX_NAMES); - for (const cmd of buildCommands()) { + // Register every command, gated or not. This runs when the Editor mounts, + // before any pane has a view or a note, and buildCommands() drops every + // command whose `when` says no at that instant, which is the whole + // editor-scoped family (:editor_reflow_paragraph, :task_forward, and the + // rest) for the life of the window. `runCommand` re-checks `when` when the + // name is actually typed, so nothing runs out of context. + for (const cmd of buildCommands({ includeUnavailable: true })) { const name = commandIdToExName(cmd.id); if (names.has(name)) continue; names.add(name); diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 7a79d10c..2ed3b5cf 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -100,7 +100,7 @@ import { } from '../lib/cm-heading-fold' import { tags as t } from '@lezer/highlight' import { autocompletion } from '@codemirror/autocomplete' -import { MIN_RIGHT_PANEL_WIDTH, useStore } from '../store' +import { MIN_RIGHT_PANEL_WIDTH, noteDiskRevision, useStore } from '../store' import type { LineNumberMode } from '../store' import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' @@ -291,6 +291,7 @@ import { import { noteUndoHistoryFromFile, serializeNoteUndoHistory } from '../lib/note-undo-file' import { latestPathRewriteSeq, pathAfterRewrites } from '../lib/path-rewrites' import { minimalTextChange } from '../lib/minimal-text-change' +import { isDiskChange, resetUndoHistory } from '../lib/editor-disk-sync' import { MIN_NOTE_WIDTH, MIN_SPLIT_NOTE_WIDTH, @@ -1125,6 +1126,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { * accounted for. Only newer ones can explain a path change as a rename, so * an old rename can never make a real note switch look like one. */ const seenPathRewriteSeqRef = useRef(0) + /** The store's disk revision for the note on screen that this editor has + * accounted for. A body change under the same path with a newer revision + * is the file changing on disk, which starts the undo history clean; a + * peer pane or a rename rewrite leaves the revision alone (#852). */ + const seenDiskRevisionRef = useRef(0) const updateSelectionCommentAction = useCallback((view: EditorView | null = viewRef.current): void => { setSelectionCommentAction(view ? getSelectionCommentAction(view) : null) @@ -1941,8 +1947,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { richMarkdownDeferredRef.current = deferInitialRichMarkdown const stateStartedAt = performance.now() viewPathRef.current = initialPath - // A new editor starts on its note, so no earlier rename concerns it. + // A new editor starts on its note, so no earlier rename concerns it, + // and no earlier disk change either. seenPathRewriteSeqRef.current = latestPathRewriteSeq(s0.recentPathRewrites) + seenDiskRevisionRef.current = initialPath ? noteDiskRevision(initialPath) : 0 followPathRewritesInNoteUndoHistories(s0.recentPathRewrites) const state = EditorState.create({ doc: initialBody, @@ -2315,6 +2323,19 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { switched || view.state.doc.length !== nextBody.length || view.state.doc.toString() !== nextBody + // The file changing on disk under the note on screen, as opposed to a + // peer pane or a rename rewrite: the store bumps the note's disk revision + // in the same update. Re-baseline on every path change, so a disk change + // a note took while off screen (already handled by the set-aside history) + // is not counted again when it comes back. + const diskRevision = nextPath ? noteDiskRevision(nextPath) : 0 + const fromDisk = isDiskChange({ + pathChanged, + bodyChanged, + diskRevision, + seenDiskRevision: seenDiskRevisionRef.current + }) + seenDiskRevisionRef.current = diskRevision if (!pathChanged && !bodyChanged) return followPathRewritesInNoteUndoHistories(rewrites) if (renamed && prevPath && nextPath) { @@ -2415,6 +2436,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { : { anchor: clampedAnchor, head: clampedHead } }) } + if (fromDisk) { + // The text came from disk (a sync tool, an external editor, a script), + // not from anyone in this app. The user's undo steps were mapped through + // the change and still apply, but onto text nobody here wrote: undoing + // one made a document neither the user nor the other program ever had, + // and the save that follows every edit wrote it to disk (#852). A note + // reopened after such a change already starts clean; one that never left + // the screen now does too. The caret and scroll stay where they are. + const historyCompartment = historyCompartmentRef.current + if (historyCompartment) resetUndoHistory(view, historyCompartment) + } if (switched) { // Switching notes: also drop the previous note's undo history so undo // can't cross the boundary at all. There's no "clear history" command, so diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index a6acdc17..c36751a0 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -16,15 +16,9 @@ import { HELP_SHORTCUT_SECTIONS, HELP_VIM_COMMANDS } from '../lib/help' -import { - CheckSquareIcon, - CloseIcon, - CommandIcon, - DocumentIcon, - SearchIcon, - SettingsIcon, - TagIcon -} from './icons' +import { parseHelpKeys } from '../lib/help-keys' +import { SearchIcon } from './icons' +import { Button } from './ui/Button' interface CommandGroup { category: string @@ -66,6 +60,8 @@ function resolveShortcutKeys( if (action === 'Search notes (non-Vim mode)') return shortcut(overrides, 'global.searchNotesNonVim') if (action === 'Open commands') return shortcut(overrides, 'global.commandPalette') if (action === 'New Quick Note') return shortcut(overrides, 'global.newQuickNote') + if (action === 'New note from template') return shortcut(overrides, 'global.newNoteFromTemplate') + if (action === 'Insert template into current note') return shortcut(overrides, 'global.insertTemplate') if (action === 'Open Settings') return shortcut(overrides, 'global.openSettings') if (action === 'Toggle sidebar') return shortcut(overrides, 'global.toggleSidebar') if (action === 'Toggle connections') return shortcut(overrides, 'global.toggleConnections') @@ -184,7 +180,7 @@ const HELP_SECTION_LINKS = [ { id: 'help-howto', label: 'How-To' }, { id: 'help-concepts', label: 'Concepts' }, { id: 'help-shortcuts', label: 'Shortcuts' }, - { id: 'help-vim', label: 'Vim + Ex' }, + { id: 'help-vim', label: 'Vim & Ex' }, { id: 'help-commands', label: 'Commands' }, { id: 'help-cli', label: 'CLI' }, { id: 'help-settings', label: 'Settings' } @@ -214,21 +210,18 @@ function matchesQuery(query: string, ...parts: Array): boole } export function HelpView(): JSX.Element { - const closeActiveNote = useStore((s) => s.closeActiveNote) const setCommandPaletteOpen = useStore((s) => s.setCommandPaletteOpen) - const setSearchOpen = useStore((s) => s.setSearchOpen) const setSettingsOpen = useStore((s) => s.setSettingsOpen) const setFocusedPanel = useStore((s) => s.setFocusedPanel) const keymapOverrides = useStore((s) => s.keymapOverrides) const runtimePlatform = window.zen.platformSync() + const mac = runtimePlatform === 'darwin' const platformLabel = runtimePlatform === 'darwin' ? 'macOS' : runtimePlatform === 'win32' ? 'Windows' : 'Linux' - const primaryModifierLabel = - runtimePlatform === 'darwin' ? 'Command (⌘)' : 'Ctrl' const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query.trim().toLowerCase()) @@ -369,6 +362,21 @@ export function HelpView(): JSX.Element { cliCards.length > 0 || settingsSections.length > 0 + // Only the sections that survive the filter get a link, so the nav never + // offers a jump to nothing. + const visibleSections = new Set( + [ + quickStart.length > 0 && 'help-start', + howToGuides.length > 0 && 'help-howto', + coreConcepts.length > 0 && 'help-concepts', + shortcutSections.length > 0 && 'help-shortcuts', + vimCommands.length > 0 && 'help-vim', + commandGroups.length > 0 && 'help-commands', + cliCards.length > 0 && 'help-cli', + settingsSections.length > 0 && 'help-settings' + ].filter((id): id is string => !!id) + ) + return (
setFocusedPanel('editor')} className="min-h-0 min-w-0 flex-1 overflow-y-auto outline-none" > -
-
-
-
-
-
-
- - ZenNotes Manual -
-

- Learn the app in layers, not all at once. -

-

- Onboarding cards to start, how-to recipes for common jobs, concepts for the app - model, and a living reference for shortcuts, commands, and settings. Labels are - rendered for {platformLabel} ({primaryModifierLabel}); Vim motions like{' '} - - Ctrl-w - {' '} - stay literal across OSes. -

-
-
- } - label="Search Notes" - onClick={() => setSearchOpen(true)} - /> - } - label="Commands" - onClick={() => setCommandPaletteOpen(true)} - /> - } - label="Settings" - onClick={() => setSettingsOpen(true)} - /> - } - label="Close" - onClick={() => void closeActiveNote()} - /> -
-
- -

- {HELP_QUICK_START.length} onboarding - {' · '} - {HELP_HOW_TO_GUIDES.length} how-to - {' · '} - - {allCommands.length + HELP_VIM_COMMANDS.length} - {' '} - reference entries -

- -
- - -
- {HELP_SECTION_LINKS.map((link) => ( - - ))} -
-
-
-
-
+
+
+

Help

+

+ The ZenNotes manual: start with the basics, then look up any shortcut, command, or + setting. +

+ +
+ + {visibleSections.size > 0 && ( + + )} {!hasMatches && ( -
-

No help topics matched.

-

- Clear the filter to see the full manual again. -

-
+

+ Nothing in the manual matches “{query.trim()}”. +

)} - {hasMatches && ( - <> - {quickStart.length > 0 && ( - -
- {quickStart.map((card) => ( - - ))} -
-
- )} + {quickStart.length > 0 && ( + + + + )} - {howToGuides.length > 0 && ( - -
- {howToGuides.map((card) => ( - - ))} -
-
- )} + {howToGuides.length > 0 && ( + + + + )} - {coreConcepts.length > 0 && ( - -
- {coreConcepts.map((card) => ( - - ))} -
-
- )} + {coreConcepts.length > 0 && ( + + + + )} - {shortcutSections.length > 0 && ( - -
- {shortcutSections.map((section) => ( -
-

{section.title}

-

{section.description}

-
- {section.items.map((item) => ( - - ))} -
-
- ))} + {shortcutSections.length > 0 && ( + +
+ {shortcutSections.map((section) => ( +
+

{section.title}

+ {section.description && ( +

+ {renderRichText(section.description, 'text-xs')} +

+ )} + + {section.items.map((item) => ( + } + detail={item.detail} + /> + ))} +
- - )} + ))} +
+
+ )} - {vimCommands.length > 0 && ( - -
-
-
- - Curated ex commands -
-
- {vimCommands.map((item) => ( -
-
- - {item.summary} -
-

{item.detail}

-
- ))} -
-
- -
- } - title="Palette commands are also ex commands" - body="Every command palette entry is registered on the Vim `:` line using its command id with punctuation normalized to underscores, like `:app_settings` or `:note_new_quick`." - /> - } - title="Tasks and Tags have local ex prompts" - body={ - shortcut(keymapOverrides, 'nav.localEx') === UNBOUND_LABEL - ? 'Inside Tasks or Tags, the local command line (nav.localEx) has no key right now; give it one under Settings, Keymaps to reach view-specific actions like close, split, refresh, and retagging.' - : `Inside Tasks or Tags, press \`${shortcut(keymapOverrides, 'nav.localEx')}\` to open the local command line for view-specific actions like close, split, refresh, and retagging.` - } - /> - } - title="Link following is context-aware" - body={ - shortcut(keymapOverrides, 'vim.goToDefinition') === UNBOUND_LABEL - ? 'Following the link at the cursor (vim.goToDefinition) has no key right now; give it one under Settings, Keymaps. It opens existing notes, external links, or PDFs, and missing wikilinks can create new notes directly from the ex-aware workflow.' - : `\`${shortcut(keymapOverrides, 'vim.goToDefinition')}\` opens existing notes, external links, or PDFs. Missing wikilinks can create new notes directly from the ex-aware workflow.` - } - /> -
-
-
- )} + {vimCommands.length > 0 && ( + + + {vimCommands.map((item) => ( + } + detail={item.detail} + /> + ))} + +

Good to know

+ + {[ + { + title: 'Palette commands are also ex commands', + body: 'Every command palette entry is registered on the Vim `:` line using its command id with punctuation normalized to underscores, like `:app_settings` or `:note_new_quick`.' + }, + { + title: 'Tasks and Tags have local ex prompts', + body: + shortcut(keymapOverrides, 'nav.localEx') === UNBOUND_LABEL + ? 'Inside Tasks or Tags, the local command line (nav.localEx) has no key right now; give it one under Settings, Keymaps to reach view-specific actions like close, split, refresh, and retagging.' + : `Inside Tasks or Tags, press \`${shortcut(keymapOverrides, 'nav.localEx')}\` to open the local command line for view-specific actions like close, split, refresh, and retagging.` + }, + { + title: 'Link following is context-aware', + body: + shortcut(keymapOverrides, 'vim.goToDefinition') === UNBOUND_LABEL + ? 'Following the link at the cursor (vim.goToDefinition) has no key right now; give it one under Settings, Keymaps. It opens existing notes, external links, or PDFs, and missing wikilinks can create new notes directly from the ex-aware workflow.' + : `\`${shortcut(keymapOverrides, 'vim.goToDefinition')}\` opens existing notes, external links, or PDFs. Missing wikilinks can create new notes directly from the ex-aware workflow.` + } + ].map((tip) => ( + + ))} + +
+ )} - {commandGroups.length > 0 && ( - -
- {commandGroups.map((group) => ( -
-
-

{group.category}

- {group.commands.length} -
-
- {group.commands.map((command) => ( -
-
-
-
{command.title}
-
- {command.shortcut && } - - {command.when && } -
-
- - {command.id} - -
- {command.keywords && ( -

- Keywords: {command.keywords} -

- )} -
- ))} -
-
- ))} + {commandGroups.length > 0 && ( + setCommandPaletteOpen(true)}> + Open palette + + } + > +
+ {commandGroups.map((group) => ( +
+

+ {group.category} + + {group.commands.length} + +

+
    + {group.commands.map((command) => ( +
  • + {command.title} + + {command.shortcut && } + + :{commandExAlias(command.id)} + + +
  • + ))} +
- - )} + ))} +
+
+ )} - {cliCards.length > 0 && ( - -
- {cliCards.map((card) => ( - - ))} -
-
- )} + {cliCards.length > 0 && ( + + + + )} - {settingsSections.length > 0 && ( - -
- {settingsSections.map((section) => ( -
-

{section.title}

-
- {section.items.map((item) => ( -
-
{item.label}
-

{item.detail}

-
- ))} -
-
- ))} + {settingsSections.length > 0 && ( + setSettingsOpen(true)}> + Open Settings + + } + > +
+ {settingsSections.map((section) => ( +
+

{section.title}

+ + {section.items.map((item) => ( + + ))} +
- - )} - + ))} +
+
)}
) } +/** + * One manual section. Sections are set apart by space alone, not boxes or + * rules: the manual reads as one document, and a card around every paragraph + * was most of what made it feel cluttered. The scroll margin clears the + * sticky section nav even when it wraps onto a second row. + */ function SectionShell({ id, title, subtitle, + action, children }: { id?: string title: string subtitle: string + action?: React.ReactNode children: React.ReactNode }): JSX.Element { return ( -
-
+
+

{title}

-

{subtitle}

+ {action}
- {children} +

{subtitle}

+
{children}
) } // Renders a help string with Markdown-style backtick code spans. Short spans -// become inline code chips; long or multi-line spans become a code block so -// shell commands stay readable instead of wrapping through prose. -function renderRichText(text: string): React.ReactNode { +// become inline code; long or multi-line spans become a code block so shell +// commands stay readable instead of wrapping through prose. `codeSize` keeps +// inline code a step below the text around it. +function renderRichText(text: string, codeSize: 'text-xs' | 'text-sm'): React.ReactNode { return text.split(/(`[^`]+`)/g).map((seg, i) => { if (seg.length > 1 && seg.startsWith('`') && seg.endsWith('`')) { const code = seg.slice(1, -1) @@ -757,7 +671,7 @@ function renderRichText(text: string): React.ReactNode { return ( {code} @@ -766,7 +680,7 @@ function renderRichText(text: string): React.ReactNode { return ( {code} @@ -776,101 +690,84 @@ function renderRichText(text: string): React.ReactNode { }) } -function InfoCard({ title, body }: { title: string; body: string }): JSX.Element { +/** Guides and concepts: a title and a paragraph, kept to a readable measure. */ +function ArticleList({ cards }: { cards: { title: string; body: string }[] }): JSX.Element { return ( -
-

{title}

-
{renderRichText(body)}
+
+ {cards.map((card) => ( +
+

{card.title}

+
+ {renderRichText(card.body, 'text-sm')} +
+
+ ))}
) } -function ShortcutRow({ - keys, - action, - detail -}: { - keys: string - action: string - detail: string -}): JSX.Element { +function ReferenceList({ children }: { children: React.ReactNode }): JSX.Element { return ( -
-
- - {action} -
-

{detail}

-
+
{children}
) } -function Keycap({ - value, - subtle = false +/** + * A reference entry: what it does, its keys at the end of the same line, and + * the detail underneath. Leading with the action keeps a long detail from + * leaving an empty column under the key, and the keys drop below the title + * on their own when the pane is too narrow for both. + */ +function ReferenceRow({ + title, + keys, + detail }: { - value: string - subtle?: boolean + title: string + keys?: React.ReactNode + detail?: string }): JSX.Element { return ( - - {value} - +
+
+ {title} + {keys} +
+ {detail && ( +
+ {renderRichText(detail, 'text-xs')} +
+ )} +
) } -function Badge({ label }: { label: string }): JSX.Element { +/** A row's keys: one keycap per key, and anything else in plain text. */ +function KeyList({ value, mac }: { value: string; mac: boolean }): JSX.Element { + if (value === UNBOUND_LABEL) { + return {value} + } + const { parts, context } = parseHelpKeys(value, mac) return ( - - {label} + + {parts.map((part, index) => + part.kind === 'key' ? ( + + ) : ( + + {part.text} + + ) + )} + {context && {context}} ) } -function ActionBtn({ - icon, - label, - onClick -}: { - icon: JSX.Element - label: string - onClick: () => void -}): JSX.Element { - return ( - - ) -} - -function CalloutCard({ - icon, - title, - body -}: { - icon: JSX.Element - title: string - body: string -}): JSX.Element { +function Keycap({ value }: { value: string }): JSX.Element { return ( -
-
- {icon} - {title} -
-
{renderRichText(body)}
-
+ + {value} + ) } diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index c084d311..b40c607e 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -38,6 +38,7 @@ import { isExcalidrawPath, isObsidianExcalidrawPath } from "@shared/excalidraw"; import { resolveExcalidrawEmbedPath } from "../lib/excalidraw-preview"; import { LazyExcalidrawPreview } from "./LazyExcalidrawPreview"; import { enhancePreviewHeadingFolds } from "../lib/preview-heading-fold"; +import { wrapTaskItemOwnText } from "../lib/preview-task-body"; import { previewEditRequestForTarget, type PreviewEditRequest, @@ -844,33 +845,9 @@ export const Preview = memo(function Preview({ const due = chip.dataset.due ?? ""; chip.classList.toggle("zen-task-due-overdue", !closed && due !== "" && due < today); }); - // Wrap the item's OWN inline text in a span, so state styling (strike/gray - // for done and cancelled) targets just this line and never bleeds onto - // nested sub-tasks. Loose items keep their

, which the CSS targets - // directly; only bare-text (tight) items get the wrapper. The state marker - // span stays outside it: it sits in the gutter and must not be struck - // along with the text. (#512) - if (!li.querySelector(":scope > .task-item-body")) { - const own = Array.from(li.childNodes).filter((node) => { - if (node === input) return false; - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as Element; - if (el.tagName === "UL" || el.tagName === "OL" || el.tagName === "P") return false; - if (el.classList.contains("zen-task-state")) return false; - } - return true; - }); - const hasText = own.some( - (node) => - node.nodeType !== Node.TEXT_NODE || (node.textContent ?? "").trim() !== "", - ); - if (hasText && own.length > 0) { - const body = document.createElement("span"); - body.className = "task-item-body"; - li.insertBefore(body, own[0]); - for (const node of own) body.appendChild(node); - } - } + // Wrap the item's own text so done and cancelled styling lands on it + // alone, never on a sub-task or a block such as a code block. (#512, #849) + wrapTaskItemOwnText(li, input); }); const applyRenderedDom = async (): Promise => { diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 22171a32..fd54fccb 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -1,10 +1,21 @@ // @vitest-environment jsdom -import { act, createElement } from "react"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { act, createElement, isValidElement, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SettingsModal } from "./SettingsModal"; import { requestSettingsTarget } from "../lib/settings-navigation"; +import { + getSettingsSearchResults, + type SettingsSearchCategory, +} from "../lib/settings-search"; + +// Spied, not replaced: the modal hands search every category it built, which +// is how the guards below read the real search items and sub-tabs. +vi.mock("../lib/settings-search", { spy: true }); const cloudMocks = vi.hoisted(() => ({ getCloudAccountStatus: vi @@ -34,6 +45,7 @@ const mocks = vi.hoisted(() => { interfaceFont: null, keymapOverrides: {} as Record, lineNumberMode: "off", + mathRenderer: "katex" as "katex" | "typst", monoFont: null, previewMaxWidth: 760, quickNoteTitlePrefix: null, @@ -81,11 +93,14 @@ const mocks = vi.hoisted(() => { return { state, runtime: "desktop" as "desktop" | "web", + capabilities: {} as Record, setSettingsOpen: state.setSettingsOpen, setVaultSettings: state.setVaultSettings, }; }); +const defaultVaultSettings = mocks.state.vaultSettings; + vi.mock("../store", () => ({ useStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), @@ -112,6 +127,7 @@ vi.mock("@zennotes/bridge-contract/bridge", () => ({ supportsCustomTemplates: true, supportsRemoteWorkspace: false, supportsCloudSync: true, + ...mocks.capabilities, }), ...cloudMocks, }), @@ -130,6 +146,47 @@ function blurInput(input: HTMLInputElement): void { input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); } +/** Folder fields that only render in folder mode, right under the location + * row a search hit already opens (tasks-location for tasks-folder). */ +const REACHED_THROUGH_LOCATION_ROW = new Set([ + "drawings-folder", + "databases-folder", + "tasks-folder", +]); + +type BuiltSettingsCategory = SettingsSearchCategory & { + content?: ReactNode; + subTabs?: { id: string; searchIds?: string[]; content: ReactNode }[]; +}; + +/** The search targets a pane's JSX registers: a row's `settingId`, or a + * `data-settings-search-id` spread onto a plain element. */ +function searchTargetsIn(node: ReactNode): string[] { + if (Array.isArray(node)) return node.flatMap(searchTargetsIn); + if (!isValidElement(node)) return []; + const props = node.props as { + settingId?: unknown; + "data-settings-search-id"?: unknown; + children?: ReactNode; + }; + const own = [props.settingId, props["data-settings-search-id"]].filter( + (id): id is string => typeof id === "string", + ); + return [...own, ...searchTargetsIn(props.children)]; +} + +/** Every id a search hit can name: each item's own id and the row it jumps to. */ +function searchItemTargets(categories: SettingsSearchCategory[]): Set { + return new Set( + categories.flatMap((category) => + (category.searchItems ?? []).flatMap((item) => [ + item.id, + item.targetId ?? item.id, + ]), + ), + ); +} + describe("SettingsModal date note directories", () => { let root: Root; let host: HTMLDivElement; @@ -145,6 +202,9 @@ describe("SettingsModal date note directories", () => { mocks.state.keymapOverrides = {}; mocks.state.workspaceMode = "local"; mocks.state.remoteWorkspaceInfo = null; + mocks.state.mathRenderer = "katex"; + mocks.state.vaultSettings = defaultVaultSettings; + mocks.capabilities = {}; ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -202,6 +262,200 @@ describe("SettingsModal date note directories", () => { expect(mocks.state.setShowWindowTitleBar).toHaveBeenCalledWith(false); }); + it("finds the new-file location rows by the words people search for", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector('input[placeholder="Search settings…"]')!; + const cases = [ + ["tasks folder", "Default tasks location", "tasks-location"], + ["task folder", "Default tasks location", "tasks-location"], + ["new task", "Default tasks location", "tasks-location"], + ["drawings folder", "Default drawings location", "drawings-location"], + ["database location", "Default databases location", "databases-location"], + ] as const; + for (const [query, title, settingId] of cases) { + await act(async () => changeInput(search, query)); + const result = [...host.querySelectorAll("aside nav button")].find( + (button) => button.textContent?.includes(title), + ); + expect(result, query).toBeTruthy(); + await act(async () => result!.click()); + expect(host.querySelector(`[data-settings-search-id="${settingId}"]`), query).toBeTruthy(); + } + }); + + it("says where a specific tasks folder really lands for the vault's layout", async () => { + const original = mocks.state.vaultSettings; + const withTasksFolder = (primaryNotesLocation: "inbox" | "root") => + ({ + ...original, + primaryNotesLocation, + tasksLocation: { mode: "folder", folder: "Tasks" }, + }) as typeof original; + const folderRowText = () => + host.querySelector('[data-settings-search-id="tasks-folder"]')?.textContent ?? ""; + try { + mocks.state.vaultSettings = withTasksFolder("inbox"); + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, "tasks folder")); + expect(folderRowText()).toContain("New task files go to `inbox/Tasks/`."); + expect(folderRowText()).not.toContain("Vault-relative"); + + mocks.state.vaultSettings = withTasksFolder("root"); + await act(async () => root.render(createElement(SettingsModal))); + expect(folderRowText()).toContain("New task files go to `Tasks/`."); + } finally { + mocks.state.vaultSettings = original; + } + }); + + /** Renders once with every gate a row can sit behind switched on (Vim mode, + * Typst, Harper, undofile, remote workspaces, periodic notes, the folder + * modes) and returns the categories the modal handed to search. */ + async function renderEveryRow(): Promise { + mocks.state.vimMode = true; + mocks.state.mathRenderer = "typst"; + mocks.state.vaultSettings = { + ...defaultVaultSettings, + dailyNotes: { ...defaultVaultSettings.dailyNotes, enabled: true }, + weeklyNotes: { ...defaultVaultSettings.weeklyNotes, enabled: true }, + monthlyNotes: { ...defaultVaultSettings.monthlyNotes, enabled: true }, + drawingsLocation: { mode: "folder", folder: "Drawings" }, + databasesLocation: { mode: "folder", folder: "Databases" }, + tasksLocation: { mode: "folder", folder: "Tasks" }, + } as typeof defaultVaultSettings; + mocks.capabilities = { + supportsRemoteWorkspace: true, + supportsUndoFile: true, + supportsCustomCodeLanguages: true, + }; + Object.assign(window.zen, { + getCapabilities: () => ({ supportsHarper: true }), + }); + await act(async () => root.render(createElement(SettingsModal))); + return vi.mocked(getSettingsSearchResults).mock + .lastCall![0] as BuiltSettingsCategory[]; + } + + /** Types a query, opens the result with this title, and returns the row the + * jump highlighted once the two frames it waits for have run. */ + async function openSearchHit( + query: string, + title: string, + ): Promise { + const search = host.querySelector('input[placeholder="Search settings…"]')!; + await act(async () => changeInput(search, query)); + const result = [...host.querySelectorAll("aside nav button")].find( + (button) => button.textContent?.includes(title), + ); + expect(result, `${query} → ${title}`).toBeTruthy(); + await act(async () => result!.click()); + await act( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + return host.querySelector('[data-settings-search-highlight="true"]') + ?.dataset.settingsSearchId; + } + + it("gives every settings row a search item, so Settings search can find it", async () => { + const findable = searchItemTargets(await renderEveryRow()); + // Not `new URL(…, import.meta.url)`: in a jsdom test Vite rewrites that + // into a dev-server asset URL, which readFileSync cannot open. + const source = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "SettingsModal.tsx"), + "utf8", + ); + const rows = new Set( + [ + ...source.matchAll( + /(?:settingId=|settingsSearchTargetProps\(|data-settings-search-id=)"([^"]+)"/g, + ), + ].map((match) => match[1]), + ); + // A scan that stopped matching the file would pass on nothing. + expect(rows.size).toBeGreaterThan(100); + const unfindable = [...rows].filter( + (id) => !findable.has(id) && !REACHED_THROUGH_LOCATION_ROW.has(id), + ); + expect(unfindable).toEqual([]); + }); + + it("lists every row in the searchIds of the sub-tab that renders it", async () => { + // Walks the JSX each pane is built from rather than the source, so rows + // named by an expression (`${key}-path`) count too. + const problems: string[] = []; + for (const category of await renderEveryRow()) { + const findable = searchItemTargets([category]); + const panes = category.subTabs ?? [ + { id: category.id, content: category.content }, + ]; + for (const pane of panes) { + const rendered = searchTargetsIn(pane.content).filter( + (id) => !REACHED_THROUGH_LOCATION_ROW.has(id), + ); + for (const id of rendered) { + if (!findable.has(id)) { + problems.push(`${category.id}: no search item for ${id}`); + } + if (category.subTabs && !pane.searchIds?.includes(id)) { + problems.push(`${category.id}/${pane.id}: searchIds is missing ${id}`); + } + } + for (const id of pane.searchIds ?? []) { + if (!rendered.includes(id)) { + problems.push(`${category.id}/${pane.id}: searchIds lists ${id}, which it does not render`); + } + } + } + } + expect(problems).toEqual([]); + }); + + it("finds each row by the words people search for and lands on it", async () => { + mocks.state.vimMode = true; + await act(async () => root.render(createElement(SettingsModal))); + const cases = [ + ["pdf theme", "Use theme for PDF export", "pdf-export-use-theme"], + ["clipboard", "Sync clipboard with Vim registers", "vim-yank-to-clipboard"], + ["korean", "Keep the input method out of normal mode", "vim-block-ime-in-normal-mode"], + ["strikethrough", "Completed task style", "completed-task-style"], + ["open in preview", "Default view mode", "default-view-mode"], + ["event triggers", "Event triggers", "workflow-event-triggers"], + ["due date", "Tasks are due on the note's date", "daily-notes-tasks-due-on-date"], + ["rollover", "Roll over unfinished tasks to today", "daily-notes-rollover"], + ["tutorial", "Guided tutorial", "workflow-tutorial"], + ["recipe gallery", "Built-in recipes", "workflow-hidden-recipes"], + ["trash folder", "Trash path", "trash-path"], + ["quick notes folder", "Quick Notes path", "quick-path"], + ["math renderer", "Math renderer", "math-renderer"], + ["quick note prefix", "Quick Note prefix", "quick-note-prefix"], + ] as const; + for (const [query, title, settingId] of cases) { + expect(await openSearchHit(query, title), query).toBe(settingId); + } + }); + + it("lands on the switch that reveals a row while that row is hidden", async () => { + mocks.state.vimMode = false; + mocks.state.vaultSettings = { + ...defaultVaultSettings, + dailyNotes: { ...defaultVaultSettings.dailyNotes, enabled: false }, + }; + await act(async () => root.render(createElement(SettingsModal))); + const cases = [ + ["clipboard", "Sync clipboard with Vim registers", "vim-mode"], + ["korean", "Keep the input method out of normal mode", "vim-mode"], + ["due date", "Tasks are due on the note's date", "enable-daily-notes"], + ["rollover", "Roll over unfinished tasks to today", "enable-daily-notes"], + ] as const; + for (const [query, title, settingId] of cases) { + expect(await openSearchHit(query, title), query).toBe(settingId); + } + }); + it("does not offer native title bar settings in the web app", async () => { mocks.runtime = "web"; await act(async () => root.render(createElement(SettingsModal))); diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index b2535be1..e001c6fa 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -28,6 +28,7 @@ import type { RaycastExtensionStatus, RemoteWorkspaceProfile, RemoteWorkspaceProfileInput, + VaultSettings, VaultTextSearchBackendPreference, VaultTextSearchCapabilities, VaultTextSearchToolPaths, @@ -46,6 +47,7 @@ import { resolveTypstPreambleFolder, } from "@shared/typst-preamble-folder"; import { useStore, refreshCustomThemes, refreshOverrides } from "../store"; +import { vaultFolderName } from "../lib/rename-vault"; import { WORKFLOW_PRESETS, hiddenPresetsInOrder, @@ -115,6 +117,7 @@ import { normalizeMonthlyNoteLocale, normalizeMonthlyNoteTitlePattern, normalizeMonthlyNotesDirectory, + specificFolderDestination, } from "../lib/vault-layout"; import { BUILTIN_TEMPLATES } from "@shared/builtin-templates"; import { composeTemplateFile, mergeTemplates } from "@shared/template-files"; @@ -309,6 +312,7 @@ function settingsSearchTargetProps(settingId: string | undefined): { return settingId ? { "data-settings-search-id": settingId } : {}; } + function findSettingsSearchTarget( root: HTMLElement, targetId: string, @@ -329,6 +333,15 @@ function clearSettingsSearchHighlights(root: HTMLElement): void { }); } +/** Where a `Specific folder` row's files really land, for its description. */ +function specificFolderDestinationLabel( + folder: string | undefined, + settings: VaultSettings, +): string { + const dir = specificFolderDestination(folder, settings); + return dir ? `\`${dir}/\`` : "the vault root"; +} + function resolveVaultTextSearchBackend( preferred: VaultTextSearchBackendPreference, capabilities: VaultTextSearchCapabilities | null, @@ -586,6 +599,8 @@ export function SettingsModal(): JSX.Element { const setContentAlign = useStore((s) => s.setContentAlign); const vault = useStore((s) => s.vault); const workspaceMode = useStore((s) => s.workspaceMode); + const renameVault = useStore((s) => s.renameVault); + const vaultDisplayName = useStore((s) => s.vaultSettings.displayName ?? ""); const remoteWorkspaceInfo = useStore((s) => s.remoteWorkspaceInfo); const remoteWorkspaceProfiles = useStore((s) => s.remoteWorkspaceProfiles); const vaultSettings = useStore((s) => s.vaultSettings); @@ -1339,12 +1354,24 @@ export function SettingsModal(): JSX.Element { const wrappedLineMotionsTargetId = vimMode ? "wrapped-line-motions" : "vim-mode"; + const vimYankToClipboardTargetId = vimMode + ? "vim-yank-to-clipboard" + : "vim-mode"; + const vimBlockImeTargetId = vimMode + ? "vim-block-ime-in-normal-mode" + : "vim-mode"; const leaderHintBehaviorTargetId = vimMode && whichKeyHints ? "leader-hint-behavior" : leaderKeyHintsTargetId; const leaderHintDurationTargetId = vimMode && whichKeyHints && whichKeyHintMode === "timed" ? "leader-hint-duration" : leaderHintBehaviorTargetId; + const dailyNotesTasksDueTargetId = vaultSettings.dailyNotes.enabled + ? "daily-notes-tasks-due-on-date" + : "enable-daily-notes"; + const dailyNotesRolloverTargetId = vaultSettings.dailyNotes.enabled + ? "daily-notes-rollover" + : "enable-daily-notes"; const categories: SettingsCategory[] = [ { @@ -1409,6 +1436,20 @@ export function SettingsModal(): JSX.Element { "Show /-separated tags as a collapsible tree in the sidebar and Tags view instead of a flat list.", keywords: ["hierarchical", "tree", "tags", "nested", "hierarchy"], }, + { + id: "pdf-export-use-theme", + title: "Use theme for PDF export", + description: + "Export PDFs in your current theme, snippets, and color tweaks, or in a clean light theme for printing.", + keywords: [ + "pdf", + "export pdf", + "pdf theme", + "dark pdf", + "print", + "paper", + ], + }, ], content: (

@@ -1901,6 +1942,40 @@ export function SettingsModal(): JSX.Element { "Map a key sequence like jk or jj to Escape in insert mode.", keywords: ["vim", "jk", "jj", "escape", "insert mode", "esc"], }, + { + id: "vim-yank-to-clipboard", + title: "Sync clipboard with Vim registers", + description: + "Yank, delete, and change to the system clipboard, and paste from it with p / P.", + keywords: [ + "vim", + "yank", + "copy", + "paste", + "system clipboard", + "clipboard=unnamed", + "unnamedplus", + ], + targetId: vimYankToClipboardTargetId, + }, + { + id: "vim-block-ime-in-normal-mode", + title: "Keep the input method out of normal mode", + description: + "With a Korean, Chinese, or Japanese input method on, normal-mode keys stay Vim motions instead of composing text.", + keywords: [ + "vim", + "ime", + "cjk", + "korean", + "chinese", + "japanese", + "hangul", + "pinyin", + "composition", + ], + targetId: vimBlockImeTargetId, + }, { id: "leader-key-hints", title: "Leader key hints", @@ -1971,6 +2046,24 @@ export function SettingsModal(): JSX.Element { "source", ], }, + { + id: "completed-task-style", + title: "Completed task style", + description: + "Strike through or gray out a checked task's text in the editor and preview, or both.", + keywords: [ + "checkbox", + "done", + "completed tasks", + "checked", + "strikethrough", + "cross out", + "gray", + "grey", + "dim", + "todo", + ], + }, { id: "math-font-scale", title: "Math size", @@ -2068,6 +2161,22 @@ export function SettingsModal(): JSX.Element { "loose", ], }, + { + id: "default-view-mode", + title: "Default view mode", + description: + "The mode a note opens in before you pick one for it: Edit, Split, or Preview.", + keywords: [ + "view mode", + "edit", + "split", + "preview", + "reading mode", + "read mode", + "open in preview", + "start in preview", + ], + }, { id: "keep-view-mode", title: "Keep view mode when switching notes", @@ -2317,6 +2426,55 @@ export function SettingsModal(): JSX.Element { "run", ], }, + { + id: "workflow-event-triggers", + title: "Event triggers", + description: + "Let active workflows run on their own when you create, save, move, or tag a note in this app.", + keywords: [ + "workflow", + "workflows", + "trigger", + "automation", + "automatic", + "on save", + "note-saved", + "note-created", + "note-moved", + "tag-added", + ], + }, + { + id: "workflow-tutorial", + title: "Guided tutorial", + description: + "A hands-on Workflows walkthrough on a practice folder, cleaned up when you finish.", + keywords: [ + "workflow", + "workflows", + "walkthrough", + "learn", + "practice", + "onboarding", + "start tutorial", + ], + }, + { + id: "workflow-hidden-recipes", + title: "Built-in recipes", + description: + "Hide or restore the shipped recipes in the recipe gallery behind New workflow.", + keywords: [ + "workflow", + "workflows", + "recipe gallery", + "gallery", + "presets", + "hide recipes", + "restore recipes", + "new workflow", + ], + }, ], subTabs: [ { @@ -2326,6 +2484,8 @@ export function SettingsModal(): JSX.Element { "vim-mode", "wrapped-line-motions", "vim-insert-escape", + "vim-yank-to-clipboard", + "vim-block-ime-in-normal-mode", "leader-key-hints", "leader-hint-behavior", "leader-hint-duration", @@ -2541,8 +2701,15 @@ export function SettingsModal(): JSX.Element { searchIds: [ "live-preview", "render-tables", + "completed-task-style", + "math-renderer", + "math-font-scale", + "typst-tag-preambles", + "typst-preamble-folder", "harper-enabled", "harper-dialect", + "loose-math-delimiters", + "default-view-mode", "keep-view-mode", "keep-panels", "persist-undo-history", @@ -2560,6 +2727,8 @@ export function SettingsModal(): JSX.Element { "smooth-preview-scroll", "pdfs-in-edit-mode", "time-format", + "date-titled-quick-notes", + "quick-note-prefix", ], content: (
@@ -2871,11 +3040,7 @@ export function SettingsModal(): JSX.Element { { id: "quick-capture", title: "Quick capture", - searchIds: [ - "date-titled-quick-notes", - "quick-note-prefix", - "quick-capture-hotkey", - ], + searchIds: ["quick-capture-hotkey"], content: (
)}
+ {/* Local vaults only (#692): a temporary folder session writes + nothing into its folder, and a remote workspace's settings + belong to the server. */} + {workspaceMode !== "remote" && vault && !vault.temporary && ( + void renameVault(next)} + /> + )}
{supportsRemoteWorkspace && ( @@ -3955,8 +4266,14 @@ export function SettingsModal(): JSX.Element { id: "notes", title: "Notes", description: - "How primary notes, new drawings and databases, and view preferences are organized.", - searchIds: ["primary-notes-location", "view-settings-scope"], + "Where primary notes live, where new drawings, databases, and tasks are created, and how view preferences apply.", + searchIds: [ + "primary-notes-location", + "drawings-location", + "databases-location", + "tasks-location", + "view-settings-scope", + ], content: (
, + onSelect: async () => { + await renameVaultWithPrompt(); + }, + }); + } if (canCloseCurrentVault) { items.push({ label: "Close Current Vault", @@ -2700,6 +2715,7 @@ export function Sidebar(): JSX.Element { return items; }, [ canCloseCurrentVault, + canRenameCurrentVault, canSwitchLocalVaults, canUseRemoteWorkspaces, closeVault, diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index ba70b078..0b9e6bfc 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -39,6 +39,7 @@ import { sequenceTokenFromEvent } from '../lib/keymaps' import { toggleWrap, wrapLink } from '../lib/cm-format' +import { convertTableToDatabase } from '../lib/table-to-database' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, dispatchKeyboardContextMenu, @@ -257,6 +258,11 @@ export function VimNav(): JSX.Element | null { keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderToggleFavorite'), label: 'Toggle favorite', detail: 'Add or remove the active note from Favorites.' + }, + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderTableToDatabase'), + label: 'Table to database', + detail: 'Turn the table under the cursor into a database, linked from the note.' } ] } @@ -1070,6 +1076,13 @@ export function VimNav(): JSX.Element | null { void state.toggleFavoriteActiveNote() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderTableToDatabase') && editorNormalMode) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + if (state.editorViewRef) void convertTableToDatabase(state.editorViewRef) + return + } resetLeader() } diff --git a/packages/app-core/src/lib/cm-table-menu.ts b/packages/app-core/src/lib/cm-table-menu.ts index 99ab2f6f..5e9342fb 100644 --- a/packages/app-core/src/lib/cm-table-menu.ts +++ b/packages/app-core/src/lib/cm-table-menu.ts @@ -28,6 +28,10 @@ export interface TableMenuRequest { col: number model: MarkdownTable apply: (next: MarkdownTable, focus?: { row: number; col: number }) => void + /** Turn the whole table into a `.base` database and leave a link in its + * place (#832). Unlike `apply`, this leaves the table model alone: the + * converter reads the committed document itself. Omitted = no such item. */ + convertToDatabase?: () => void } type MenuItem = @@ -43,7 +47,7 @@ export function closeTableContextMenu(): void { export function openTableContextMenu(req: TableMenuRequest): void { closeTableContextMenu() - const { row, col, model, apply } = req + const { row, col, model, apply, convertToDatabase } = req // Restore focus to whatever opened the menu (e.g. a table cell) on close, // unless an action ran — that focuses its own target cell. const previouslyFocused = document.activeElement as HTMLElement | null @@ -142,7 +146,13 @@ export function openTableContextMenu(req: TableMenuRequest): void { kind: 'item', label: 'Sort column (Z → A)', run: () => apply(sortByColumn(model, col, 'desc')) - } + }, + ...(convertToDatabase + ? [ + { kind: 'sep' } as const, + { kind: 'item', label: 'Convert to database…', run: convertToDatabase } as const + ] + : []) ] const menu = document.createElement('div') diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index 5fe5b434..38dc0e80 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -42,6 +42,7 @@ import { /** Minimum draggable column width (px) for the resize handles (#294). */ const MIN_COL_WIDTH = 48 import { openTableContextMenu } from './cm-table-menu' +import { convertTableToDatabase } from './table-to-database' import { renderMarkdown } from './markdown' import { getCM, Vim } from '@replit/codemirror-vim' import { undo, redo } from '@codemirror/commands' @@ -191,7 +192,7 @@ export function tableBlockAt(doc: Text, pos: number): { from: number; to: number /** Find the enclosing `Table` node range for a doc position, or null. The range * is extended over a trailing `zen:cols` width marker so re-serialization * replaces both (no duplicate markers, no leaked raw comment). */ -function tableRangeAt(view: EditorView, pos: number): { from: number; to: number } | null { +export function tableRangeAt(view: EditorView, pos: number): { from: number; to: number } | null { let node = syntaxTree(view.state).resolveInner(pos, 1) while (node) { if (node.name === 'Table') { @@ -204,6 +205,55 @@ function tableRangeAt(view: EditorView, pos: number): { from: number; to: number return tableBlockAt(view.state.doc, pos) } +/** + * The rendered table the user last worked in, per editor. + * + * A palette or leader command that acts on "the table under the cursor" + * cannot read it from the selection while tables are widgets: the atomic + * range keeps the caret out of the block, and by the time the command runs, + * focus has already left the cell for the palette input. So the editor + * remembers the widget that last took focus and forgets it as soon as focus + * lands back in the note text, where the caret is the better authority again. + * The element is kept alongside its position so a table that is still on + * screen resolves live (robust to edits above it), and one whose widget was + * rebuilt while the palette was up still resolves from where it started. + */ +const lastFocusedTable = new WeakMap() +const focusTrackedViews = new WeakSet() + +function trackTableFocus(view: EditorView): void { + if (focusTrackedViews.has(view)) return + focusTrackedViews.add(view) + view.contentDOM.addEventListener('focusin', (event) => { + const widget = (event.target as HTMLElement | null)?.closest?.('.cm-table-widget') + if (!widget) { + lastFocusedTable.delete(view) + return + } + try { + lastFocusedTable.set(view, { el: widget, anchor: view.posAtDOM(widget) }) + } catch { + // A widget that is no longer part of the view has nothing to record. + } + }) +} + +/** Document position of the rendered table whose cell holds focus, or held it + * most recently since the caret was last in the note text. Null when no table + * was visited, or the editor itself has had focus since. */ +export function focusedTableAnchor(view: EditorView): number | null { + const entry = lastFocusedTable.get(view) + if (!entry) return null + if (entry.el.isConnected) { + try { + return view.posAtDOM(entry.el) + } catch { + // Detached between the check and the lookup; fall back to the recorded start. + } + } + return entry.anchor +} + /** * Re-serialize `table` and write it over whichever Table node currently sits * under the widget's DOM. Resolving the range live (via posAtDOM) keeps the @@ -404,6 +454,7 @@ class TableWidget extends WidgetType { toDOM(view: EditorView): HTMLElement { this.view = view + trackTableFocus(view) const root = document.createElement('div') root.className = 'cm-table-widget' root.setAttribute('contenteditable', 'false') @@ -664,7 +715,8 @@ class TableWidget extends WidgetType { row, col, model: this.model, - apply: (next, focus) => this.applyMenuAction(next, focus, anchor) + apply: (next, focus) => this.applyMenuAction(next, focus, anchor), + convertToDatabase: () => this.convertToDatabase(anchor) }) }) cell.append(editable) @@ -1960,10 +2012,21 @@ class TableWidget extends WidgetType { row, col, model: this.model, - apply: (next, focus) => this.applyMenuAction(next, focus, anchor) + apply: (next, focus) => this.applyMenuAction(next, focus, anchor), + convertToDatabase: () => this.convertToDatabase(anchor) }) } + /** Hand this table to the database converter (#832). The focus moving into + * the menu already committed any dirty cell, so the document holds the + * table's current contents; `anchor` re-finds it after that rebuild. */ + private convertToDatabase(anchor?: number): void { + this.commitIfDirty() + const pos = anchor ?? this.captureTableAnchor() + if (pos == null) return + void convertTableToDatabase(this.view, pos) + } + ignoreEvent(): boolean { return true } diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index b30fa942..dae94c1f 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -270,6 +270,39 @@ describe('close-tab command shortcut', () => { }) }) +describe('template command shortcuts (#847)', () => { + it('show the template shortcut once one is set, and the leader key only with Vim on', async () => { + const { buildCommands, useStore } = await loadCommands() + // Linux: the template shortcuts ship unbound there. + Object.defineProperty(window, 'zen', { + configurable: true, + value: { ...window.zen, platformSync: () => 'linux' } + }) + const shortcutOf = (id: string) => buildCommands().find((c) => c.id === id)?.shortcut + + // Insert only exists with a note open. + useStore.setState({ + vimMode: false, + keymapOverrides: {}, + activeNote: { folder: 'inbox', path: 'inbox/Plan.md', title: 'Plan', body: '' } as never + }) + expect(buildCommands().some((c) => c.id === 'template.insert')).toBe(true) + expect(shortcutOf('template.create')).toBeFalsy() + expect(shortcutOf('template.insert')).toBeFalsy() + + useStore.setState({ vimMode: true }) + expect(shortcutOf('template.create')).toMatch(/ t$/) + expect(shortcutOf('template.insert')).toMatch(/ i$/) + + useStore.setState({ + vimMode: false, + keymapOverrides: { 'global.newNoteFromTemplate': 'Alt+Mod+T', 'global.insertTemplate': 'Alt+Mod+Y' } + }) + expect(shortcutOf('template.create')).toMatch(/T$/) + expect(shortcutOf('template.insert')).toMatch(/Y$/) + }) +}) + describe('New Note in Current Folder (#403)', () => { it('creates in the active note folder, not the sidebar browse view', async () => { const { buildCommands, useStore } = await loadCommands() diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 03d01c42..947d18e2 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -29,6 +29,8 @@ import { } from './cm-harper' import { harperEditorConfig, harperSupported } from './harper-runtime' import { reflowParagraph } from './cm-reflow' +import { convertTableToDatabase } from './table-to-database' +import { canRenameVault, renameVaultWithPrompt } from './rename-vault' import { promptImageWidth } from './image-resize' import { copyLinkAtCursor } from './link-copy' import { getKeymapDisplay, type KeymapId } from './keymaps' @@ -196,7 +198,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'New Note from Template…', category: 'Note', keywords: 'template scaffold adr rfc meeting daily weekly boilerplate new', - shortcut: leaderShortcut('vim.leaderTemplatePicker'), + shortcut: + shortcut('global.newNoteFromTemplate') || + (getState().vimMode ? leaderShortcut('vim.leaderTemplatePicker') : undefined), run: () => getState().setTemplatePaletteOpen(true) }, { @@ -204,7 +208,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma title: 'Insert Template into Current Note…', category: 'Note', keywords: 'template insert apply into current note scaffold fill', - shortcut: leaderShortcut('vim.leaderInsertTemplate'), + shortcut: + shortcut('global.insertTemplate') || + (getState().vimMode ? leaderShortcut('vim.leaderInsertTemplate') : undefined), when: () => !!getState().activeNote, run: () => getState().openTemplatePaletteForInsert() }, @@ -962,6 +968,24 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma view.focus() } }, + { + id: 'table.to-database', + title: 'Convert Table to Database…', + category: 'Editor', + shortcut: getState().vimMode + ? chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderTableToDatabase') + : undefined, + keywords: 'table database convert csv base grid board rows columns markdown pipe extract', + // Stays listed whenever a note is open: with no table under the cursor + // the run says so, which beats an entry that vanishes from the palette + // and an ex command that returns nothing. + when: () => !!getState().editorViewRef && !!getState().activeNote, + run: async () => { + const view = getState().editorViewRef + if (!view) return + await convertTableToDatabase(view) + } + }, { id: 'editor.copy-link', title: 'Copy Link Under Cursor', @@ -1855,6 +1879,18 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma /* handled by CommandPalette */ } }, + { + id: 'vault.rename', + title: 'Rename Vault…', + category: 'Vault', + keywords: 'vault name display name rename label switcher sidebar header folder', + // Local vaults only (#692): a temporary folder session writes nothing + // into its folder, and a remote workspace's settings belong to its server. + when: () => canRenameVault(getState()), + run: async () => { + await renameVaultWithPrompt() + } + }, { id: 'app.workspace.remote', title: 'Connect to Remote Vault…', diff --git a/packages/app-core/src/lib/editor-disk-sync.test.ts b/packages/app-core/src/lib/editor-disk-sync.test.ts new file mode 100644 index 00000000..26d356f7 --- /dev/null +++ b/packages/app-core/src/lib/editor-disk-sync.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +// +// #852: the file changing on disk under an open note must not leave the +// user's undo steps mapped onto text nobody in the app wrote. The editor +// applies the disk change as a minimal non-undoable change (so the caret +// stays), then starts the history clean. A peer pane or a rename rewrite, +// which arrive the same way, keep it. + +import { Compartment, EditorState, Transaction } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { history, redoDepth, undo, undoDepth } from '@codemirror/commands' +import { afterEach, describe, expect, it } from 'vitest' +import { isDiskChange, resetUndoHistory } from './editor-disk-sync' +import { minimalTextChange } from './minimal-text-change' + +const cleanups: Array<() => void> = [] +afterEach(() => { + while (cleanups.length) cleanups.pop()!() +}) + +function makeView(doc: string): { view: EditorView; compartment: Compartment } { + const compartment = new Compartment() + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [compartment.of(history())] }) + }) + cleanups.push(() => { + view.destroy() + parent.remove() + }) + return { view, compartment } +} + +/** What the user types: an ordinary, undoable edit. */ +function type(view: EditorView, text: string): void { + view.dispatch({ changes: { from: view.state.doc.length, insert: text } }) +} + +/** How EditorPane applies a body the store handed it: the span between the + * common prefix and suffix, not added to the history. */ +function applyBodyFromStore(view: EditorView, nextBody: string): void { + const change = minimalTextChange(view.state.doc.toString(), nextBody) + if (!change) return + view.dispatch({ changes: change, annotations: [Transaction.addToHistory.of(false)] }) +} + +describe('isDiskChange', () => { + it('is a body change under the same path with a moved disk revision', () => { + expect(isDiskChange({ pathChanged: false, bodyChanged: true, diskRevision: 1, seenDiskRevision: 0 })).toBe(true) + }) + + it('is not a peer pane or a rename rewrite, whose revision stays put', () => { + expect(isDiskChange({ pathChanged: false, bodyChanged: true, diskRevision: 2, seenDiskRevision: 2 })).toBe(false) + }) + + it('is not a tab switch or a rename, even when the incoming path has a history of disk changes', () => { + expect(isDiskChange({ pathChanged: true, bodyChanged: true, diskRevision: 5, seenDiskRevision: 0 })).toBe(false) + }) + + it('needs a body change at all', () => { + expect(isDiskChange({ pathChanged: false, bodyChanged: false, diskRevision: 1, seenDiskRevision: 0 })).toBe(false) + }) +}) + +describe('the open note changes on disk (#852)', () => { + it('undo does nothing after the history is reset, and the disk text stays', () => { + const { view, compartment } = makeView('') + type(view, 'Hello') + expect(undoDepth(view.state)).toBe(1) + + applyBodyFromStore(view, 'Hello, changed outside') + resetUndoHistory(view, compartment) + expect(view.state.doc.toString()).toBe('Hello, changed outside') + expect(undoDepth(view.state)).toBe(0) + expect(redoDepth(view.state)).toBe(0) + + undo(view) + expect(view.state.doc.toString()).toBe('Hello, changed outside') + }) + + it('edits made after the disk change undo normally, back to the disk text', () => { + const { view, compartment } = makeView('Hello') + applyBodyFromStore(view, 'Hello, changed outside') + resetUndoHistory(view, compartment) + type(view, ' and mine') + expect(view.state.doc.toString()).toBe('Hello, changed outside and mine') + undo(view) + expect(view.state.doc.toString()).toBe('Hello, changed outside') + undo(view) + expect(view.state.doc.toString()).toBe('Hello, changed outside') + }) + + it('a peer pane edit keeps the history: undo reverts your own step under it', () => { + // The mapping is the point for in-app changes (renames, other panes): + // the user's edit still fits the text and comes out cleanly. + const { view } = makeView('Draft: ') + type(view, 'hello') + applyBodyFromStore(view, 'Draft (v2): hello') + undo(view) + expect(view.state.doc.toString()).toBe('Draft (v2): ') + }) + + it('control: without the reset, undo produces text nobody wrote (the bug)', () => { + const { view } = makeView('') + type(view, 'Hello') + applyBodyFromStore(view, 'Hello, changed outside') + undo(view) + expect(view.state.doc.toString()).toBe(', changed outside') + }) +}) diff --git a/packages/app-core/src/lib/editor-disk-sync.ts b/packages/app-core/src/lib/editor-disk-sync.ts new file mode 100644 index 00000000..c13f9bc2 --- /dev/null +++ b/packages/app-core/src/lib/editor-disk-sync.ts @@ -0,0 +1,47 @@ +/** + * Telling a change that came from disk apart from the other ways a note's + * text changes underneath its editor, and what to do about it (#852). + * + * The editor applies every body update the store hands it as a minimal, + * non-undoable change, and CodeMirror maps the existing undo steps through + * it. For another pane typing, or a rename rewriting the title heading or a + * link, that is right: the user's own edits still fit the text and undo keeps + * working (the reason the minimal change exists). For the file changing on + * disk it is not. The steps land on text nobody in this app wrote, undoing + * one produced a document neither the user nor the other program ever had, + * and the save that follows every edit wrote it to disk a moment later. A + * reopened note already starts clean when its text changed elsewhere; a note + * that never left the screen now does the same. + * + * The editor cannot see the difference in the body alone, so the store says + * it: a per-note disk revision, bumped in the same update that takes a body + * from disk (a watcher event, a resync after a remote feed came back) and + * left alone by every in-app writer. An editor remembers the revision it + * last accounted for, re-baselines whenever it starts showing another path, + * and treats a body change under an unchanged path with a moved revision as + * the disk speaking. + */ +import { history } from '@codemirror/commands' +import type { Compartment } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' + +export function isDiskChange(args: { + /** The editor's path changed (a tab switch, or a rename of the note it shows). */ + pathChanged: boolean + /** The text the store holds differs from the editor's document. */ + bodyChanged: boolean + /** The store's disk revision for the path about to be shown. */ + diskRevision: number + /** The revision this editor last accounted for. */ + seenDiskRevision: number +}): boolean { + return !args.pathChanged && args.bodyChanged && args.diskRevision !== args.seenDiskRevision +} + +/** Drop a view's undo history where it stands: there is no clear command, so + * the field is removed and added back empty, the idiom every reset in the + * editor uses (#247). */ +export function resetUndoHistory(view: EditorView, compartment: Compartment): void { + view.dispatch({ effects: compartment.reconfigure([]) }) + view.dispatch({ effects: compartment.reconfigure(history()) }) +} diff --git a/packages/app-core/src/lib/help-keys.test.ts b/packages/app-core/src/lib/help-keys.test.ts new file mode 100644 index 00000000..ec942e1c --- /dev/null +++ b/packages/app-core/src/lib/help-keys.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { HELP_SHORTCUT_SECTIONS, HELP_VIM_COMMANDS } from './help' +import { parseHelpKeys } from './help-keys' + +const keysOf = (raw: string, mac: boolean): string[] => + parseHelpKeys(raw, mac).parts.map((part) => part.text) + +describe('parseHelpKeys', () => { + it('draws a Mod combo the way the platform names it', () => { + expect(keysOf('Mod+P', true)).toEqual(['⌘P']) + expect(keysOf('Mod+P', false)).toEqual(['Ctrl+P']) + expect(keysOf('Shift+Mod+Space', true)).toEqual(['⇧⌘Space']) + expect(keysOf('Mod+Enter', true)).toEqual(['⌘↵']) + expect(keysOf('Mod+-', true)).toEqual(['⌘-']) + }) + + it('puts modifiers in the order the rest of the app draws them', () => { + expect(keysOf('Mod+Shift+C', true)).toEqual(['⇧⌘C']) + expect(keysOf('Mod+Alt+M', true)).toEqual(['⌥⌘M']) + expect(keysOf('Mod+Shift+C', false)).toEqual(['Shift+Ctrl+C']) + }) + + it('leaves keys without Mod as written, so they match the prose', () => { + expect(keysOf('Ctrl+D', true)).toEqual(['Ctrl+D']) + expect(keysOf('Shift+Enter', true)).toEqual(['Shift+Enter']) + expect(keysOf('Space l s', true)).toEqual(['Space l s']) + }) + + it('moves a trailing aside out of the keycap', () => { + expect(parseHelpKeys('Ctrl+D (in Search notes)', true)).toEqual({ + parts: [{ kind: 'key', text: 'Ctrl+D' }], + context: '(in Search notes)' + }) + expect(parseHelpKeys('Mod+F (in the editor)', true)).toEqual({ + parts: [{ kind: 'key', text: '⌘F' }], + context: '(in the editor)' + }) + expect(parseHelpKeys('Alt+Q (macOS: Ctrl+Q)', true).context).toBe('(macOS: Ctrl+Q)') + }) + + it('gives each alternative its own keycap', () => { + expect(parseHelpKeys('Mod+4 / Mod+5 / Mod+6', true).parts).toEqual([ + { kind: 'key', text: '⌘4' }, + { kind: 'separator', text: '/' }, + { kind: 'key', text: '⌘5' }, + { kind: 'separator', text: '/' }, + { kind: 'key', text: '⌘6' } + ]) + expect(parseHelpKeys('↑ / ↓ · Enter (in a picker)', true)).toEqual({ + parts: [ + { kind: 'key', text: '↑' }, + { kind: 'separator', text: '/' }, + { kind: 'key', text: '↓' }, + { kind: 'separator', text: '·' }, + { kind: 'key', text: 'Enter' } + ], + context: '(in a picker)' + }) + }) + + it('keeps shorthand alternatives together, since a lone key would mislead', () => { + expect(keysOf('Ctrl-w h / j / k / l', true)).toEqual(['Ctrl-w h / j / k / l']) + expect(keysOf(':zen [toggle|on|off] / :zenmode', true)).toEqual([ + ':zen [toggle|on|off] / :zenmode' + ]) + }) + + it('never drops text from a manual row', () => { + const rows = [ + ...HELP_SHORTCUT_SECTIONS.flatMap((section) => section.items.map((item) => item.keys)), + ...HELP_VIM_COMMANDS.map((command) => command.command) + ].filter((raw) => !/\bMod\b/.test(raw)) + expect(rows.length).toBeGreaterThan(50) + for (const raw of rows) { + const { parts, context } = parseHelpKeys(raw, true) + const keys = parts + .map((part) => (part.kind === 'separator' ? ` ${part.text} ` : part.text)) + .join('') + expect(context ? `${keys} ${context}` : keys).toBe(raw) + } + }) +}) diff --git a/packages/app-core/src/lib/help-keys.ts b/packages/app-core/src/lib/help-keys.ts new file mode 100644 index 00000000..5c58d0d6 --- /dev/null +++ b/packages/app-core/src/lib/help-keys.ts @@ -0,0 +1,69 @@ +import { formatKeyToken } from './keymaps' + +/** A key (or chord, or ex command) drawn as a keycap, or the quiet mark + * between two alternatives. */ +export interface HelpKeyPart { + kind: 'key' | 'separator' + text: string +} + +export interface HelpKeys { + parts: HelpKeyPart[] + /** A trailing aside such as "(in Search notes)": it says where the keys + * work, so it sits beside the keycaps instead of inside one. */ + context: string | null +} + +// The order the keymap recorder stores modifiers in (normalizeModifiers in +// keymaps.ts), which is also the order macOS menus draw them in: ⌃⌥⇧⌘. +const MODIFIER_ORDER = ['Ctrl', 'Alt', 'Shift', 'Mod', 'Meta'] +const COMBO = /^(?:(?:Ctrl|Alt|Shift|Mod|Meta)\+)+[^\s+]+$/ + +// Manual rows that are not tied to a keymap id spell keys the portable way +// (`Shift+Mod+T`). No keyboard has a Mod key, so a combo that uses it is drawn +// the way the rest of the app draws bindings: ⇧⌘T on macOS, Shift+Ctrl+T +// elsewhere. Combos without Mod already name real keys and stay as written, +// which keeps them matching the prose that mentions them. +function formatCombo(token: string, mac: boolean): string { + if (!COMBO.test(token)) return token + const modifiers = token.split('+') + const key = modifiers.pop() as string + if (!modifiers.includes('Mod')) return token + const ordered = [...new Set(modifiers)].sort( + (a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b) + ) + return formatKeyToken([...ordered, key].join('+'), mac) +} + +function formatKeys(text: string, mac: boolean): string { + return text + .split(/(\s+)/) + .map((piece) => formatCombo(piece, mac)) + .join('') +} + +/** + * Splits a manual row's key text into keycaps. `A / B / C` becomes one keycap + * per alternative only when every alternative has the same shape: shorthand + * such as `Ctrl-w h / j / k / l` stays a single keycap, because a lone `j` + * would read as a different key. + */ +export function parseHelpKeys(raw: string, mac: boolean): HelpKeys { + const aside = /^(.*\S)\s+(\([^()]+\))$/.exec(raw) + const keys = aside ? aside[1] : raw + const context = aside ? aside[2] : null + const pieces = keys.split(/(\s+[/·]\s+)/) + const alternatives = pieces.filter((_, index) => index % 2 === 0) + const shapes = new Set(alternatives.map((alt) => alt.trim().split(/\s+/).length)) + if (alternatives.length === 1 || shapes.size !== 1) { + return { parts: [{ kind: 'key', text: formatKeys(keys, mac) }], context } + } + return { + parts: pieces.map((piece, index) => + index % 2 === 0 + ? { kind: 'key', text: formatKeys(piece, mac) } + : { kind: 'separator', text: piece.trim() } + ), + context + } +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index d86959b4..5d55e876 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -104,7 +104,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Start a note from a template', body: - 'Open the template picker with `Space t`, the `:template` (or `:tmpl`) ex command, or the “New Note from Template…” command palette entry. Pick a built-in template — engineering ones like ADR, RFC/Design Doc, Bug Report, Postmortem, Meeting Notes, and 1:1, or personal ones like Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, and To-do — or one of your own. ZenNotes then asks which folder to create it in (defaulting to the folder you are viewing) and fills in variables like the date and week before placing your cursor where the template marks it. To create straight into a specific folder, right-click that folder in the sidebar and choose “New from template”.' + 'Open the template picker with `Space t`, the `:template` (or `:tmpl`) ex command, or the “New Note from Template…” command palette entry. It has a shortcut of its own too, which works with Vim mode on or off: `Alt+Mod+T` (⌘⌥T) on macOS, and on Linux and Windows whatever key you give “New note from template” under Settings → Keymap. Pick a built-in template — engineering ones like ADR, RFC/Design Doc, Bug Report, Postmortem, Meeting Notes, and 1:1, or personal ones like Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, and To-do — or one of your own. ZenNotes then asks which folder to create it in (defaulting to the folder you are viewing) and fills in variables like the date and week before placing your cursor where the template marks it. To create straight into a specific folder, right-click that folder in the sidebar and choose “New from template”.' }, { title: 'Make and edit your own templates', @@ -126,6 +126,16 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Run “New Database” from the command palette (or right-click a folder in the sidebar → New database) to create one, or just open an existing `.csv` file from the vault. ZenNotes stores a database as a `.base` folder: `data.csv` (the rows), `schema.json` (field types, select options, saved views), and the record-page notes rows open into. A database opens from its sidebar row like a note: click it, or press Enter or `l` on it in Vim mode (the row’s `m` menu carries Open database too); the chevron still expands the folder to browse its record pages, and the open grid closes like any tab. Edit cells inline in the Table view, group records in a Board by any select field, switch the raw-CSV toggle to see the underlying file, and press `o` on a row to open it as a full Markdown page whose frontmatter mirrors the row’s properties. The whole grid is keyboard-driven — see the Database grid shortcuts.' }, + { + title: 'Convert a Markdown table into a database', + body: + 'A note often starts with a small pipe table that outgrows inline editing. Put the cursor in it and run “Convert Table to Database…” from the command palette (`:table_to_database` on the ex line, or `Space l d` in Vim mode); in a rendered table, the same entry sits at the bottom of the cell menu (right-click, or `m` in Vim mode). Confirm a name (prefilled from the heading above the table, or the note’s title) and ZenNotes creates a `.base` database where new databases go (Settings → Vault → New Drawings, Databases & Tasks location), then replaces the table in the note with a `[[Name]]` link that opens the grid. Every column becomes a field, typed the way an opened CSV is (checkbox, number, date, text) so the Board and filters work at once; cell text travels verbatim, inline Markdown included, and the CSV layer takes care of commas and quotes. Column widths you dragged in the editor carry over; alignment does not, since a grid has none. The swap is an ordinary edit: `u` (or Undo) brings the table back while the database stays in the vault, and the confirmation toast offers an Open button if you want to jump straight to the grid.' + }, + { + title: 'Give a vault a name of its own', + body: + 'A vault is named after its folder, which is fine until several vaults are all called `docs` (one per project repo, say). Rename the vault without touching the folder: run “Rename Vault…” from the command palette (`:vault_rename` on the ex line), pick it from the sidebar’s vault menu (click the vault name at the top of the sidebar), or type the name into Settings → Vault → Location → Vault name. The name shows in the sidebar header, the vault switcher (`Space v`), the title bar and `zn vault list`; the folder keeps its name on disk, and clearing the field goes back to it. It is saved in the vault’s own `.zennotes/vault.json` as `displayName`, so it travels with the folder to every machine that opens it and shows up in the switcher after the vault has been opened there once. A temporary folder session and a remote workspace do not offer it: the first writes nothing into its folder, and the second’s settings belong to its server.' + }, { title: 'Move a note without dragging', body: @@ -222,7 +232,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Tabs and splits are first-class', body: - 'Each editor pane can hold multiple tabs. Split the current tab right or down, move between panes with pane motions, switch the active note between Edit, Split, and Preview from commands, and, if you hide tabs, use the buffer switcher shortcut or `:buffers`. The active tab also has a full keyboard context menu, so actions like Close Others, Close Tabs to the Right, Pin Tab, Pin as Reference, Open in Floating Window, and Reveal in Finder stay accessible without the mouse. If you disable Vim mode, use the command palette instead. Each note keeps its own undo history while the app is open: switch tabs, or close a note and open it again, and `u` / Mod+Z (and redo) still walk through the edits you made to it. Renaming or moving the note you are editing keeps its caret, scroll position and undo history too. If the note was changed somewhere else in the meantime (another pane, sync, an external editor), it starts a clean history, because the old undo steps would no longer fit the text. On the desktop app you can also keep undo history after quitting, the way Vim\'s `undofile` does: turn on Settings → Editor → Keep undo history after quitting (or `:set undofile` in Vim mode, or the command palette). It is off by default because the history contains text you deleted; it is stored with the app on this computer, never in your vault, histories nobody returns to expire after 90 days, and turning the setting off erases them all. It travels in `config.toml` as `persist_undo_history` under `[editor]`.' + 'Each editor pane can hold multiple tabs. Split the current tab right or down, move between panes with pane motions, switch the active note between Edit, Split, and Preview from commands, and, if you hide tabs, use the buffer switcher shortcut or `:buffers`. The active tab also has a full keyboard context menu, so actions like Close Others, Close Tabs to the Right, Pin Tab, Pin as Reference, Open in Floating Window, and Reveal in Finder stay accessible without the mouse. If you disable Vim mode, use the command palette instead. Each note keeps its own undo history while the app is open: switch tabs, or close a note and open it again, and `u` / Mod+Z (and redo) still walk through the edits you made to it. Renaming or moving the note you are editing keeps its caret, scroll position and undo history too. If the note was changed somewhere else in the meantime (another pane, sync, an external editor), it starts a clean history, because the old undo steps would no longer fit the text. The same happens the moment a sync tool, a script or another editor changes the file while you are looking at it: the new text appears in place, the caret stays, and Undo starts over instead of undoing your earlier typing underneath text that arrived from outside. Another pane typing into the same note leaves the history alone. On the desktop app you can also keep undo history after quitting, the way Vim\'s `undofile` does: turn on Settings → Editor → Keep undo history after quitting (or `:set undofile` in Vim mode, or the command palette). It is off by default because the history contains text you deleted; it is stored with the app on this computer, never in your vault, histories nobody returns to expire after 90 days, and turning the setting off erases them all. It travels in `config.toml` as `persist_undo_history` under `[editor]`.' }, { title: 'Context menus are part of the keyboard model', @@ -277,7 +287,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'A whole note can be a task (task files)', body: - 'Besides inline `- [ ]` checkboxes, a whole note can itself be a task: give its frontmatter a `task` tag (`tags: [task]`) and its metadata lives in frontmatter — `status` (open / in-progress / done…), `priority` (high / normal / low), `due` and `scheduled` (`YYYY-MM-DD`), plus any `tags`, while the note body holds free-form detail or sub-checkboxes. These "task files" show up in the Tasks views right alongside inline tasks, so both styles live in one vault. This is the TaskNotes convention, so a vault stays interoperable with TaskForge and Obsidian. Quick-add one from the command palette (**New Task**, or **New Task in Folder…** to choose where it lands), the "+ New task" button in the Tasks header, the `a` key (Vim mode), or the `:newtask` / `:task` ex command — and `:newtask Projects/Website` drops it straight into a folder so multiple projects stay organized. New task files default to your configured tasks location (Settings → New Drawings, Databases & Tasks; the inbox by default), and the folder-picking options override that per task. Checking a task file off rewrites its frontmatter (`status: done` and a `completedDate`) rather than a checkbox character, and rescheduling from the calendar or changing its column on the Kanban board updates the matching frontmatter field. Delete on a task file trashes the whole note (after a confirm).' + 'Besides inline `- [ ]` checkboxes, a whole note can itself be a task: give its frontmatter a `task` tag (`tags: [task]`) and its metadata lives in frontmatter — `status` (open / in-progress / done…), `priority` (high / normal / low), `due` and `scheduled` (`YYYY-MM-DD`), plus any `tags`, while the note body holds free-form detail or sub-checkboxes. These "task files" show up in the Tasks views right alongside inline tasks, so both styles live in one vault. This is the TaskNotes convention, so a vault stays interoperable with TaskForge and Obsidian. Quick-add one from the command palette (**New Task**, or **New Task in Folder…** to choose where it lands), the "+ New task" button in the Tasks header, the `a` key (Vim mode), or the `:newtask` / `:task` ex command — and `:newtask Projects/Website` drops it straight into a folder so multiple projects stay organized. New task files default to your configured tasks location (Settings → Vault → Notes → **Default tasks location**, or search Settings for "tasks folder"). That is your primary notes area unless you pick the active note’s folder or a specific folder, and a specific folder lives inside the primary notes area: `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault, as the setting itself shows. The folder-picking options override it per task. Checking a task file off rewrites its frontmatter (`status: done` and a `completedDate`) rather than a checkbox character, and rescheduling from the calendar or changing its column on the Kanban board updates the matching frontmatter field. Delete on a task file trashes the whole note (after a confirm).' }, { title: 'The Tasks calendar schedules and reschedules', @@ -495,6 +505,8 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Shift+Mod+P', action: 'Open commands', detail: 'Open the command palette.' }, { keys: 'Mod+N', action: 'New note in current folder', detail: 'Create a note in the active note\u2019s folder (or the browsed folder when no note is open) and focus its title. On Linux and Windows this wins over Vim\u2019s Ctrl+N (cursor down). Rebindable under Settings \u2192 Keymaps.' }, { keys: 'Shift+Mod+N', action: 'New Quick Note', detail: 'Create a quick capture note in the main window and focus its title.' }, + { keys: 'Alt+Mod+T (macOS)', action: 'New note from template', detail: 'Open the template picker to create a note, with Vim mode on or off. Bound by default on macOS; on Linux and Windows it starts unbound, since Ctrl+Alt+T opens a terminal on most Linux desktops, so set a key under Settings → Keymap.' }, + { keys: 'Unbound', action: 'Insert template into current note', detail: 'Pick a template and insert it at the cursor of the active note, with Vim mode on or off. Unbound until you set a key under Settings → Keymap.' }, { keys: 'Shift+Mod+Space', action: 'Open quick capture window', detail: 'Open the floating, always-on-top capture window. Bound system-wide (CommandOrControl+Shift+Space by default) so it works over any app; change it under Settings → Editor.' }, { keys: 'Mod+,', action: 'Open Settings', detail: 'Open settings for appearance, editor behavior, fonts, vault controls, and app details. Settings opens on its search: type to filter, move through the results with ↑/↓ (or Ctrl+J / Ctrl+K), and press Enter to jump to the picked setting. From anywhere in Settings, Mod+F (or / in Vim mode) returns to the search.' }, { keys: 'Mod+1', action: 'Toggle sidebar', detail: 'Hide or show the left sidebar.' }, @@ -555,9 +567,10 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space r', action: 'Review Cloud conflicts', detail: 'Open whatever Cloud sync is waiting on: the queue of files two devices changed at once, one decision at a time, or the vault settings question, which lists each setting that differs with this device’s value beside the cloud’s and lets you pick a side per setting. The binding and the command palette entry appear only while something is waiting, and open the same dialog as Review in the status bar.' }, { keys: 'Space g', action: 'Open atlas', detail: 'Open the Atlas view: the whole vault drawn as a map of notes and links.' }, { keys: 'Space q', action: 'Quick capture window', detail: 'Open the floating, always-on-top capture window, same as the global hotkey.' }, - { keys: 'Space i', action: 'Insert template into note', detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it.' }, + { keys: 'Space i', action: 'Insert template into note', detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it. Without Vim mode, give “Insert template into current note” a key under Settings → Keymap.' }, { keys: 'Space c', action: 'Toggle calendar', detail: 'Show or hide the calendar panel for the active pane.' }, { keys: 'Space l s', action: 'Toggle favorite', detail: 'Add or remove the active note from the Favorites section shown in the sidebar and on the home view. Folders join it from their context menu.' }, + { keys: 'Space l d', action: 'Convert table to database', detail: 'Turn the Markdown table under the cursor (or the rendered table whose cell you are in) into a `.base` database and leave a `[[link]]` to it in the note. Also “Convert Table to Database…” in the command palette, `:table_to_database`, and the bottom of the table cell menu.' }, { keys: 'Space, then pause', action: 'Show leader hints', detail: 'If enabled in Settings, open a which-key style guide for the next available leader actions. Sticky mode keeps it open until `Space` or `Esc`.' }, { keys: 'Mod+3', action: 'Toggle outline panel', detail: 'Show or hide the persistent outline in the active pane. Once focused (Ctrl+W l or Alt+L from the editor), j / k — or the arrows — walk the headings, gg / G jump to the first and last, Enter jumps the editor to the heading under the cursor, and Esc hands focus back.' }, { keys: 'zc / zo', action: 'Fold / unfold heading', detail: 'Collapse or expand the section below the heading at the cursor.' }, @@ -1133,8 +1146,9 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ title: 'Vault', items: [ { label: 'Vault location', detail: 'Reveal or change the root folder ZenNotes treats as the active vault.' }, + { label: 'Vault name', detail: 'What the sidebar header, the vault switcher and the title bar call this vault; the folder keeps its own name on disk. Leave it empty to use the folder name. Saved in the vault’s `.zennotes/vault.json`, so it follows the folder wherever it is opened. Also “Rename Vault…” in the command palette (`:vault_rename`) and the sidebar’s vault menu. Local vaults only.' }, { label: 'Primary notes location', detail: 'Treat `inbox/` as the main notes area, or use the vault root directly for an Obsidian-style flat vault.' }, - { label: 'New Drawings & Databases location', detail: 'Choose where new Excalidraw drawings and databases are created so they do not pile up in the vault root: the primary notes location (the default), the folder of the note you are currently viewing, or a specific subfolder you name (e.g. `assets/drawings`). Only new files are affected; existing ones stay put.' }, + { label: 'New Drawings, Databases & Tasks location', detail: 'Choose where new Excalidraw drawings, databases, and task files are created so they do not pile up in the vault root: the primary notes location (the default), the folder of the note you are currently viewing, or a specific folder you name (e.g. `assets/drawings` or `Tasks`). A specific folder lives inside the primary notes area, so `Tasks` means `inbox/Tasks/` in an Inbox vault and `Tasks/` in a root vault; the folder field shows the exact destination. Search Settings for "tasks folder" or "drawings folder" to jump there. Only new files are affected; existing ones stay put.' }, { label: 'Daily notes', detail: "Enable a daily-notes workflow, choose a directory pattern, naming pattern, locale, and template so each day’s note starts in the right place. Supported tokens are `yyyy`, `yy`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `EEE`, `EEEE`, `w`, and `ww`; quote literal words like `'Daily Notes'/yyyy/MM-MMM`. Open today’s note with `Space d`, `:daily`, or the command palette. Two task options live here too: “Tasks are due on the note’s date” makes tasks in a daily note show on the calendar for that day (on by default), and “Roll over unfinished tasks to today” moves every unchecked task from past daily notes into today when you open it (off by default; also runnable from the command palette)." }, { label: 'Weekly notes', detail: "Enable weekly notes with a directory pattern, naming pattern, locale, and template. Weekly patterns support the same tokens as daily notes plus ISO week `w` and `ww`; the default title pattern is `yyyy-'W'ww`. Open this week’s note with `Space w`, `:weekly`, or the command palette." }, { label: 'Monthly notes', detail: 'Enable monthly notes with a directory pattern, naming pattern, locale, and template. It creates one note per calendar month, handy for monthly reviews and reflections. The default title pattern is `yyyy-MM` (e.g. `2026-07`). Open this month’s note with `Space m`, `:monthly`, or the command palette. Each notes section in Settings now collapses its fields when its toggle is off.' }, diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 1979d403..4b39d232 100644 --- a/packages/app-core/src/lib/keymaps.test.ts +++ b/packages/app-core/src/lib/keymaps.test.ts @@ -230,6 +230,49 @@ describe('sequenceTokenFromEvent', () => { }) }) +describe('template shortcuts (#847)', () => { + it('bind New note from template to ⌘⌥T on the Mac and leave Linux and Windows unbound', () => { + withPlatform('darwin', () => { + expect(getDefaultKeymapBinding('global.newNoteFromTemplate')).toBe('Alt+Mod+T') + expect(getDefaultKeymapBinding('global.insertTemplate')).toBe('') + }) + for (const platform of ['linux', 'win32'] as const) { + withPlatform(platform, () => { + expect(getDefaultKeymapBinding('global.newNoteFromTemplate')).toBe('') + expect(getDefaultKeymapBinding('global.insertTemplate')).toBe('') + }) + } + }) + + it('fire on the chord a Mac actually sends, and never while unbound', () => { + withPlatform('darwin', () => { + // Option turns T into †; the binding still has to match the physical key. + const cmdOptionT = fakeEvent({ key: '†', code: 'KeyT', metaKey: true, altKey: true }) + expect(matchesShortcut(cmdOptionT, {}, 'global.newNoteFromTemplate')).toBe(true) + expect(matchesShortcut(cmdOptionT, {}, 'global.insertTemplate')).toBe(false) + }) + withPlatform('linux', () => { + const ctrlAltT = fakeEvent({ key: 't', code: 'KeyT', ctrlKey: true, altKey: true }) + expect(matchesShortcut(ctrlAltT, {}, 'global.newNoteFromTemplate')).toBe(false) + const bound = { 'global.newNoteFromTemplate': 'Alt+Mod+T' } + expect(matchesShortcut(ctrlAltT, bound, 'global.newNoteFromTemplate')).toBe(true) + }) + }) + + it('have no key to show while unbound, and never clash with each other', () => { + withPlatform('linux', () => { + expect(getKeymapDisplay({}, 'global.insertTemplate')).toBe('') + expect(findKeymapConflict({}, 'global.newNoteFromTemplate', '')).toBeNull() + expect(findKeymapConflict({}, 'global.insertTemplate', 'Alt+Mod+Y')).toBeNull() + }) + withPlatform('darwin', () => { + expect(findKeymapConflict({}, 'global.insertTemplate', 'Alt+Mod+T')?.id).toBe( + 'global.newNoteFromTemplate' + ) + }) + }) +}) + describe('leader keymap definitions', () => { it('keeps the recent-note toggle portable with a literal Ctrl+Tab Mac default', () => { withPlatform('darwin', () => { @@ -252,6 +295,9 @@ describe('leader keymap definitions', () => { // unchanged on every platform. for (const def of getKeymapDefinitions()) { if (def.kind !== 'shortcut') continue + // An action that ships unbound (the template shortcuts off the Mac, + // #847) has no spelling to round-trip. + if (def.defaultBinding === '') continue for (const platform of ['darwin', 'linux', 'win32'] as const) { const roundTripped = withPlatform(platform, () => normalizeShortcutBinding(def.defaultBinding) diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index f281368a..444504cc 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -14,6 +14,8 @@ export type KeymapId = | "global.commandPalette" | "global.newQuickNote" | "global.newNoteHere" + | "global.newNoteFromTemplate" + | "global.insertTemplate" | "global.openSettings" | "global.openFile" | "global.toggleSidebar" @@ -63,6 +65,7 @@ export type KeymapId = | "vim.leaderFormatNote" | "vim.leaderCopyMarkdown" | "vim.leaderToggleFavorite" + | "vim.leaderTableToDatabase" | "vim.leaderQuickCapture" | "vim.leaderTemplatePicker" | "vim.leaderInsertTemplate" @@ -225,6 +228,31 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ "Create a note in the active note's folder (or the browsed folder when no note is open) and focus its title. Wins over Vim's Ctrl+N (cursor down) when Mod is Ctrl.", defaultBinding: "Mod+N", }, + // The template actions were reachable only through Vim's leader (Space t, + // Space i) and the palette, so with Vim off no key could reach them (#847). + // Windows and Linux ship unbound: Ctrl+Alt+T opens a terminal on most Linux + // desktops, and Ctrl/Cmd+Alt+I is Electron's DevTools. + { + id: "global.newNoteFromTemplate", + kind: "shortcut", + scope: "app", + group: "global", + title: "New note from template", + description: + "Open the template picker to create a note, as Vim's Leader t does. Unbound on Windows and Linux until you set a key.", + defaultBinding: "", + defaultBindingMac: "Alt+Mod+T", + }, + { + id: "global.insertTemplate", + kind: "shortcut", + scope: "app", + group: "global", + title: "Insert template into current note", + description: + "Render a template into the note you are editing, as Vim's Leader i does. Unbound until you set a key.", + defaultBinding: "", + }, { id: "global.openSettings", kind: "shortcut", @@ -641,6 +669,19 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderTableToDatabase", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader note action: convert table to database", + // `d` for database; `t` stays free for whatever the note group needs next. + description: + "Turn the Markdown table under the cursor into a database and leave a link to it in the note.", + defaultBinding: "d", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.leaderQuickCapture", kind: "sequence", diff --git a/packages/app-core/src/lib/markdown-table.test.ts b/packages/app-core/src/lib/markdown-table.test.ts index 295c99c5..0116db85 100644 --- a/packages/app-core/src/lib/markdown-table.test.ts +++ b/packages/app-core/src/lib/markdown-table.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { parseTable, + parseTableBlock, serializeTable, insertRow, deleteRow, @@ -300,6 +301,27 @@ describe('column-width marker (#294)', () => { }) }) +describe('parseTableBlock (table + trailing zen:cols marker, #832)', () => { + it('parses a bare table exactly like parseTable', () => { + expect(parseTableBlock(SIMPLE)).toEqual(parseTable(SIMPLE)) + }) + + it('lifts a trailing zen:cols marker into colWidths instead of a body row', () => { + const t = parseTableBlock(`${SIMPLE}\n`) + expect(t).not.toBeNull() + expect(t!.rows).toEqual([ + ['1', '2'], + ['3', '4'] + ]) + expect(t!.colWidths).toEqual([120, null]) + }) + + it('still rejects text that is not a table', () => { + expect(parseTableBlock('| just one line |')).toBeNull() + expect(parseTableBlock('')).toBeNull() + }) +}) + describe('column-width marker round-trip (#294)', () => { it('re-serializing a widths-bearing table is idempotent (single marker, stable)', () => { const t: MarkdownTable = { ...parse(SIMPLE), colWidths: [120, 200] } diff --git a/packages/app-core/src/lib/markdown-table.ts b/packages/app-core/src/lib/markdown-table.ts index 4c944a3c..3dcccdd9 100644 --- a/packages/app-core/src/lib/markdown-table.ts +++ b/packages/app-core/src/lib/markdown-table.ts @@ -169,6 +169,21 @@ function normalizeRow(cells: string[], colCount: number): string[] { return out } +/** + * Parse a table block the way the editor delimits one: the pipe rows plus the + * optional trailing `` width marker (`tableBlockAt` and + * `tableRangeAt` in cm-table.ts extend their range over it). The marker + * becomes `colWidths` here; handed to `parseTable` it would read as a body row + * whose first cell is an HTML comment. + */ +export function parseTableBlock(block: string): MarkdownTable | null { + const lines = block.replace(/\n+$/, '').split('\n') + const widths = lines.length > 2 ? parseColWidthsComment(lines[lines.length - 1]) : null + const table = parseTable(widths ? lines.slice(0, -1).join('\n') : block) + if (table && widths) table.colWidths = widths + return table +} + // --------------------------------------------------------------------------- // Serialization (padded, Obsidian/prettier-ish) // --------------------------------------------------------------------------- diff --git a/packages/app-core/src/lib/note-editor-context.ts b/packages/app-core/src/lib/note-editor-context.ts index 3803a243..fdb9beb9 100644 --- a/packages/app-core/src/lib/note-editor-context.ts +++ b/packages/app-core/src/lib/note-editor-context.ts @@ -12,3 +12,8 @@ export function noteEditorMatches(view: EditorView, path: string, paneId: string const registered = noteEditors.get(view) return registered?.paneId === paneId && registered.path() === path } + +/** The note path a registered editor shows right now, or null. */ +export function noteEditorPath(view: EditorView): string | null { + return noteEditors.get(view)?.path() ?? null +} diff --git a/packages/app-core/src/lib/preview-task-body.test.ts b/packages/app-core/src/lib/preview-task-body.test.ts new file mode 100644 index 00000000..188813e1 --- /dev/null +++ b/packages/app-core/src/lib/preview-task-body.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import { renderMarkdown } from './markdown' +import { wrapTaskItemOwnText } from './preview-task-body' + +const fence = '```' +const stepOne = (box: string): string => + [ + `- [${box}] **1. Step one.** Open PowerShell and paste:`, + ` ${fence}powershell`, + ' Get-Date', + ` ${fence}`, + " Expected: today's date." + ].join('\n') + +/** The first task item of `md`, rendered and wrapped the way Preview does. */ +function wrappedTaskItem(md: string): HTMLLIElement { + const host = document.createElement('div') + host.innerHTML = renderMarkdown(md) + const li = host.querySelector('li.task-list-item') + if (!li) throw new Error('no task item rendered') + const checkbox = li.querySelector( + ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]' + ) + wrapTaskItemOwnText(li, checkbox) + return li +} + +function inSourceOrder(li: HTMLLIElement, ...parts: string[]): boolean { + const text = li.textContent ?? '' + const at = parts.map((part) => text.indexOf(part)) + return at.every((index, i) => index >= 0 && (i === 0 || index > at[i - 1])) +} + +describe('wrapTaskItemOwnText', () => { + it('keeps a loose item in source order and leaves its paragraphs to the CSS (#849)', () => { + const li = wrappedTaskItem(`${stepOne(' ')}\n\n- [ ] **2. Step two.** Text only\n`) + expect(inSourceOrder(li, 'Step one', 'Get-Date', 'Expected')).toBe(true) + expect(li.querySelector(':scope > .task-item-body')).toBeNull() + }) + + it("wraps a tight item's own text but never its code block", () => { + const li = wrappedTaskItem(`${stepOne('x')}\n- [ ] **2. Step two.** Text only\n`) + expect(inSourceOrder(li, 'Step one', 'Get-Date', 'Expected')).toBe(true) + const bodies = [...li.querySelectorAll(':scope > .task-item-body')].map((b) => b.textContent) + expect(bodies).toHaveLength(2) + expect(bodies[0]).toContain('Step one') + expect(bodies[1]).toContain('Expected') + expect(li.querySelector('pre')?.closest('.task-item-body')).toBeNull() + }) + + it('leaves a sub-list outside the wrapper (#512)', () => { + const li = wrappedTaskItem('- [x] Parent task\n - [ ] Open sub-task\n') + const body = li.querySelector(':scope > .task-item-body') + expect(body?.textContent).toContain('Parent task') + expect(body?.textContent).not.toContain('Open sub-task') + expect(li.querySelector(':scope > ul')).not.toBeNull() + }) + + it('keeps the state marker out of the wrapper', () => { + const li = wrappedTaskItem('- [/] Halfway there\n') + const marker = li.querySelector('.zen-task-state') + expect(marker).not.toBeNull() + expect(marker?.closest('.task-item-body')).toBeNull() + expect(li.querySelector(':scope > .task-item-body')?.textContent).toContain('Halfway there') + }) + + it('wraps once however often it runs', () => { + const li = wrappedTaskItem(`${stepOne('x')}\n- [ ] Next\n`) + wrapTaskItemOwnText(li, li.querySelector('input[type="checkbox"]')) + expect(li.querySelectorAll('.task-item-body')).toHaveLength(2) + }) +}) diff --git a/packages/app-core/src/lib/preview-task-body.ts b/packages/app-core/src/lib/preview-task-body.ts new file mode 100644 index 00000000..ea448f68 --- /dev/null +++ b/packages/app-core/src/lib/preview-task-body.ts @@ -0,0 +1,61 @@ +/** + * The wrapper Preview puts around a task item's own text, so done and + * cancelled styling (strike, gray) lands on that text and never on a nested + * sub-task (#512). The CSS styles `> .task-item-body` in tight items and + * `> p` in loose ones, whose text already sits in paragraphs. + * + * Only inline content is wrapped, one span per run, and every block child + * stays where it is. The wrapper used to take every child but the checkbox, + * paragraphs and sub-lists, and went in where the first of those sat: in a + * loose item that was the whitespace ahead of a code block, so the code block + * moved above the task's text (#849), and in a tight item a done task's code + * block was struck through with it, which a loose item never does. + */ + +const BLOCK_TAGS = new Set([ + 'P', + 'UL', + 'OL', + 'DIV', + 'PRE', + 'BLOCKQUOTE', + 'TABLE', + 'HR', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'DETAILS', + 'FIGURE', + 'DL' +]) + +export function wrapTaskItemOwnText(li: HTMLLIElement, checkbox: Element | null): void { + if (li.querySelector(':scope > .task-item-body')) return + let run: ChildNode[] = [] + const flush = (): void => { + const hasText = run.some( + (node) => node.nodeType !== Node.TEXT_NODE || (node.textContent ?? '').trim() !== '' + ) + if (hasText) { + const body = li.ownerDocument.createElement('span') + body.className = 'task-item-body' + li.insertBefore(body, run[0]) + for (const node of run) body.appendChild(node) + } + run = [] + } + for (const node of Array.from(li.childNodes)) { + // The checkbox and the state marker bound a run rather than join it: the + // marker sits in the gutter and must not be struck along with the text. + const el = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : null + if (node === checkbox || el?.classList.contains('zen-task-state') || (el && BLOCK_TAGS.has(el.tagName))) { + flush() + continue + } + run.push(node) + } + flush() +} diff --git a/packages/app-core/src/lib/rename-vault.ts b/packages/app-core/src/lib/rename-vault.ts new file mode 100644 index 00000000..0d7666c2 --- /dev/null +++ b/packages/app-core/src/lib/rename-vault.ts @@ -0,0 +1,51 @@ +/** + * Renaming a vault's display name (#692): the name the sidebar header, the + * vault switcher and the title bar use, kept in the vault's own vault.json so + * it follows the folder wherever it is opened, while the folder keeps its + * name on disk. The palette command and the sidebar's vault menu share this + * prompt; Settings has a field instead. + */ +import type { VaultInfo, WorkspaceMode } from '@shared/ipc' +import { promptApp } from './prompt-requests' +import { useStore } from '../store' + +/** The folder a vault lives in, as it is shown beside a display name: the + * root's last segment, POSIX or Windows. */ +export function vaultFolderName(root: string): string { + return root.split(/[\\/]/).filter(Boolean).pop() ?? root +} + +/** + * Whether the open vault can take a display name: a local vault that is not + * a temporary folder session (nothing is written into those) and not a remote + * workspace, whose settings belong to its server. + */ +export function canRenameVault(state: { + vault: VaultInfo | null + workspaceMode: WorkspaceMode +}): boolean { + return !!state.vault && !state.vault.temporary && state.workspaceMode !== 'remote' +} + +/** Ask for the vault's display name and save it. Resolves to false when the + * prompt was cancelled, the vault cannot be renamed, or the save failed. An + * empty answer is an answer: it takes the vault back to its folder name. */ +export async function renameVaultWithPrompt(): Promise { + const state = useStore.getState() + const vault = state.vault + if (!vault || !canRenameVault(state)) return false + const folder = vaultFolderName(vault.root) + const next = await promptApp({ + title: 'Rename vault', + description: `Shown in the sidebar and the vault switcher. The folder stays ${folder} on disk; leave the name empty to go back to it.`, + initialValue: state.vaultSettings.displayName ?? '', + placeholder: folder, + okLabel: 'Rename', + allowEmptySubmit: true + }) + if (next === null) return false + // The prompt outlived a vault switch: the answer was for the vault that + // asked, not whatever is open now. + if (useStore.getState().vault?.root !== vault.root) return false + return useStore.getState().renameVault(next) +} diff --git a/packages/app-core/src/lib/table-to-database.test.ts b/packages/app-core/src/lib/table-to-database.test.ts new file mode 100644 index 00000000..ad2e7fc6 --- /dev/null +++ b/packages/app-core/src/lib/table-to-database.test.ts @@ -0,0 +1,324 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { history } from '@codemirror/commands' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import type { DatabaseDoc } from '@shared/databases' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { tablePlugin } from './cm-table' +import { closeTableContextMenu, openTableContextMenu } from './cm-table-menu' +import { parseTable } from './markdown-table' +import { registerNoteEditor } from './note-editor-context' +import { getPromptRequest, settlePromptRequest } from './prompt-requests' +import { + convertTableToDatabase, + defaultDatabaseTitle, + headingAbove, + tableToSeed, + tableUnderCursor, + validateDatabaseTitle +} from './table-to-database' +import { useToastStore } from './toast' +import { useStore } from '../store' + +const NOTE = `# Weekly plan + +Some intro text. + +## Roadmap + +| Item | Done | Estimate | +| --- | --- | --- | +| Write spec, v1 | yes | 3 | +| Ship | no | 1 | + +Closing text.` + +function mount(doc: string, withWidgets: boolean): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage }), + history(), + ...(withWidgets ? [tablePlugin] : []) + ] + }) + }) + forceParsing(view, doc.length, 5000) + // Nudge the table field so it rebuilds against the completed parse. + view.dispatch({ changes: { from: 0, insert: ' ' } }) + view.dispatch({ changes: { from: 0, to: 1 } }) + return view +} + +const tableStart = (doc: string): number => doc.indexOf('| Item') + +const saved = { + notes: useStore.getState().notes, + activeNote: useStore.getState().activeNote, + createDatabase: useStore.getState().createDatabase, + openDatabase: useStore.getState().openDatabase, + vaultSettings: useStore.getState().vaultSettings +} + +afterEach(() => { + closeTableContextMenu() + useStore.setState(saved) + useToastStore.setState({ toasts: [] }) + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('tableUnderCursor', () => { + it('finds the raw pipe table around the caret when tables are plain text', () => { + const view = mount(NOTE, false) + view.dispatch({ selection: { anchor: NOTE.indexOf('Ship') } }) + const found = tableUnderCursor(view) + expect(found).not.toBeNull() + expect(found!.from).toBe(tableStart(NOTE)) + expect(found!.source).toBe( + '| Item | Done | Estimate |\n| --- | --- | --- |\n| Write spec, v1 | yes | 3 |\n| Ship | no | 1 |' + ) + expect(found!.table.headers).toEqual(['Item', 'Done', 'Estimate']) + view.destroy() + }) + + it('is null when the caret sits in prose next to the table', () => { + const view = mount(NOTE, false) + view.dispatch({ selection: { anchor: NOTE.indexOf('Closing') } }) + expect(tableUnderCursor(view)).toBeNull() + view.destroy() + }) + + it('resolves the rendered table whose cell holds focus, even after focus moves on', () => { + const view = mount(NOTE, true) + // The caret is far from the table; only the widget knows which one. + view.dispatch({ selection: { anchor: 2 } }) + const cell = view.dom.querySelector('.cm-table-cell[data-row="0"][data-col="0"]') + expect(cell).toBeTruthy() + cell!.focus() + expect(tableUnderCursor(view)?.from).toBe(tableStart(NOTE)) + + // Focus leaving for a modal (a palette input) keeps the memory... + const input = document.createElement('input') + document.body.append(input) + input.focus() + expect(tableUnderCursor(view)?.from).toBe(tableStart(NOTE)) + + // ...until the caret is back in the note text, which is then the authority. + view.focus() + view.contentDOM.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(tableUnderCursor(view)).toBeNull() + view.destroy() + }) +}) + +describe('defaultDatabaseTitle / headingAbove', () => { + it('takes the nearest heading above the table', () => { + const view = mount(NOTE, false) + expect(headingAbove(view.state, tableStart(NOTE))).toBe('Roadmap') + expect(defaultDatabaseTitle(view.state, tableStart(NOTE), 'Weekly plan')).toBe('Roadmap') + view.destroy() + }) + + it('skips a # comment inside a fenced code block', () => { + const doc = '## Setup\n\n```bash\n# install deps\nnpm i\n```\n\n| A |\n| --- |\n| 1 |' + const view = mount(doc, false) + expect(headingAbove(view.state, doc.indexOf('| A |'))).toBe('Setup') + view.destroy() + }) + + it('falls back to " table" and strips what a link cannot carry', () => { + const doc = '| A |\n| --- |\n| 1 |' + const view = mount(doc, false) + expect(defaultDatabaseTitle(view.state, 0, 'Meeting notes')).toBe('Meeting notes table') + expect(defaultDatabaseTitle(view.state, 0, '')).toBe('Table') + view.destroy() + const headed = mount('## **Q3** [[Goals]] #work\n\n| A |\n| --- |', false) + expect(defaultDatabaseTitle(headed.state, headed.state.doc.toString().indexOf('| A'), 'x')).toBe( + 'Q3 Goals work' + ) + headed.destroy() + }) +}) + +describe('validateDatabaseTitle', () => { + it('rejects names the filesystem or a [[wikilink]] cannot carry', () => { + expect(validateDatabaseTitle('')).toMatch(/Enter a database name/) + expect(validateDatabaseTitle('.hidden')).toMatch(/dot/) + expect(validateDatabaseTitle('a/b')).toMatch(/\[\[link\]\]/) + expect(validateDatabaseTitle('C# notes')).toMatch(/\[\[link\]\]/) + expect(validateDatabaseTitle('a|b')).toMatch(/\[\[link\]\]/) + expect(validateDatabaseTitle('Roadmap 2026')).toBeNull() + expect(validateDatabaseTitle('Q3: goals?')).toBeNull() + }) +}) + +describe('tableToSeed', () => { + it('copies headers and rows verbatim and carries column widths when present', () => { + const table = parseTable('| A | B |\n| --- | :-: |\n| x, y | **b** |')! + expect(tableToSeed(table)).toEqual({ headers: ['A', 'B'], rows: [['x, y', '**b**']] }) + expect(tableToSeed({ ...table, colWidths: [100, null] })).toEqual({ + headers: ['A', 'B'], + rows: [['x, y', '**b**']], + columnWidths: [100, null] + }) + }) +}) + +describe('convertTableToDatabase', () => { + const NOTE_PATH = 'inbox/Weekly plan.md' + + function setup(doc: string, withWidgets = false) { + const view = mount(doc, withWidgets) + registerNoteEditor(view, () => NOTE_PATH, useStore.getState().activePaneId) + const created: DatabaseDoc = { + version: 1, + path: 'inbox/Roadmap 2.base/data.csv', + title: 'Roadmap 2', + idFieldId: 'f-id', + fields: [{ id: 'f-id', name: 'id', type: 'text', hidden: true }], + views: [], + activeViewId: 'v1', + rows: [] + } + const createDatabase = vi.fn(async () => created) + const openDatabase = vi.fn(async () => {}) + useStore.setState({ + activeNote: { path: NOTE_PATH, title: 'Weekly plan', folder: 'inbox' }, + notes: [{ path: NOTE_PATH, title: 'Weekly plan', folder: 'inbox' }], + createDatabase, + openDatabase + } as never) + return { view, created, createDatabase, openDatabase } + } + + const flush = () => new Promise((r) => setTimeout(r, 0)) + + it('prompts with the heading as the name, creates the seeded database, and leaves a link', async () => { + const { view, createDatabase, openDatabase } = setup(NOTE) + view.dispatch({ selection: { anchor: NOTE.indexOf('Ship') } }) + const run = convertTableToDatabase(view) + await flush() + const prompt = getPromptRequest() + expect(prompt).not.toBeNull() + expect(prompt!.options.initialValue).toBe('Roadmap') + expect(prompt!.options.description).toContain('3 columns and 2 rows') + settlePromptRequest(prompt!, 'Roadmap') + await run + + expect(createDatabase).toHaveBeenCalledWith('inbox', '', 'Roadmap', undefined, { + seed: { + headers: ['Item', 'Done', 'Estimate'], + rows: [ + ['Write spec, v1', 'yes', '3'], + ['Ship', 'no', '1'] + ] + }, + open: false + }) + // The link carries the title the vault actually gave the database. + const text = view.state.doc.toString() + expect(text).toBe(NOTE.replace(/\| Item[\s\S]*\| 1 \|/, '[[Roadmap 2]]')) + expect(view.state.selection.main.head).toBe(tableStart(NOTE)) + // A normal edit: undo brings the table back. + const { undo } = await import('@codemirror/commands') + undo(view) + expect(view.state.doc.toString()).toBe(NOTE) + + const toast = useToastStore.getState().toasts.at(-1) + expect(toast?.type).toBe('success') + expect(toast?.message).toContain('Roadmap 2') + toast?.action?.onClick() + expect(openDatabase).toHaveBeenCalledWith('inbox/Roadmap 2.base/data.csv') + view.destroy() + }) + + it('converts the rendered table whose cell was focused (the widget menu and leader paths)', async () => { + const { view, createDatabase } = setup(NOTE, true) + const cell = view.dom.querySelector('.cm-table-cell[data-row="1"][data-col="1"]') + cell!.focus() + const run = convertTableToDatabase(view) + await flush() + settlePromptRequest(getPromptRequest()!, 'Roadmap') + await run + expect(createDatabase).toHaveBeenCalledTimes(1) + expect(view.state.doc.toString()).toContain('## Roadmap\n\n[[Roadmap 2]]\n\nClosing text.') + expect(view.dom.querySelector('.cm-table-widget')).toBeNull() + view.destroy() + }) + + it('does nothing when the prompt is cancelled', async () => { + const { view, createDatabase } = setup(NOTE) + view.dispatch({ selection: { anchor: NOTE.indexOf('Ship') } }) + const run = convertTableToDatabase(view) + await flush() + settlePromptRequest(getPromptRequest()!, null) + await run + expect(createDatabase).not.toHaveBeenCalled() + expect(view.state.doc.toString()).toBe(NOTE) + view.destroy() + }) + + it('says so instead of guessing when the caret is not in a table', async () => { + const { view, createDatabase } = setup(NOTE) + view.dispatch({ selection: { anchor: NOTE.indexOf('Closing') } }) + await convertTableToDatabase(view) + expect(getPromptRequest()).toBeNull() + expect(createDatabase).not.toHaveBeenCalled() + expect(useToastStore.getState().toasts.at(-1)?.message).toMatch(/Put the cursor in a table/) + view.destroy() + }) + + it('leaves the note alone when the table moved while the prompt was up', async () => { + const { view, createDatabase } = setup(NOTE) + view.dispatch({ selection: { anchor: NOTE.indexOf('Ship') } }) + const run = convertTableToDatabase(view) + await flush() + // An external sync lands a paragraph above the table before the user confirms. + view.dispatch({ changes: { from: 0, insert: 'Prepended line\n\n' } }) + settlePromptRequest(getPromptRequest()!, 'Roadmap') + await run + expect(createDatabase).toHaveBeenCalledTimes(1) + expect(view.state.doc.toString()).toContain('| Ship | no | 1 |') + expect(view.state.doc.toString()).not.toContain('[[Roadmap') + expect(useToastStore.getState().toasts.at(-1)?.message).toMatch(/left in place/) + view.destroy() + }) + + it('stops quietly when the store refused to create the database', async () => { + const { view, createDatabase } = setup(NOTE) + createDatabase.mockResolvedValueOnce(undefined as never) + view.dispatch({ selection: { anchor: NOTE.indexOf('Ship') } }) + const run = convertTableToDatabase(view) + await flush() + settlePromptRequest(getPromptRequest()!, 'Roadmap') + await run + expect(view.state.doc.toString()).toBe(NOTE) + view.destroy() + }) +}) + +describe('table context menu entry', () => { + const model = parseTable('| A |\n| --- |\n| 1 |')! + + it('offers Convert to database… only when the widget wires it', () => { + const convert = vi.fn() + openTableContextMenu({ x: 0, y: 0, row: 0, col: 0, model, apply: () => {}, convertToDatabase: convert }) + const labels = [...document.querySelectorAll('.cm-table-menu-item')].map((b) => b.textContent) + expect(labels.at(-1)).toBe('Convert to database…') + ;(document.querySelectorAll('.cm-table-menu-item')[labels.length - 1]).click() + expect(convert).toHaveBeenCalledTimes(1) + closeTableContextMenu() + + openTableContextMenu({ x: 0, y: 0, row: 0, col: 0, model, apply: () => {} }) + const plain = [...document.querySelectorAll('.cm-table-menu-item')].map((b) => b.textContent) + expect(plain).not.toContain('Convert to database…') + }) +}) diff --git a/packages/app-core/src/lib/table-to-database.ts b/packages/app-core/src/lib/table-to-database.ts new file mode 100644 index 00000000..e9a4d2cd --- /dev/null +++ b/packages/app-core/src/lib/table-to-database.ts @@ -0,0 +1,214 @@ +/** + * Convert the Markdown table under the cursor into a `.base` database (#832). + * + * A note often starts with a small pipe table that outgrows inline editing. + * This takes that table, creates a database from it at the configured + * databases location, and leaves a `[[Database]]` wikilink where the table + * stood, the same link the `[[` picker inserts for a database, so the note + * keeps pointing at its data and the grid is one `gd` / click away. The + * columns are typed the way an adopted CSV would be (number, checkbox, date, + * text), cell text travels verbatim (inline markdown included; the CSV layer + * does the RFC 4180 quoting), and column alignment is dropped because a grid + * has none. Column widths from a `zen:cols` marker become field widths. + * + * The replacement is an ordinary editor transaction: autosaved by the pane's + * update listener and one `u` away. The database itself stays; undo restores + * the table, it does not delete files. + */ +import { syntaxTree } from '@codemirror/language' +import type { EditorState } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import type { DatabaseSeed } from '@shared/databases' +import type { NoteMeta } from '@shared/ipc' +import { focusedTableAnchor, tableRangeAt } from './cm-table' +import { parseTableBlock, type MarkdownTable } from './markdown-table' +import { noteEditorPath } from './note-editor-context' +import { promptApp } from './prompt-requests' +import { useToastStore } from './toast' +import { normalizeVaultSettings, resolveCreateLocation } from './vault-layout' +import { useStore } from '../store' + +export interface TableUnderCursor { + from: number + to: number + /** The exact source the range covers, so the replacement can confirm the + * table is still there after the prompt and the write. */ + source: string + table: MarkdownTable +} + +function tableAt(view: EditorView, pos: number): TableUnderCursor | null { + const range = tableRangeAt(view, pos) + if (!range) return null + const source = view.state.sliceDoc(range.from, range.to) + const table = parseTableBlock(source) + if (!table) return null + return { from: range.from, to: range.to, source, table } +} + +/** + * The table the user is working in. When tables render as widgets the caret + * cannot be inside one, so a cell that holds focus (or held it last, before + * the palette took over) wins; otherwise the table around the caret, which + * is how raw pipe tables (Split mode, live tables off) are found. + */ +export function tableUnderCursor(view: EditorView): TableUnderCursor | null { + const focused = focusedTableAnchor(view) + if (focused != null) { + const found = tableAt(view, focused) + if (found) return found + } + return tableAt(view, view.state.selection.main.head) +} + +const HEADING_LINE_RE = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/ + +/** + * The nearest heading above `pos`, its markers stripped. Read from the + * syntax tree where it has parsed that far (a `# comment` inside a fenced code + * block is not a heading), by line scan otherwise. + */ +export function headingAbove(state: EditorState, pos: number): string | null { + const tree = syntaxTree(state) + if (tree.length >= pos) { + const last: { range: { from: number; to: number } | null } = { range: null } + tree.iterate({ + from: 0, + to: pos, + enter: (node) => { + if (/^ATXHeading[1-6]$/.test(node.name) && node.to <= pos) { + last.range = { from: node.from, to: node.to } + } + } + }) + if (!last.range) return null + const m = HEADING_LINE_RE.exec(state.sliceDoc(last.range.from, last.range.to)) + return m ? m[1].trim() || null : null + } + const doc = state.doc + for (let n = doc.lineAt(pos).number - 1; n >= 1; n--) { + const m = HEADING_LINE_RE.exec(doc.line(n).text) + if (m) return m[1].trim() || null + } + return null +} + +/** Characters a database name cannot carry and still be a filesystem name + * AND a `[[wikilink]]` target: `[`/`]` close the link, `#` and `^` read as + * anchors, `|` as display text, and the slashes as directories. */ +const UNLINKABLE_RE = /[[\]#^|/\\]/ + +/** Reduce heading text to something that can name a database: inline + * emphasis markers and the characters a link cannot carry become spaces. */ +function nameFromHeading(text: string): string { + return text + .replace(/[*_~`]+/g, '') + .replace(/[[\]#^|/\\]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +/** The prefilled name: the nearest heading above the table, else the note's + * title followed by "table". */ +export function defaultDatabaseTitle( + state: EditorState, + tableFrom: number, + noteTitle: string +): string { + const heading = headingAbove(state, tableFrom) + const fromHeading = heading ? nameFromHeading(heading) : '' + if (fromHeading) return fromHeading + const base = nameFromHeading(noteTitle) + return base ? `${base} table` : 'Table' +} + +export function validateDatabaseTitle(value: string): string | null { + const name = value.trim() + if (!name) return 'Enter a database name.' + if (name.startsWith('.')) return 'Database names cannot start with a dot.' + if (/[\u0000-\u001f]/.test(name)) return 'Enter a database name without control characters.' + if (UNLINKABLE_RE.test(name)) return 'A database name cannot contain [ ] # ^ | / or \\, so its [[link]] can find it.' + return null +} + +export function tableToSeed(table: MarkdownTable): DatabaseSeed { + return { + headers: [...table.headers], + rows: table.rows.map((row) => [...row]), + ...(table.colWidths ? { columnWidths: [...table.colWidths] } : {}) + } +} + +/** The note an editor shows, for placement and the post-write guard. */ +function noteForEditor(view: EditorView): NoteMeta | null { + const path = noteEditorPath(view) + if (!path) return null + const state = useStore.getState() + if (state.activeNote?.path === path) return state.activeNote + return state.notes.find((n) => n.path === path) ?? null +} + +/** + * Prompt for a name, create the database from the table at `anchor` (or under + * the cursor), and swap the table for a link to it. Reports through toasts; + * never throws to the caller. + */ +export async function convertTableToDatabase(view: EditorView, anchor?: number): Promise { + const toast = useToastStore.getState().addToast + const note = noteForEditor(view) + if (!note) { + toast('Open a note to convert its table.', 'info') + return + } + const found = anchor == null ? tableUnderCursor(view) : tableAt(view, anchor) + if (!found) { + toast('Put the cursor in a table to convert it.', 'info') + return + } + const rowCount = found.table.rows.length + const rowsLabel = rowCount === 1 ? '1 row' : `${rowCount} rows` + const answer = await promptApp({ + title: 'Convert table to database', + description: `Creates a database from the table's ${found.table.headers.length} columns and ${rowsLabel}, and replaces the table with a [[link]] to it.`, + initialValue: defaultDatabaseTitle(view.state, found.from, note.title), + placeholder: 'Database name', + okLabel: 'Convert', + validate: validateDatabaseTitle + }) + const title = answer?.trim() + if (!title || validateDatabaseTitle(title)) return + + const state = useStore.getState() + const settings = normalizeVaultSettings(state.vaultSettings) + const { folder, subpath } = resolveCreateLocation(settings.databasesLocation, note, settings) + const doc = await state.createDatabase(folder, subpath, title, undefined, { + seed: tableToSeed(found.table), + open: false + }) + if (!doc) return + + // The prompt and the write took time. Only touch the note if this editor + // still shows it and the table is still exactly where it was; otherwise the + // database exists and the table stays, which is recoverable, unlike a + // replacement landing on the wrong text. + const intact = + view.dom.isConnected && + noteEditorPath(view) === note.path && + view.state.sliceDoc(found.from, found.to) === found.source + const openDatabase = { label: 'Open', onClick: () => void useStore.getState().openDatabase(doc.path) } + if (!intact) { + toast( + `Created database "${doc.title}", but the note changed meanwhile, so the table was left in place.`, + 'info', + openDatabase + ) + return + } + const link = `[[${doc.title}]]` + view.dispatch({ + changes: { from: found.from, to: found.to, insert: link }, + selection: { anchor: found.from } + }) + view.focus() + toast(`Converted the table to database "${doc.title}"`, 'success', openDatabase) +} diff --git a/packages/app-core/src/lib/vault-layout.test.ts b/packages/app-core/src/lib/vault-layout.test.ts index 468fb7ba..b2db4704 100644 --- a/packages/app-core/src/lib/vault-layout.test.ts +++ b/packages/app-core/src/lib/vault-layout.test.ts @@ -14,6 +14,7 @@ import { noteFolderSubpath, normalizeVaultSettings, resolveCreateLocation, + specificFolderDestination, parseFavoriteFolderKey, removeFavoritesForFolder, resolveFavoriteItems, @@ -787,6 +788,32 @@ describe('resolveCreateLocation (#362)', () => { }) }) +describe('specificFolderDestination', () => { + const inboxVault = normalizeVaultSettings(null) + const rootVault = normalizeVaultSettings({ primaryNotesLocation: 'root' } as VaultSettings) + + it('nests the folder inside the inbox of an Inbox vault, not at the vault root', () => { + expect(specificFolderDestination('Tasks', inboxVault)).toBe('inbox/Tasks') + expect(specificFolderDestination('/Projects/Inbox/', inboxVault)).toBe('inbox/Projects/Inbox') + }) + + it('is the vault-root folder in a root vault', () => { + expect(specificFolderDestination('Tasks', rootVault)).toBe('Tasks') + }) + + it('follows a remapped inbox', () => { + const remapped = normalizeVaultSettings({ + systemFolderPaths: { inbox: '01 - Entry' } + } as unknown as VaultSettings) + expect(specificFolderDestination('Tasks', remapped)).toBe('01 - Entry/Tasks') + }) + + it('is the primary notes root while no folder is named', () => { + expect(specificFolderDestination('', inboxVault)).toBe('inbox') + expect(specificFolderDestination(undefined, rootVault)).toBe('') + }) +}) + describe('normalizeVaultSettings drawings/databases location (#362)', () => { it('fills defaults and normalizes a folder value', () => { const n = normalizeVaultSettings(null) diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index 388def97..722b8b67 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -28,6 +28,7 @@ import { import { normalizeTasksExcludedFolders } from '@shared/tasks-excluded-folders' import { normalizeTypstPreambleSettings } from '@shared/typst-preamble-folder' import { normalizeHarperVaultState } from '@shared/harper-settings' +import { normalizeVaultDisplayName } from '@shared/vault-display-name' import { getISOWeek, getISOWeekYear, mondayOfISOWeek } from './template-render' // Reserved however the system folders are remapped: @@ -526,7 +527,7 @@ function normalizeFileLocation( } /** - * Resolve where a new Drawing/Database should be created, from a + * Resolve where a new Drawing/Database/task file should be created, from a * `FileLocationSetting` + the note you're currently viewing. Returns the * `(folder, subpath)` pair the `createExcalidraw` / `createDatabase` bridge calls * expect. (#362) @@ -551,6 +552,21 @@ export function resolveCreateLocation( return { folder: 'inbox', subpath: '' } } +/** + * The vault-relative directory a `Specific folder` location really creates + * files in ('' is the vault root), so Settings can spell it out. The folder is + * an inbox subpath, not a vault path: `Tasks` is `inbox/Tasks` in an Inbox + * vault, `Tasks` in a root vault, and follows a remapped inbox. The Settings + * field used to call it vault-relative, which only held in a root vault. + */ +export function specificFolderDestination( + folder: string | null | undefined, + settings: VaultSettings | null | undefined +): string { + const location = resolveCreateLocation({ mode: 'folder', folder: folder ?? '' }, null, settings) + return vaultRelativeFolderPath(location.folder, location.subpath, settings) +} + /** * Whether the calendar side panel can be toggled in the current context. The * calendar navigates daily/weekly notes relative to the active note, so it @@ -603,6 +619,7 @@ export function normalizeVaultSettings( ) const normalizedTypstPreambles = normalizeTypstPreambleSettings(settings?.typstPreambles) const normalizedHarper = normalizeHarperVaultState(settings?.harper) + const displayName = normalizeVaultDisplayName(settings?.displayName) const primaryNotesLocation = settings?.primaryNotesLocation === 'root' ? 'root' @@ -673,7 +690,8 @@ export function normalizeVaultSettings( ? { tasks: { excludedFolders: normalizedTasksExcluded } } : {}), ...(normalizedTypstPreambles ? { typstPreambles: normalizedTypstPreambles } : {}), - ...(normalizedHarper ? { harper: normalizedHarper } : {}) + ...(normalizedHarper ? { harper: normalizedHarper } : {}), + ...(displayName ? { displayName } : {}) } } diff --git a/packages/app-core/src/lib/vault-settings-conflict.ts b/packages/app-core/src/lib/vault-settings-conflict.ts index 0b922606..e10d898c 100644 --- a/packages/app-core/src/lib/vault-settings-conflict.ts +++ b/packages/app-core/src/lib/vault-settings-conflict.ts @@ -16,6 +16,7 @@ import { normalizeVaultSettings } from "./vault-layout"; */ export const VAULT_SETTINGS_SECTION_LABELS: Record = { + displayName: "Vault name", primaryNotesLocation: "Primary notes location", dailyNotes: "Daily notes", weeklyNotes: "Weekly notes", diff --git a/packages/app-core/src/rename-vault.test.ts b/packages/app-core/src/rename-vault.test.ts new file mode 100644 index 00000000..1a6fa410 --- /dev/null +++ b/packages/app-core/src/rename-vault.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_VAULT_SETTINGS, type VaultSettings } from '@shared/ipc' + +// A vault's display name (#692): saved into vault.json through the ordinary +// settings write, reflected on the open vault without treating it as a +// different vault, and offered only where a vault.json can be written. + +function installZen(overrides: Record = {}): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop', version: 'test' }), + getCapabilities: vi.fn().mockReturnValue({ + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false + }), + scanTasks: vi.fn().mockResolvedValue([]), + scanTasksForPath: vi.fn().mockResolvedValue([]), + listNotes: vi.fn().mockResolvedValue([]), + listFolders: vi.fn().mockResolvedValue([]), + listAssets: vi.fn().mockResolvedValue([]), + hasAssetsDir: vi.fn().mockResolvedValue(false), + listLocalVaults: vi.fn().mockResolvedValue([]), + getRemoteWorkspaceInfo: vi.fn().mockResolvedValue(null), + getVaultSettings: vi.fn().mockResolvedValue({}), + // Echo the save back the way main does: normalized by the bridge, so a + // blank name comes back absent. + setVaultSettings: vi.fn().mockImplementation(async (settings: VaultSettings) => { + const { displayName, ...rest } = settings + const trimmed = displayName?.trim() + return trimmed ? { ...rest, displayName: trimmed } : rest + }), + writeDatabaseRows: vi.fn().mockResolvedValue(undefined), + writeDatabaseSchema: vi.fn().mockResolvedValue(undefined), + ...overrides + } + }) +} + +async function loadStore() { + vi.resetModules() + localStorage.clear() + const { useStore } = await import('./store') + useStore.setState({ + vault: { root: '/Users/me/repos/acme/docs', name: 'docs' }, + workspaceMode: 'local', + vaultSettings: DEFAULT_VAULT_SETTINGS, + localVaults: [{ root: '/Users/me/repos/acme/docs', name: 'docs', lastOpenedAt: 1 }] + } as never) + return useStore +} + +beforeEach(() => { + vi.restoreAllMocks() + installZen() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('store.renameVault', () => { + it('saves the name into vault.json, renames the open vault in place, and refreshes the remembered list', async () => { + const useStore = await loadStore() + const listLocalVaults = vi.fn().mockResolvedValue([ + { root: '/Users/me/repos/acme/docs', name: 'Acme API docs', lastOpenedAt: 1 } + ]) + ;(window.zen as unknown as { listLocalVaults: unknown }).listLocalVaults = listLocalVaults + useStore.setState({ closedTabStack: [{ path: 'inbox/A.md' }] } as never) + + expect(await useStore.getState().renameVault(' Acme API docs ')).toBe(true) + + expect(window.zen.setVaultSettings).toHaveBeenCalledWith( + expect.objectContaining({ displayName: 'Acme API docs' }) + ) + expect(useStore.getState().vaultSettings.displayName).toBe('Acme API docs') + expect(useStore.getState().vault?.name).toBe('Acme API docs') + expect(useStore.getState().vault?.root).toBe('/Users/me/repos/acme/docs') + // The same vault: what it was holding is still there. + expect(useStore.getState().closedTabStack).toHaveLength(1) + expect(listLocalVaults).toHaveBeenCalled() + expect(useStore.getState().localVaults[0].name).toBe('Acme API docs') + }) + + it('an empty name goes back to the folder name and leaves vault.json without the key', async () => { + const useStore = await loadStore() + useStore.setState({ + vault: { root: '/Users/me/repos/acme/docs', name: 'Acme API docs' }, + vaultSettings: { ...DEFAULT_VAULT_SETTINGS, displayName: 'Acme API docs' } + } as never) + + expect(await useStore.getState().renameVault(' ')).toBe(true) + + const saved = (window.zen.setVaultSettings as ReturnType).mock.calls[0][0] + expect('displayName' in saved).toBe(false) + expect(useStore.getState().vaultSettings.displayName).toBeUndefined() + expect(useStore.getState().vault?.name).toBe('docs') + }) + + it('does not write when the name is unchanged, and refuses a temporary folder session', async () => { + const useStore = await loadStore() + useStore.setState({ vaultSettings: { ...DEFAULT_VAULT_SETTINGS, displayName: 'Acme API docs' } }) + expect(await useStore.getState().renameVault('Acme API docs')).toBe(true) + expect(window.zen.setVaultSettings).not.toHaveBeenCalled() + + useStore.setState({ vault: { root: '/tmp/dropped', name: 'dropped', temporary: true } }) + expect(await useStore.getState().renameVault('Anything')).toBe(false) + expect(window.zen.setVaultSettings).not.toHaveBeenCalled() + }) + + it('reports a failed save and keeps the old name', async () => { + const useStore = await loadStore() + ;(window.zen.setVaultSettings as ReturnType).mockRejectedValueOnce(new Error('read-only')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(await useStore.getState().renameVault('Acme API docs')).toBe(false) + expect(useStore.getState().vault?.name).toBe('docs') + errorSpy.mockRestore() + }) + + it('setVault treats a different name on the same root as the same vault', async () => { + const useStore = await loadStore() + useStore.setState({ closedTabStack: [{ path: 'inbox/A.md' }] } as never) + useStore.getState().setVault({ root: '/Users/me/repos/acme/docs', name: 'Acme API docs' }) + expect(useStore.getState().closedTabStack).toHaveLength(1) + useStore.getState().setVault({ root: '/Users/me/other', name: 'other' }) + expect(useStore.getState().closedTabStack).toHaveLength(0) + }) + + it('an external vault.json change renames the open vault too', async () => { + const useStore = await loadStore() + ;(window.zen.getVaultSettings as ReturnType).mockResolvedValue({ + ...DEFAULT_VAULT_SETTINGS, + displayName: 'Renamed elsewhere' + }) + await useStore.getState().applyChange({ kind: 'change', path: '.zennotes/vault.json', folder: 'inbox', scope: 'vault-settings' } as never) + expect(useStore.getState().vault?.name).toBe('Renamed elsewhere') + }) +}) + +describe('renameVaultWithPrompt', () => { + it('prefills the current name, names the folder, and saves the answer', async () => { + const useStore = await loadStore() + useStore.setState({ vaultSettings: { ...DEFAULT_VAULT_SETTINGS, displayName: 'Old name' } }) + const { renameVaultWithPrompt } = await import('./lib/rename-vault') + const prompts = await import('./lib/prompt-requests') + const renameVault = vi.fn(async () => true) + useStore.setState({ renameVault } as never) + + const run = renameVaultWithPrompt() + await new Promise((r) => setTimeout(r, 0)) + const request = prompts.getPromptRequest() + expect(request?.options.initialValue).toBe('Old name') + expect(request?.options.placeholder).toBe('docs') + expect(request?.options.description).toContain('The folder stays docs on disk') + expect(request?.options.allowEmptySubmit).toBe(true) + prompts.settlePromptRequest(request!, 'Acme API docs') + expect(await run).toBe(true) + expect(renameVault).toHaveBeenCalledWith('Acme API docs') + }) + + it('a cancelled prompt saves nothing; an emptied one clears the name', async () => { + const useStore = await loadStore() + const { renameVaultWithPrompt } = await import('./lib/rename-vault') + const prompts = await import('./lib/prompt-requests') + const renameVault = vi.fn(async () => true) + useStore.setState({ renameVault } as never) + + let run = renameVaultWithPrompt() + await new Promise((r) => setTimeout(r, 0)) + prompts.settlePromptRequest(prompts.getPromptRequest()!, null) + expect(await run).toBe(false) + expect(renameVault).not.toHaveBeenCalled() + + run = renameVaultWithPrompt() + await new Promise((r) => setTimeout(r, 0)) + prompts.settlePromptRequest(prompts.getPromptRequest()!, '') + expect(await run).toBe(true) + expect(renameVault).toHaveBeenCalledWith('') + }) + + it('is unavailable for a temporary folder session and a remote workspace', async () => { + await loadStore() + const { canRenameVault } = await import('./lib/rename-vault') + expect(canRenameVault({ vault: { root: '/v', name: 'v' }, workspaceMode: 'local' })).toBe(true) + expect(canRenameVault({ vault: { root: '/v', name: 'v', temporary: true }, workspaceMode: 'local' })).toBe(false) + expect(canRenameVault({ vault: { root: '/v', name: 'v' }, workspaceMode: 'remote' })).toBe(false) + expect(canRenameVault({ vault: null, workspaceMode: 'local' })).toBe(false) + }) +}) + +describe('the palette', () => { + it('lists Rename Vault… for a local vault only', async () => { + const useStore = await loadStore() + const { buildCommands } = await import('./lib/commands') + const ids = () => buildCommands().map((c) => c.id) + expect(ids()).toContain('vault.rename') + useStore.setState({ workspaceMode: 'remote' }) + expect(ids()).not.toContain('vault.rename') + useStore.setState({ workspaceMode: 'local', vault: { root: '/tmp/x', name: 'x', temporary: true } }) + expect(ids()).not.toContain('vault.rename') + }) +}) diff --git a/packages/app-core/src/store-note-integrity.test.ts b/packages/app-core/src/store-note-integrity.test.ts index 57318d63..7b2aa106 100644 --- a/packages/app-core/src/store-note-integrity.test.ts +++ b/packages/app-core/src/store-note-integrity.test.ts @@ -195,6 +195,79 @@ describe('#202 — store keeps each note its own content during navigation', () }) }) +// #852: the editor tells a body that came from disk apart from one another +// pane or a rename rewrite produced by the note's disk revision, which only a +// read from disk moves. +describe('#852: a note body taken from disk moves its disk revision', () => { + const a = 'Work/Documentation/Vault CLI Cheatsheet.md' + + it('an external change to an open, clean note bumps it once', async () => { + const { useStore, noteDiskRevision } = await loadStore() + seedRootVault(useStore) + await useStore.getState().openNoteInPane(useStore.getState().activePaneId, a) + await flush() + expect(noteDiskRevision(a)).toBe(0) + + vault.set(a, 'Hello, changed outside') + await useStore.getState().applyChange({ kind: 'change', path: a, folder: 'inbox' }) + await flush() + expect(useStore.getState().noteContents[a]?.body).toBe('Hello, changed outside') + expect(noteDiskRevision(a)).toBe(1) + }) + + it("the app's own save echo and the user's typing leave it alone", async () => { + const { useStore, noteDiskRevision } = await loadStore() + seedRootVault(useStore) + await useStore.getState().openNoteInPane(useStore.getState().activePaneId, a) + await flush() + + // Typing is an in-app change: no disk read, no revision. + useStore.getState().updateNoteBody(a, 'CLI_BODY typed') + await useStore.getState().persistNote(a) + expect(vault.get(a)).toBe('CLI_BODY typed') + expect(noteDiskRevision(a)).toBe(0) + + // The watcher echoing that save reads the same bytes the buffer holds. + await useStore.getState().applyChange({ kind: 'change', path: a, folder: 'inbox' }) + await flush() + expect(noteDiskRevision(a)).toBe(0) + }) + + it('a rewrite the app made itself (an asset rename) is read back without moving it', async () => { + const { useStore, noteDiskRevision } = await loadStore() + seedRootVault(useStore) + await useStore.getState().openNoteInPane(useStore.getState().activePaneId, a) + await flush() + + vault.set(a, 'CLI_BODY with ![](renamed.png)') + await useStore.getState().applyChange({ kind: 'change', path: a, folder: 'inbox' }, { source: 'app' }) + await flush() + expect(useStore.getState().noteContents[a]?.body).toBe('CLI_BODY with ![](renamed.png)') + expect(noteDiskRevision(a)).toBe(0) + }) + + it('a change refused because the buffer is dirty does not count as one', async () => { + const { useStore, noteDiskRevision } = await loadStore() + seedRootVault(useStore) + await useStore.getState().openNoteInPane(useStore.getState().activePaneId, a) + await flush() + useStore.getState().updateNoteBody(a, 'unsaved typing') + + vault.set(a, 'Hello, changed outside') + await useStore.getState().applyChange({ kind: 'change', path: a, folder: 'inbox' }) + await flush() + expect(useStore.getState().noteContents[a]?.body).toBe('unsaved typing') + expect(noteDiskRevision(a)).toBe(0) + + // The typing above armed the debounced save; settle it here rather than + // let it fire into a later test. The user's own write reaching disk is + // not a disk change either. + await useStore.getState().persistNote(a) + expect(vault.get(a)).toBe('unsaved typing') + expect(noteDiskRevision(a)).toBe(0) + }) +}) + // #585 ("ZenNotes clears all text from a note while editing"): the watcher // echo of one save could read the file while the next non-atomic save had it // truncated. applyChange pushed that empty read over the DIRTY buffer, the diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 87c739d3..9c47f145 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -11,6 +11,7 @@ import { type EditorCursorPosition } from './lib/editor-cursor-position' import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' +import { normalizeVaultDisplayName, resolveVaultName } from '@shared/vault-display-name' import { DEFAULT_HARPER_DIALECT, isHarperDialect, @@ -67,7 +68,7 @@ import { taskFilePriorityValue, updateFrontmatterFields } from '@shared/frontmatter' -import type { DatabaseDoc, DatabaseSidecar } from '@shared/databases' +import type { DatabaseDoc, DatabaseSeed, DatabaseSidecar } from '@shared/databases' import { databaseTabPath, csvPathFromDatabaseTab, @@ -1747,6 +1748,13 @@ export interface ConnectionPreviewState { anchorRect: PreviewAnchorRect } +export interface CreateDatabaseOptions { + /** Initial columns and rows (a converted Markdown table, #832). */ + seed?: DatabaseSeed + /** Open the new grid in the active pane. Default true. */ + open?: boolean +} + function getVisiblePreviewScrollElement(): HTMLElement | null { if (typeof document === 'undefined') return null return [...document.querySelectorAll('[data-preview-scroll]')].find( @@ -3241,6 +3249,14 @@ interface Store { * prompt reads the flag so it does not discard the cloud's copy after a * save that never happened. */ setVaultSettings: (next: VaultSettings) => Promise + /** + * Give the open vault a display name (#692), kept in its vault.json so it + * travels with the folder; empty or whitespace goes back to the folder's + * own name. Local vaults only: a temporary folder session writes nothing. + * Resolves true once saved; the header, switcher and remembered-vaults + * list follow without a reopen. + */ + renameVault: (name: string | null) => Promise /** * Toggle a favorite (a note path or a `folder:subpath` key) and persist it. * Favorites pin to the top of the sidebar. @@ -3294,8 +3310,18 @@ interface Store { loadDatabase: (csvPath: string) => Promise /** Load a database and open it as a tab in the active pane. */ openDatabase: (csvPath: string) => Promise - /** Create a new empty database under `folder`/`subpath` and open it. */ - createDatabase: (folder: NoteFolder, subpath?: string, title?: string, isCurrent?: () => boolean) => Promise + /** Create a new database under `folder`/`subpath` and open it: empty by + * default, or holding `options.seed` (a converted Markdown table, #832). + * `options.open: false` leaves the caller in place. Resolves to the created + * doc (its title may carry a collision suffix), or undefined when nothing + * was created or the workspace moved on meanwhile. */ + createDatabase: ( + folder: NoteFolder, + subpath?: string, + title?: string, + isCurrent?: () => boolean, + options?: CreateDatabaseOptions + ) => Promise /** Create a database in the configured default databases location and open it. (#362) */ newDatabase: () => Promise /** Rename a database (its `.base` folder); rehomes the open grid tab. */ @@ -3406,7 +3432,14 @@ interface Store { jumpToPreviousNote: () => Promise jumpToNextNote: () => Promise toggleRecentNote: () => Promise - applyChange: (ev: VaultChangeEvent) => Promise + /** + * Bring a vault change into the store. A body re-read for an open note + * counts as a change from disk unless `options.source` is `'app'`: the app + * itself rewrote the file (a link re-targeted after an asset rename) and + * the editor should keep the note's undo history through it, as it does + * for a rename's heading rewrite, rather than start it clean (#852). + */ + applyChange: (ev: VaultChangeEvent, options?: { source?: 'disk' | 'app' }) => Promise refreshNotes: () => Promise refreshRootContentHidden: () => Promise /** Dismiss the vault-root notice for the current vault, persisted (#216). */ @@ -3853,6 +3886,21 @@ const savedBodies = new Map() // Only the latest watcher read may apply, and a newer local save invalidates // older reads even if it finishes or returns to the same starting body. const noteContentVersions = new Map() +/** + * How many times each open note's body was taken from disk this session (a + * watcher event, a resync after a remote feed came back), bumped in the same + * update that replaces the body and left alone by every in-app writer. The + * editor reads it to tell a change from disk apart from a peer pane or a + * rename rewrite, which look the same in the body, and starts the note's undo + * history clean for the former (#852). Keyed by path like noteContentVersions; + * an entry outliving a vault switch is harmless, since an editor re-baselines + * on every note it starts showing. + */ +const noteDiskRevisions = new Map() + +export function noteDiskRevision(path: string): number { + return noteDiskRevisions.get(path) ?? 0 +} /** * Old paths of renames the host has not answered yet. A rename is a move on @@ -4763,6 +4811,22 @@ function withoutNoteInWorkspace(s: Store, path: string): Partial { } } +/** + * Give the open vault the name its settings say (#692): the display name when + * there is one, else the folder's own, read off the root the way main does. + * Runs after every settings save and every external vault.json change, so + * the header, title bar and note-list heading move with the file. A plain + * `set`, not `setVault`: a renamed vault is the same vault. + */ +function applyVaultNameFromSettings(settings: VaultSettings): void { + const vault = useStore.getState().vault + if (!vault) return + const folder = vault.root.split(/[\\/]/).filter(Boolean).pop() ?? vault.root + const name = resolveVaultName(settings.displayName, folder) + if (name === vault.name) return + useStore.setState({ vault: { ...vault, name } }) +} + export const useStore = create((set, get) => { const mutateNoteImpl = async ( path: string, @@ -5812,7 +5876,10 @@ export const useStore = create((set, get) => { setVault: (v) => set((s) => { - const vaultChanged = s.vault?.root !== v?.root || s.vault?.name !== v?.name + // The root is the vault's identity. Its name is a label the user can + // change (#692), so a renamed vault keeps its caches, undo stacks and + // closed-tab history; only a different folder starts those over. + const vaultChanged = s.vault?.root !== v?.root if (vaultChanged) { clearNoteContentReadCaches() } @@ -5825,6 +5892,7 @@ export const useStore = create((set, get) => { set({ vaultSettings: settings }) + applyVaultNameFromSettings(settings) } catch (err) { console.error('setVaultSettings failed', err) return false @@ -5839,6 +5907,21 @@ export const useStore = create((set, get) => { } return true }, + renameVault: async (name) => { + const vault = get().vault + if (!vault || vault.temporary) return false + const displayName = normalizeVaultDisplayName(name ?? '') + const current = get().vaultSettings + if (current.displayName === displayName) return true + const { displayName: _previous, ...rest } = current + const saved = await get().setVaultSettings( + displayName ? { ...rest, displayName } : rest + ) + // Main rewrote the remembered-vaults entry with the save; the switcher + // and the sidebar header read that list, so fetch it again. + if (saved) await get().refreshLocalVaults() + return saved + }, applyFavorites: async (nextFavorites) => { const isCurrent = captureFolderActionContext(get) const current = get().vaultSettings @@ -6112,10 +6195,10 @@ export const useStore = create((set, get) => { ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) }, - createDatabase: async (folder, subpath = '', title, hostIsCurrent) => { - if (workspaceWritesBlocked()) return + createDatabase: async (folder, subpath = '', title, hostIsCurrent, options) => { + if (workspaceWritesBlocked()) return undefined const isCurrent = captureFolderActionContext(get, hostIsCurrent) - if (!isCurrent()) return + if (!isCurrent()) return undefined const directory = vaultRelativeFolderPath(folder, subpath, get().vaultSettings) const prefix = directory ? `${directory}/` : '' let release: (() => void) | undefined @@ -6135,15 +6218,17 @@ export const useStore = create((set, get) => { release = resolve }) ) - const doc = await window.zen.createDatabase(folder, subpath, title) - if (!isCurrent()) return + const doc = await window.zen.createDatabase(folder, subpath, title, options?.seed) + if (!isCurrent()) return undefined set((s) => ({ databases: { ...s.databases, [doc.path]: doc } })) await get().refreshNotes() - if (!isCurrent()) return + if (!isCurrent()) return undefined + if (options?.open === false) return doc await get().openNoteInPane(get().activePaneId, databaseTabPath(doc.path)) - if (!isCurrent()) return + if (!isCurrent()) return undefined ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) + return doc } catch (err) { if (hostIsCurrent) throw err console.error('createDatabase failed', err) @@ -6151,6 +6236,7 @@ export const useStore = create((set, get) => { const { useToastStore } = await import('./lib/toast') useToastStore.getState().addToast(humanIpcError(err, 'Could not create database'), 'error') } + return undefined } finally { if (release) { databaseCreations.delete(prefix) @@ -6912,8 +6998,10 @@ export const useStore = create((set, get) => { // already reads as that day, so strip any `due:` token; otherwise write the // explicit date. const movedLine = setTaskDueAtIndex(line, 0, inferDue ? null : dateIso) - const trimmed = tgtBody.replace(/\s+$/u, '') - const nextTgt = trimmed.length ? `${trimmed}\n${movedLine}\n` : `${movedLine}\n` + + // Insert into the destination note's Tasks section when one exists; + // otherwise append to the end of the note. + const nextTgt = insertTasksUnderTasksHeading(tgtBody, [movedLine]) // Persist both notes (open buffers go through the edit pipeline). if (srcBuffer) get().updateNoteBody(task.sourcePath, strippedSrc) @@ -7386,7 +7474,7 @@ export const useStore = create((set, get) => { result = await window.zen.renameAsset(relPath, nextName) await Promise.all([get().refreshAssets(), get().refreshNotes()]) await Promise.all(Object.values(get().noteContents).map(({ path, folder }) => - get().applyChange({ kind: 'change', path, folder }) + get().applyChange({ kind: 'change', path, folder }, { source: 'app' }) )) }, false, true) return result @@ -7397,7 +7485,7 @@ export const useStore = create((set, get) => { result = await window.zen.moveAsset(relPath, targetDir) await Promise.all([get().refreshAssets(), get().refreshNotes()]) await Promise.all(Object.values(get().noteContents).map(({ path, folder }) => - get().applyChange({ kind: 'change', path, folder }) + get().applyChange({ kind: 'change', path, folder }, { source: 'app' }) )) }, false, true) return result @@ -7471,7 +7559,7 @@ export const useStore = create((set, get) => { } }, - applyChange: async (ev) => { + applyChange: async (ev, options) => { if (folderMutationBlocks(ev.path)) return // The live feed's unlink handling, shared with the resync path below: // a deleted note's tab closes wherever it is open. @@ -7557,6 +7645,10 @@ export const useStore = create((set, get) => { if (!existing || existing.body === content.body || s.noteDirty[openPath]) return s const contents = { ...s.noteContents, [openPath]: content } const dirty = { ...s.noteDirty, [openPath]: false } + // The body comes from the server's disk: the editor starts the + // note's undo history clean rather than mapping steps onto + // text written elsewhere (#852). + noteDiskRevisions.set(openPath, noteDiskRevision(openPath) + 1) return { noteContents: contents, noteDirty: dirty, @@ -7637,6 +7729,11 @@ export const useStore = create((set, get) => { vaultSettings: normalized, ...(get().viewSettingsScope === 'vault' ? viewPrefsFromVault(normalized) : {}) }) + // A display name edited outside (#692) renames the open vault + // too; the remembered list is what main rewrites, so refetch it. + const nameBefore = get().vault?.name + applyVaultNameFromSettings(normalized) + if (get().vault?.name !== nameBefore) void get().refreshLocalVaults() }) .catch((err) => { console.error('refresh vault settings failed', err) @@ -7712,6 +7809,12 @@ export const useStore = create((set, get) => { if (s.noteDirty[ev.path]) return s const contents = { ...s.noteContents, [ev.path]: content } const dirty = { ...s.noteDirty, [ev.path]: false } + // Text from disk, not from this app: the editor starts the note's + // undo history clean, since the user's steps would map onto text + // nobody here wrote (#852). A rewrite the app made itself keeps it. + if (options?.source !== 'app') { + noteDiskRevisions.set(ev.path, noteDiskRevision(ev.path) + 1) + } return { noteContents: contents, noteDirty: dirty, @@ -9271,8 +9374,9 @@ export const useStore = create((set, get) => { : `- [ ] ${content} due:${dateIso}` const openBuffer = get().noteContents[path] const body = openBuffer?.body ?? (await window.zen.readNote(path)).body - const trimmed = body.replace(/\s+$/u, '') - const nextBody = trimmed.length ? `${trimmed}\n${line}\n` : `${line}\n` + // Insert into the note's Tasks section when one exists; otherwise + // append to the end of the note + const nextBody = insertTasksUnderTasksHeading(body, [line]) if (openBuffer) { // Open note: edit through the buffer so unsaved changes aren't stomped; // its autosave + the watcher rescan the tasks (a disk rescan now would be diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 65590682..1792b755 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.55.0", + "version": "2.56.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index c48ddd51..7eb2cc85 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -74,6 +74,7 @@ import type { import type { VaultTask } from './tasks.js' import type { DatabaseDoc, + DatabaseSeed, DatabaseSidecar, DatabaseSummary, DbRow @@ -314,7 +315,16 @@ export interface ZenBridge { openDatabase(relPath: string): Promise writeDatabaseRows(relPath: string, rows: DbRow[]): Promise writeDatabaseSchema(relPath: string, sidecar: DatabaseSidecar, rows: DbRow[]): Promise - createDatabase(folder: NoteFolder, subpath: string, title?: string): Promise + /** Create a `.base` database under `folder`/`subpath`. Empty (an `id` + * + `Name` grid) by default; with a `seed`, its columns and rows become + * the initial contents, typed the way an adopted CSV would be (#832). + * The resolved doc's `title` may carry a collision suffix (`Name 2`). */ + createDatabase( + folder: NoteFolder, + subpath: string, + title?: string, + seed?: DatabaseSeed + ): Promise<DatabaseDoc> /** Rename a database's `.base` folder; resolves to the new `data.csv` path. */ renameDatabase(csvPath: string, newTitle: string): Promise<string> /** Create a record's "page" note (returns its vault-relative path). */ diff --git a/packages/bridge-contract/src/databases.ts b/packages/bridge-contract/src/databases.ts index 3c70157d..9b11206a 100644 --- a/packages/bridge-contract/src/databases.ts +++ b/packages/bridge-contract/src/databases.ts @@ -121,6 +121,19 @@ export interface DatabaseSidecar { pages?: Record<string, string> } +/** + * Tabular contents a database is created with, when it starts from existing + * data rather than empty: a header row and body rows of raw cell strings, + * index-aligned. A Markdown table converted in place arrives this way (#832). + * Per-column pixel widths, when given, follow the same column order so a + * table whose columns were resized keeps those widths in the grid. + */ +export interface DatabaseSeed { + headers: string[] + rows: string[][] + columnWidths?: Array<number | null> +} + /** Cells are raw CSV strings keyed by DbField.id. */ export interface DbRow { /** == cells[idFieldId]. */ diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 4cad4356..b11ee679 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -486,6 +486,18 @@ export interface VaultViewSettings { } export interface VaultSettings { + /** + * The name the vault goes by in the app (#692): the sidebar header, the + * vault switcher, the title bar, the CLI's vault list. Absent means the + * folder's own name, which is what every vault had before. It lives in + * vault.json rather than the app's config so it travels with the vault + * (a `docs/` folder inside a repo can be "Acme API docs" on every machine + * that opens it), and it is deliberately not a portable app preference for + * the same reason. Normalized by `normalizeVaultDisplayName` in + * shared-domain: trimmed, inner whitespace collapsed, control characters + * dropped, capped, and omitted rather than stored empty. + */ + displayName?: string primaryNotesLocation: PrimaryNotesLocation dailyNotes: DailyNotesSettings weeklyNotes: WeeklyNotesSettings diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 317803ab..aa59a248 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.55.0", + "version": "2.56.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/database-csv.ts b/packages/shared-domain/src/database-csv.ts index 6d9dd190..ffaa0414 100644 --- a/packages/shared-domain/src/database-csv.ts +++ b/packages/shared-domain/src/database-csv.ts @@ -7,7 +7,14 @@ * of encoding inside a single cell ("a, b") — option values therefore may not * contain commas (enforced when options are created). */ -import type { DbField, DbRow, DbView, FieldType } from './databases' +import type { + DatabaseSeed, + DatabaseSidecar, + DbField, + DbRow, + DbView, + FieldType +} from './databases' import { DEFAULT_ID_FIELD_NAME } from './databases' /** Injectable id factory (main passes node's randomUUID; tests pass a counter). */ @@ -246,3 +253,81 @@ export function buildDefaultViews( } return { views: [view], activeViewId: id } } + +// --------------------------------------------------------------------------- +// Creation (what a brand-new database is written with) +// --------------------------------------------------------------------------- + +export interface InitialDatabaseContents { + sidecar: DatabaseSidecar + rows: DbRow[] +} + +/** + * The sidecar and rows a new database starts with. Both database IO + * implementations (desktop main's direct-fs one and the IO-abstracted + * database-ops) build their files from this, so what "New Database" and a + * converted table write stays byte-compatible across transports. + * + * Without a seed that is the empty grid: a hidden `id` column and one `Name` + * text field. With a seed, its columns become fields through the same + * inference that adopts a plain CSV (`inferFields`), so a Markdown table + * converted in place (#832) gets exactly the schema its data would get if it + * had been saved as a CSV and opened: number, checkbox, and date columns + * typed, blank or duplicate headers renamed, an existing all-unique `id` + * column adopted as the row id and a hidden one synthesized otherwise. Rows + * are keyed by the resulting field ids; a row without an id gets one minted, + * as `parseRows` does on read. + * + * The seed crosses the bridge from the renderer, so its shape is normalized + * here rather than trusted: cells and headers are coerced to strings and only + * positive finite widths survive. + */ +export function initialDatabaseContents( + seed: DatabaseSeed | undefined, + genId: GenId = defaultGenId +): InitialDatabaseContents { + if (!seed) { + const idField: DbField = { id: genId(), name: DEFAULT_ID_FIELD_NAME, type: 'text', hidden: true } + const nameField: DbField = { id: genId(), name: 'Name', type: 'text' } + const fields = [idField, nameField] + const { views, activeViewId } = buildDefaultViews(fields, genId) + return { + sidecar: { version: 1, idFieldId: idField.id, fields, views, activeViewId }, + rows: [] + } + } + + const headers = (Array.isArray(seed.headers) ? seed.headers : []).map((h) => String(h ?? '')) + const grid = (Array.isArray(seed.rows) ? seed.rows : []).map((row) => + headers.map((_, i) => String((Array.isArray(row) ? row[i] : undefined) ?? '')) + ) + const { fields, idFieldId } = inferFields(headers, grid, genId) + // inferFields either adopted one of the seed's own columns as the id field, + // leaving fields aligned 1:1 with the headers, or put a synthesized id field + // in front of them. + const columnFields = fields.length > headers.length ? fields.slice(1) : fields + columnFields.forEach((field, i) => { + const width = seed.columnWidths?.[i] + if (typeof width === 'number' && Number.isFinite(width) && width > 0) { + field.width = Math.round(width) + } + }) + + const rows: DbRow[] = grid.map((raw) => { + const cells: Record<string, string> = {} + for (const field of fields) cells[field.id] = '' + columnFields.forEach((field, i) => { + cells[field.id] = raw[i] + }) + let id = cells[idFieldId] + if (!id) { + id = genId() + cells[idFieldId] = id + } + return { id, cells } + }) + + const { views, activeViewId } = buildDefaultViews(fields, genId) + return { sidecar: { version: 1, idFieldId, fields, views, activeViewId }, rows } +} diff --git a/packages/shared-domain/src/database-ops.test.ts b/packages/shared-domain/src/database-ops.test.ts index fba66e99..71d70c62 100644 --- a/packages/shared-domain/src/database-ops.test.ts +++ b/packages/shared-domain/src/database-ops.test.ts @@ -88,6 +88,37 @@ describe('createDatabaseOps', () => { expect(second.path).toBe('inbox/Log 2.base/data.csv') }) + it('seeds a new database with a converted table and writes the same files the desktop does (#832)', async () => { + const vault = memVault() + const ops = createDatabaseOps(vault.io) + const doc = await ops.createDatabase('inbox', 'Work', 'Roadmap', { + headers: ['Item', 'Done', 'Estimate'], + rows: [ + ['Write "spec", v1', 'yes', '3'], + ['Ship', 'no', '1'] + ], + columnWidths: [240, null, null] + }) + expect(doc.path).toBe('inbox/Work/Roadmap.base/data.csv') + expect(doc.fields.map((f) => [f.name, f.type])).toEqual([ + ['id', 'text'], + ['Item', 'text'], + ['Done', 'checkbox'], + ['Estimate', 'number'] + ]) + expect(doc.fields[1].width).toBe(240) + const lines = vault.files.get(doc.path)!.trimEnd().split('\n') + expect(lines[0]).toBe('id,Item,Done,Estimate') + expect(lines[1]).toBe(`${doc.rows[0].id},"Write ""spec"", v1",yes,3`) + expect(lines[2]).toBe(`${doc.rows[1].id},Ship,no,1`) + const sidecar = JSON.parse(vault.files.get('inbox/Work/Roadmap.base/schema.json')!) + expect(sidecar.fields).toEqual(doc.fields) + expect(sidecar.views[0].hiddenFieldIds).toEqual([doc.idFieldId]) + + const reopened = await ops.openDatabase(doc.path) + expect(reopened.rows).toEqual(doc.rows) + }) + it('adopts a bare CSV: infers a schema, persists the sidecar, canonicalizes ids', async () => { const vault = memVault() vault.files.set('inbox/Books.base/data.csv', 'Title,Rating\nDune,5\nNeuromancer,4\n') diff --git a/packages/shared-domain/src/database-ops.ts b/packages/shared-domain/src/database-ops.ts index 5f16da29..4b6e5fcd 100644 --- a/packages/shared-domain/src/database-ops.ts +++ b/packages/shared-domain/src/database-ops.ts @@ -26,13 +26,21 @@ import { FORM_DIR_SUFFIX, isFormDirName, type DatabaseDoc, + type DatabaseSeed, type DatabaseSidecar, type DatabaseSummary, type DbField, type DbRow, type DbView } from './databases' -import { buildDefaultViews, inferFields, parseCsv, parseRows, serializeRows } from './database-csv' +import { + buildDefaultViews, + inferFields, + initialDatabaseContents, + parseCsv, + parseRows, + serializeRows +} from './database-csv' import { resolveFolderPath, systemFolderForDirName, @@ -70,7 +78,12 @@ export interface DatabaseOps { openDatabase(csvPath: string): Promise<DatabaseDoc> writeDatabaseRows(csvPath: string, rows: DbRow[]): Promise<DatabaseDoc> writeDatabaseSchema(csvPath: string, sidecar: DatabaseSidecar, rows: DbRow[]): Promise<DatabaseDoc> - createDatabase(folder: NoteFolder, subpath: string, title?: string): Promise<DatabaseDoc> + createDatabase( + folder: NoteFolder, + subpath: string, + title?: string, + seed?: DatabaseSeed + ): Promise<DatabaseDoc> createRecordPage(csvPath: string, title: string, body: string): Promise<string> renameDatabase(csvPath: string, newTitle: string): Promise<string> listDatabases(): Promise<DatabaseSummary[]> @@ -281,7 +294,8 @@ export function createDatabaseOps(io: DatabaseFileOps): DatabaseOps { async function createDatabase( folder: NoteFolder, subpath: string, - title?: string + title?: string, + seed?: DatabaseSeed ): Promise<DatabaseDoc> { const layout = await io.vaultLayout() const baseTitle = (title ?? 'Untitled Database').trim() || 'Untitled Database' @@ -297,22 +311,11 @@ export function createDatabaseOps(io: DatabaseFileOps): DatabaseOps { const csvPath = csvFor(name) const folderSub = joinSub(subpath, `${name}${FORM_DIR_SUFFIX}`) - const idField: DbField = { id: dbGenId(), name: 'id', type: 'text', hidden: true } - const nameField: DbField = { id: dbGenId(), name: 'Name', type: 'text' } - const fields = [idField, nameField] - const { views, activeViewId } = buildDefaultViews(fields, dbGenId) - const sidecar: DatabaseSidecar = { - version: 1, - idFieldId: idField.id, - fields, - views, - activeViewId - } - + const { sidecar, rows } = initialDatabaseContents(seed, dbGenId) await io.createFolder(folder, folderSub) await dbPersistSidecar(csvPath, sidecar) - await io.writeFile(csvPath, serializeRows([], fields)) - return dbHydrate(csvPath, sidecar, []) + await io.writeFile(csvPath, serializeRows(rows, sidecar.fields)) + return dbHydrate(csvPath, sidecar, rows) } async function createRecordPage(csvPath: string, title: string, body: string): Promise<string> { diff --git a/packages/shared-domain/src/databases.ts b/packages/shared-domain/src/databases.ts index ca0b9bc6..aafefd3d 100644 --- a/packages/shared-domain/src/databases.ts +++ b/packages/shared-domain/src/databases.ts @@ -10,6 +10,7 @@ export type { DbViewType, DbView, DatabaseSidecar, + DatabaseSeed, DbRow, DatabaseDoc, DatabaseSummary diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index f6787b5a..4dfe2bab 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -46,6 +46,8 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "global.commandPalette", group: "global", defaultBinding: "Shift+Mod+P", title: "Open command palette" }, { id: "global.newQuickNote", group: "global", defaultBinding: "Shift+Mod+N", title: "New quick note" }, { id: "global.newNoteHere", group: "global", defaultBinding: "Mod+N", title: "New note in current folder" }, + { id: "global.newNoteFromTemplate", group: "global", defaultBinding: "", defaultBindingMac: "Alt+Mod+T", title: "New note from template" }, + { id: "global.insertTemplate", group: "global", defaultBinding: "", title: "Insert template into current note" }, { id: "global.openSettings", group: "global", defaultBinding: "Mod+,", title: "Open settings" }, { id: "global.openFile", group: "global", defaultBinding: "Mod+O", title: "Open file" }, { id: "global.toggleSidebar", group: "global", defaultBinding: "Mod+1", title: "Toggle sidebar" }, @@ -95,6 +97,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderFormatNote", group: "vim", defaultBinding: "f", title: "Leader note action: format note" }, { id: "vim.leaderCopyMarkdown", group: "vim", defaultBinding: "y", title: "Leader note action: copy note as Markdown" }, { id: "vim.leaderToggleFavorite", group: "vim", defaultBinding: "s", title: "Leader note action: toggle favorite" }, + { id: "vim.leaderTableToDatabase", group: "vim", defaultBinding: "d", title: "Leader note action: convert table to database" }, { id: "vim.leaderQuickCapture", group: "vim", defaultBinding: "q", title: "Leader: open quick capture" }, { id: "vim.leaderTemplatePicker", group: "vim", defaultBinding: "t", title: "Leader: new from template" }, { id: "vim.leaderInsertTemplate", group: "vim", defaultBinding: "i", title: "Leader: insert template into note" }, diff --git a/packages/shared-domain/src/vault-display-name.test.ts b/packages/shared-domain/src/vault-display-name.test.ts new file mode 100644 index 00000000..240a4169 --- /dev/null +++ b/packages/shared-domain/src/vault-display-name.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + VAULT_DISPLAY_NAME_MAX_LENGTH, + normalizeVaultDisplayName, + resolveVaultName +} from './vault-display-name' + +describe('normalizeVaultDisplayName (#692)', () => { + it('keeps an ordinary name as typed', () => { + expect(normalizeVaultDisplayName('Acme API docs')).toBe('Acme API docs') + expect(normalizeVaultDisplayName('Été 2026 · notes')).toBe('Été 2026 · notes') + }) + + it('trims and collapses whitespace', () => { + expect(normalizeVaultDisplayName(' Acme API\tdocs \n')).toBe('Acme API docs') + }) + + it('drops control characters and folds line separators into a space', () => { + expect(normalizeVaultDisplayName('Acme\u0000 docs\u2028v2\u0007')).toBe('Acme docs v2') + // A dropped control between two spaces leaves one space, not two. + expect(normalizeVaultDisplayName('A \u0007 B')).toBe('A B') + }) + + it('is undefined for nothing usable, so the key is omitted rather than stored empty', () => { + expect(normalizeVaultDisplayName('')).toBeUndefined() + expect(normalizeVaultDisplayName(' ')).toBeUndefined() + expect(normalizeVaultDisplayName('\u0007')).toBeUndefined() + expect(normalizeVaultDisplayName(undefined)).toBeUndefined() + expect(normalizeVaultDisplayName(42)).toBeUndefined() + expect(normalizeVaultDisplayName(null)).toBeUndefined() + }) + + it('cuts at the limit without leaving a trailing space', () => { + const long = `${'word '.repeat(20)}end` + const out = normalizeVaultDisplayName(long)! + expect(out.length).toBeLessThanOrEqual(VAULT_DISPLAY_NAME_MAX_LENGTH) + expect(out).toBe(out.trimEnd()) + expect(normalizeVaultDisplayName('x'.repeat(VAULT_DISPLAY_NAME_MAX_LENGTH))).toHaveLength( + VAULT_DISPLAY_NAME_MAX_LENGTH + ) + }) + + it('never splits a surrogate pair at the cut', () => { + // 63 units of x, then an emoji (2 units): the emoji does not fit, so it goes whole. + const out = normalizeVaultDisplayName(`${'x'.repeat(63)}😀tail`)! + expect(out).toBe('x'.repeat(63)) + // 62 units of x, then the emoji fits exactly. + expect(normalizeVaultDisplayName(`${'x'.repeat(62)}😀tail`)).toBe(`${'x'.repeat(62)}😀`) + }) +}) + +describe('resolveVaultName', () => { + it('prefers a usable display name and falls back to the folder name', () => { + expect(resolveVaultName('Acme API docs', 'docs')).toBe('Acme API docs') + expect(resolveVaultName(' ', 'docs')).toBe('docs') + expect(resolveVaultName(undefined, 'docs')).toBe('docs') + }) +}) diff --git a/packages/shared-domain/src/vault-display-name.ts b/packages/shared-domain/src/vault-display-name.ts new file mode 100644 index 00000000..5b7779f1 --- /dev/null +++ b/packages/shared-domain/src/vault-display-name.ts @@ -0,0 +1,57 @@ +// A vault's display name (#692): what the app calls the vault, kept in +// `.zennotes/vault.json` as `displayName`, distinct from the folder's name on +// disk. Several `docs/` folders, one per repo, can each carry the project's +// name in the sidebar and the vault switcher while staying `docs/` for git. +// +// Absent means the folder name, so a vault that never set one behaves exactly +// as before, and clearing the field is the same as never having set it. +// +// Desktop main, the renderer and the CLI import these helpers; the Go server +// mirrors them in internal/vault (VaultSettings.DisplayName). Change one, +// change both, and keep the rules byte-compatible: a name that survives one +// runtime's round-trip must survive the other's. + +/** Longer than any name that fits a sidebar header; a limit rather than a + * layout rule, so a pasted paragraph cannot become the vault's name. Counted + * in UTF-16 code units (`String.length`); the Go side counts the same way. */ +export const VAULT_DISPLAY_NAME_MAX_LENGTH = 64 + +// C0 and C1 controls other than the whitespace ones (tab, the newlines, the +// feeds), which stay for the whitespace pass so a pasted `API\tdocs` keeps +// its word break. Nothing a name needs. +const CONTROL_CHARS_RE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g + +/** + * The name as it is stored and shown, in this order: control characters + * dropped, runs of whitespace (tabs, newlines and the Unicode separators + * included) collapsed to one space, trimmed, cut at the limit without + * splitting a surrogate pair. Undefined when nothing usable is left (or the + * input is not a string), so vault.json is written without the key rather + * than with an empty one, and every reader's `displayName ?? folderName` + * fallback holds. + */ +export function normalizeVaultDisplayName(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined + const cleaned = raw.replace(CONTROL_CHARS_RE, '').replace(/\s+/g, ' ').trim() + if (!cleaned) return undefined + return cleaned.length > VAULT_DISPLAY_NAME_MAX_LENGTH + ? cutAtUtf16Units(cleaned, VAULT_DISPLAY_NAME_MAX_LENGTH) + : cleaned +} + +/** The longest prefix of `text` within `limit` UTF-16 units that ends on a + * whole code point, with the space a cut can leave at the end removed. */ +function cutAtUtf16Units(text: string, limit: number): string { + let end = 0 + for (const codePoint of text) { + if (end + codePoint.length > limit) break + end += codePoint.length + } + return text.slice(0, end).trimEnd() +} + +/** The name a vault goes by: its display name when it has one, else the + * folder name the caller derived from its path. */ +export function resolveVaultName(displayName: unknown, folderName: string): string { + return normalizeVaultDisplayName(displayName) ?? folderName +} diff --git a/packages/shared-domain/src/vault-settings-conflict.test.ts b/packages/shared-domain/src/vault-settings-conflict.test.ts index edc96474..b78e2822 100644 --- a/packages/shared-domain/src/vault-settings-conflict.test.ts +++ b/packages/shared-domain/src/vault-settings-conflict.test.ts @@ -96,6 +96,7 @@ describe('diffVaultSettings', () => { it('covers every vault.json section', () => { const declared: Record<keyof VaultSettings, true> = { + displayName: true, primaryNotesLocation: true, dailyNotes: true, weeklyNotes: true, diff --git a/packages/shared-domain/src/vault-settings-conflict.ts b/packages/shared-domain/src/vault-settings-conflict.ts index f1a1fafd..31a61526 100644 --- a/packages/shared-domain/src/vault-settings-conflict.ts +++ b/packages/shared-domain/src/vault-settings-conflict.ts @@ -22,6 +22,7 @@ export type VaultSettingsSection = keyof VaultSettings // section list is what the settings conflict prompt offers, so a new section // must be added (or knowingly left out) rather than silently never shown. const SECTIONS: Record<VaultSettingsSection, true> = { + displayName: true, primaryNotesLocation: true, dailyNotes: true, weeklyNotes: true, diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 4cdec708..43a22a46 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.55.0", + "version": "2.56.0", "type": "module", "exports": { ".": "./src/index.ts"