diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f51e9d96..560dc0ff 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.75.0", + "version": "2.76.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index db54a75a..d77de0fb 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -132,6 +132,7 @@ describe('TOML serialization', () => { savedTaskFilters: { 'Project alpha': '@project:alpha', Blocked: '@status:blocked' }, kanbanGroupBy: 'folder', kanbanCardSort: 'due', + tasksListOrder: 'priority', kanbanFolderRoot: 'Projects', externalApplicationSchemes: ['zotero', 'obsidian'], ignoredKeys: ['KanaMode', 'F24'] @@ -165,6 +166,8 @@ describe('TOML serialization', () => { expect(round.kanbanGroupBy).toBe('folder') expect(text).toContain('kanban_card_sort = "due"') expect(round.kanbanCardSort).toBe('due') + expect(text).toContain('tasks_list_order = "priority"') + expect(round.tasksListOrder).toBe('priority') expect(round.kanbanFolderRoot).toBe('Projects') expect(text).toContain('ignored_keys = ["KanaMode", "F24"]') expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian']) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 6e875376..46d99b40 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -415,6 +415,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'kanban_card_sort', comment: 'manual | due: cards in a Kanban column keep the order you drag them into, or follow their due dates' }, + tasksListOrder: { + section: 'view', + tomlKey: 'tasks_list_order', + comment: 'note | priority: each Tasks list group keeps its notes\' line order, or puts high priority first' + }, kanbanFolderRoot: { section: 'view', tomlKey: 'kanban_folder_root', diff --git a/apps/desktop/src/main/cloud-sync-filesystem.test.ts b/apps/desktop/src/main/cloud-sync-filesystem.test.ts index 9b98a5c7..60be8ea6 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.test.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.test.ts @@ -567,6 +567,64 @@ describe('DesktopCloudSyncRepository: decisions and writes', () => { expect(await readFile(path.join(root, 'Deck.pdf'), 'utf8')).toBe('cloud bytes') }, 20_000) + it('puts the other device\'s version where it went and moves this device\'s different file beside it', async () => { + // Dave (2026-10-23): the computer trashed the note his phone edited, and + // the phone had trashed an Untitled.md of its own into the same slot. + const root = await temporaryRoot() + const note = 'inbox/Daily Notes/Untitled.md' + await mkdir(path.join(root, 'inbox/Daily Notes'), { recursive: true }) + await mkdir(path.join(root, 'trash/Daily Notes'), { recursive: true }) + await writeFile(path.join(root, note), 'edited here') + await writeFile(path.join(root, 'trash/Daily Notes/Untitled.md'), 'own scratch') + await writeFile(path.join(root, 'trash/Daily Notes/Untitled 2.md'), 'already second') + const cloud = upsert('trash/Daily Notes/Untitled.md', 'from the computer').content! + const { data, ...contentMetadata } = cloud + const reference = { ...contentMetadata, item_id: 'item-remote', revision: 2 } + const repository = new DesktopCloudSyncRepository(root, { + stagingDirectory: await temporaryRoot(), + fetchImplementation: async () => new Response(data) + }) + const file = await repository.stageCloudContent({ + reference, + previewLimitBytes: 0, + getInstruction: async () => ({ + item_id: reference.item_id, revision: reference.revision, content: contentMetadata, + download: { method: 'GET', url: 'https://objects.example.test/immutable', headers: {}, expires_at: '2099-10-01T12:00:00Z' } + }) + }) + try { + await repository.resolveStagedCloudConflict({ + expected_path: note, expected_sha256: hash('edited here'), cloud_path: 'trash/Daily Notes/Untitled.md', file + }) + } finally { + await releaseCloudSyncStagedFile(file) + } + + expect(await readFile(path.join(root, 'trash/Daily Notes/Untitled.md'), 'utf8')).toBe('from the computer') + expect(await readFile(path.join(root, 'trash/Daily Notes/Untitled 3.md'), 'utf8')).toBe('own scratch') + expect(await readFile(path.join(root, 'trash/Daily Notes/Untitled 2.md'), 'utf8')).toBe('already second') + await expect(readFile(path.join(root, note), 'utf8')).rejects.toThrow() + }) + + it('moves only for the other device\'s version: a chosen name that is taken still refuses', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'Plan.md'), 'edited here') + await writeFile(path.join(root, 'Taken.md'), 'someone else') + const repository = new DesktopCloudSyncRepository(root) + const cloud = upsert('Taken.md', 'cloud').content! + + await expect(repository.applyConflictResolutionFiles({ + expected_path: 'Plan.md', expected_sha256: hash('edited here'), + files: [{ path: 'Taken.md', content: cloud }] + })).rejects.toThrow('already exists') + await repository.applyConflictResolutionFiles({ + expected_path: 'Plan.md', expected_sha256: hash('edited here'), + files: [{ path: 'Taken.md', content: cloud, displace_existing: true }] + }) + expect(await readFile(path.join(root, 'Taken.md'), 'utf8')).toBe('cloud') + expect(await readFile(path.join(root, 'Taken 2.md'), 'utf8')).toBe('someone else') + }) + it('refuses to write a snapshot that carries no bytes and no source', async () => { const root = await temporaryRoot() await writeFile(path.join(root, 'Deck.pdf'), 'agreed') diff --git a/apps/desktop/src/main/cloud-sync-filesystem.ts b/apps/desktop/src/main/cloud-sync-filesystem.ts index 521b400d..bdfd880d 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.ts @@ -13,6 +13,7 @@ import type { import { CLOUD_SYNC_SETTINGS_CONFLICT_PATH, CLOUD_SYNC_VAULT_SETTINGS_PATH, + cloudSyncFreePath, cloudSyncPathKey, isCloudSyncVaultSettingsPath, normalizeCloudSyncPath, @@ -136,8 +137,11 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { const local = expected ? await this.localItemOrNull(expected) : null if ((local?.content.sha256 ?? null) !== input.expected_sha256) throw new Error('This file changed on this device.') const replacing = expected !== null && cloudSyncPathKey(expected) === cloudSyncPathKey(cloud) - if (!replacing && await exists(this.resolve(cloud))) throw new Error(`A file named “${cloud}” already exists. Choose another name.`) + // The other device's version goes where that device put it. + const displacement = replacing ? null + : await this.displacementFor(cloud, new Set([cloud, ...(keep ? [keep] : [])].map(cloudSyncPathKey))) let keptCopy: Stats | undefined + let displaced = false let cloudPublished = false try { if (keep) { @@ -149,6 +153,10 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { if (expected && ((await this.localItemOrNull(expected))?.content.sha256 ?? null) !== input.expected_sha256) { throw new Error('This file changed on this device.') } + if (displacement) { + await renameWithRetry(this.resolve(displacement.from), this.resolve(displacement.to)) + displaced = true + } await this.publishFile(cloud, handle.path, input.file.reference, replacing ? input.expected_sha256 : null, handle.signal) cloudPublished = true if (expected && !replacing) { @@ -162,10 +170,37 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { // intact. An edit, replacement or unverifiable file must survive. await this.rollbackStagedConflictCopy(keep, expected, local.content, keptCopy).catch(() => {}) } + if (displacement && displaced && !cloudPublished) await this.undoDisplacement(displacement).catch(() => {}) throw error } } + /** + * A different file of this device where the other device's version has to + * go (see CloudSyncRepository.applyConflictResolutionFiles): the next free + * name beside it, or null when the path is free. Anything but a plain file + * there keeps the choice refused. + */ + private async displacementFor(relPath: string, reserved: Set): Promise<{ from: string; to: string } | null> { + let stats: Stats + try { + stats = await fs.lstat(this.resolve(relPath)) + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } + if (!stats.isFile()) throw new Error(`A file named “${relPath}” already exists. Choose another name.`) + const to = await cloudSyncFreePath(relPath, async (candidate) => + reserved.has(cloudSyncPathKey(candidate)) || await exists(this.resolve(candidate))) + reserved.add(cloudSyncPathKey(to)) + return { from: relPath, to } + } + + private async undoDisplacement(move: { from: string; to: string }): Promise { + if (await exists(this.resolve(move.from))) return + await renameWithRetry(this.resolve(move.to), this.resolve(move.from)) + } + private async rollbackStagedConflictCopy(relPath: string, originalPath: string, content: CloudSyncContent, created: Stats): Promise { const filename = this.resolve(relPath) const before = await fs.lstat(filename) @@ -370,7 +405,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { async applyConflictResolutionFiles(input: { expected_path: string | null expected_sha256: string | null - files: Array<{ path: string; content: CloudSyncContent }> + files: Array<{ path: string; content: CloudSyncContent; displace_existing?: boolean }> }): Promise { const expectedPath = input.expected_path ? normalizeCloudSyncPath(input.expected_path) : null const current = expectedPath ? await this.localItemOrNull(expectedPath) : null @@ -382,9 +417,14 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { const files = normalizedResolutionFiles(input.files) const expectedKey = expectedPath ? cloudSyncPathKey(expectedPath) : null + const reserved = new Set(files.map((file) => cloudSyncPathKey(file.path))) + const displacements: Array<{ from: string; to: string }> = [] for (const file of files) { if (cloudSyncPathKey(file.path) === expectedKey) continue - if (await exists(this.resolve(file.path))) { + if (file.displace_existing) { + const displacement = await this.displacementFor(file.path, reserved) + if (displacement) displacements.push(displacement) + } else if (await exists(this.resolve(file.path))) { throw new Error(`A file named “${file.path}” already exists. Choose another name.`) } } @@ -399,7 +439,12 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { : 0 ) const newPaths: string[] = [] + const displaced: Array<{ from: string; to: string }> = [] try { + for (const move of displacements) { + await renameWithRetry(this.resolve(move.from), this.resolve(move.to)) + displaced.push(move) + } for (const file of ordered) { if (cloudSyncPathKey(file.path) !== expectedKey) newPaths.push(file.path) await this.writeContent(file.path, file.content, cloudSyncPathKey(file.path) === expectedKey ? input.expected_sha256 : null) @@ -412,6 +457,7 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { for (const createdPath of newPaths.reverse()) { await fs.rm(this.resolve(createdPath), { force: true }).catch(() => undefined) } + for (const move of displaced.reverse()) await this.undoDisplacement(move).catch(() => undefined) throw error } } @@ -579,8 +625,8 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { } function normalizedResolutionFiles( - files: Array<{ path: string; content: CloudSyncContent }> -): Array<{ path: string; content: CloudSyncContent }> { + files: Array<{ path: string; content: CloudSyncContent; displace_existing?: boolean }> +): Array<{ path: string; content: CloudSyncContent; displace_existing?: boolean }> { const normalized = files.map((file) => ({ ...file, path: normalizeCloudSyncPath(file.path) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index e26e1641..2787ec89 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.10.0", - "commit": "ced7926715549e85349e545f780a9e4bdca1585f", + "version": "0.11.0", + "commit": "881057116fa131212bf55c2d26f5eae42ef8d72d", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_darwin_arm64.tar.gz", - "sha256": "0ab6f5596079cadb1329161dd9a6716f253355465a4ec8e527838fbb8ec97ee6" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_darwin_arm64.tar.gz", + "sha256": "4b3548577ece83dc311bb67d72d4389a0a48a24c3bd56b9ba28aaa705303d33f" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_darwin_amd64.tar.gz", - "sha256": "dcbe9cfe7b116b1667e282dc81fcf13e8340b41d7f9575e0919481bd75908376" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_darwin_amd64.tar.gz", + "sha256": "9dd6b9e19c3448ff947c7dd20eec511a6ffb4975616f0a78014c0e6b33047ddb" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_linux_arm64.tar.gz", - "sha256": "b7bd5f8a1b22988240d612879b3194a07212ba8d7a51112df822ba935680d3b0" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_linux_arm64.tar.gz", + "sha256": "132eb7755693cea9911783c2097b6a18964e18658a95d99d93353bc1e56ee6e8" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_linux_amd64.tar.gz", - "sha256": "d7aeca19b82c29b994cc0c2bcf6d3eff2767f3dea0842170462d21b6eda394f1" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_linux_amd64.tar.gz", + "sha256": "44fd68b60dd82946a210dc715bea4d8d28363870ef7fcc335522e0a3e586d2bd" } } } diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 8cd59b9b..24ae2d8e 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.75.0", + "version": "2.76.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index caf0f905..94297305 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.75.0", + "version": "2.76.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 f825378c..9ee0f6b8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.75.0", + "version": "2.76.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 e9d4604c..626d971d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.75.0", + "version": "2.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.75.0", + "version": "2.76.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.75.0", + "version": "2.76.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.75.0", + "version": "2.76.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.75.0", + "version": "2.76.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.75.0", + "version": "2.76.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16351,9 +16351,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz", + "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.75.0", + "version": "2.76.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.75.0", + "version": "2.76.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.75.0", + "version": "2.76.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16519,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.75.0" + "version": "2.76.0" } } } diff --git a/package.json b/package.json index fd54730e..2fd6ffa7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.75.0", + "version": "2.76.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 a442be6f..2f2aa29b 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.75.0", + "version": "2.76.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/components/BufferPalette.test.ts b/packages/app-core/src/components/BufferPalette.test.ts index 999bf442..504ba84f 100644 --- a/packages/app-core/src/components/BufferPalette.test.ts +++ b/packages/app-core/src/components/BufferPalette.test.ts @@ -3,6 +3,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_VAULT_SETTINGS, type NoteFolder, type NoteMeta } from '@shared/ipc' import { findLeaf, type PaneLeaf } from '../lib/pane-layout' import { useStore } from '../store' import { BufferPalette } from './BufferPalette' @@ -109,4 +110,29 @@ describe('BufferPalette (#641)', () => { 'B' ) }) + + it('shows the folder note search and the [[ picker show for each buffer', () => { + const note = (path: string, folder: NoteFolder): NoteMeta => + ({ path, folder, title: 'Overview', tags: [], wikilinks: [] }) as unknown as NoteMeta + const tabs = [ + 'inbox/Projects/Atlas/Overview.md', + 'archive/2025/Atlas/Overview.md', + 'inbox/Overview.md', + 'inbox/Unlisted/Overview.md' + ] + useStore.setState({ + paneLayout: { kind: 'leaf', id: 'pane-a', tabs, pinnedTabs: [], activeTab: tabs[0] }, + selectedPath: tabs[0], + notes: [note(tabs[0], 'inbox'), note(tabs[1], 'archive'), note(tabs[2], 'inbox')], + vaultSettings: { ...DEFAULT_VAULT_SETTINGS, primaryNotesLocation: 'inbox' } + }) + act(() => root.render(createElement(BufferPalette))) + // Natural order: the hidden tabs, then the current one. A tab whose note + // is not listed falls back to its path's folder. + expect( + [...document.querySelectorAll('[data-buf-idx] span[dir="rtl"]')].map((span) => + span.textContent?.replace(/[\u202A\u202C]/g, '') + ) + ).toEqual(['archive/2025/Atlas', 'inbox', 'inbox/Unlisted', 'Projects/Atlas']) + }) }) diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 8a4ac349..6637a298 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -29,6 +29,7 @@ import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/system-folder-labels' import { focusEditorNormalMode } from '../lib/editor-focus' +import { noteFolderLabel } from '../lib/vault-layout' import { Modal } from './ui/Modal' interface BufferEntry { @@ -47,9 +48,11 @@ function fallbackTitle(path: string): string { return filename.replace(/\.md$/i, '') || path } -/** The row's secondary column: the note's folder, not the full path — the - * filename part would just repeat the title, and it was crowding the title - * out of the row (#635). The full path stays searchable via keywords. */ +/** The row's secondary column: the note's folder, not the full path, since + * the filename part would just repeat the title, and it was crowding the title + * out of the row (#635). The full path stays searchable via keywords. A note + * the store knows gets the folder note search and the [[ picker show + * (`noteFolderLabel`); this is only for a tab whose note is not listed. */ function parentDir(path: string): string { const idx = path.lastIndexOf('/') return idx > 0 ? path.slice(0, idx) : '' @@ -63,6 +66,7 @@ interface BuildDeps { notes: ReturnType['notes'] noteDirty: ReturnType['noteDirty'] systemFolderLabels: SystemFolderLabels + vaultSettings: ReturnType['vaultSettings'] } function buildEntries(deps: BuildDeps): BufferEntry[] { @@ -201,7 +205,7 @@ function buildEntries(deps: BuildDeps): BufferEntry[] { entries.push({ path, title, - subtitle: parentDir(path), + subtitle: meta ? noteFolderLabel(meta, deps.vaultSettings) || meta.folder : parentDir(path), keywords: [title, path, meta?.folder].filter(Boolean).join(' '), badge, current: isCurrent, @@ -249,6 +253,7 @@ export function BufferPalette(): JSX.Element { const notes = useStore((s) => s.notes) const noteDirty = useStore((s) => s.noteDirty) const systemFolderLabels = useStore((s) => s.systemFolderLabels) + const vaultSettings = useStore((s) => s.vaultSettings) const entries = useMemo( () => @@ -259,9 +264,19 @@ export function BufferPalette(): JSX.Element { noteContents, notes, noteDirty, - systemFolderLabels + systemFolderLabels, + vaultSettings }), - [paneLayout, activePaneId, selectedPath, noteContents, notes, noteDirty, systemFolderLabels] + [ + paneLayout, + activePaneId, + selectedPath, + noteContents, + notes, + noteDirty, + systemFolderLabels, + vaultSettings + ] ) const [query, setQuery] = useState('') diff --git a/packages/app-core/src/components/DatePickerModal.tsx b/packages/app-core/src/components/DatePickerModal.tsx index cc80c164..43a030d2 100644 --- a/packages/app-core/src/components/DatePickerModal.tsx +++ b/packages/app-core/src/components/DatePickerModal.tsx @@ -102,8 +102,8 @@ export function DatePickerModal({ } const hint = vimMode - ? 'h j k l or arrows move, t is today. PageUp/PageDown change the month, with Shift the year. Enter inserts.' - : 'Arrows move. PageUp/PageDown change the month, with Shift the year. Enter inserts.' + ? 'h j k l or arrows move, t is today. PageUp/PageDown change the month, with Shift the year. Enter picks the day.' + : 'Arrows move. PageUp/PageDown change the month, with Shift the year. Enter picks the day.' return ( { + const dueByPath = buildDailyNoteDateByPath(notes, vaultSettings) const render = computeTasksRender( - filterTasksForDisplay(vaultTasks, showArchivedTasks), + inferDailyTaskDueDates(filterTasksForDisplay(vaultTasks, showArchivedTasks), dueByPath), '', new Date(now), NO_COLLAPSE ) return { today: render.groups.today, overdueCount: render.groups.overdueCount } - }, [vaultTasks, showArchivedTasks, now]) + }, [vaultTasks, showArchivedTasks, now, notes, vaultSettings]) const visibleTasks = today.slice(0, MAX_TASKS) const hiddenTaskCount = today.length - visibleTasks.length diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index b8089eac..2cb3e9a3 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -16,6 +16,7 @@ import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { ResizeHandle } from './ResizeHandle' import { Button, IconButton } from './ui/Button' import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from '../lib/move-note' +import { assetMoveTargetDir, buildMoveAssetPrompt } from '../lib/move-asset' import { naturalCompare } from '../lib/natural-sort' import { extractTags } from '../lib/tags' import { setDragPayload } from '../lib/dnd' @@ -353,23 +354,10 @@ export function NoteList(): JSX.Element { items.push({ label: 'Move…', onSelect: async () => { - const target = await promptApp({ - title: 'Move asset', - description: 'Enter a vault-relative folder path. Leave empty to move to the vault root.', - initialValue: currentDir, - placeholder: 'media/screenshots', - okLabel: 'Move', - allowEmptySubmit: true, - validate: (value) => { - const clean = value.trim() - if (clean.includes('..')) return 'Path cannot contain ..' - if (clean.split('/').includes('.zennotes')) { - return 'Cannot move assets into internal ZenNotes files' - } - return null - } - }) - if (target === null || target === currentDir) return + const value = await promptApp(buildMoveAssetPrompt(currentDir)) + if (value === null) return + const target = assetMoveTargetDir(value) + if (target === currentDir) return await moveAsset(asset.path, target) } }) diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index f9c1d2f3..45395d5d 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -82,6 +82,7 @@ import { NoteHoverPreview } from "./NoteHoverPreview"; import { ContextMenu, type ContextMenuItem } from "./ContextMenu"; import { ArrowUpRightIcon, MaximizeIcon, MinimizeIcon } from "./icons"; import { promptApp } from "../lib/prompt-requests"; +import { assetMoveTargetDir, buildMoveAssetPrompt } from "../lib/move-asset"; import { renderMermaidSvg } from "../lib/mermaid-render"; import { confirmApp } from "../lib/confirm-requests"; @@ -1051,23 +1052,10 @@ export const Preview = memo(function Preview({ items.push({ label: "Move…", onSelect: async () => { - const target = await promptApp({ - title: "Move asset", - description: "Enter a vault-relative folder path. Leave empty to move to the vault root.", - initialValue: currentDir, - placeholder: "media/screenshots", - okLabel: "Move", - allowEmptySubmit: true, - validate: (value) => { - const clean = value.trim(); - if (clean.includes("..")) return "Path cannot contain .."; - if (clean.split("/").includes(".zennotes")) { - return "Cannot move assets into internal ZenNotes files"; - } - return null; - }, - }); - if (target === null || target === currentDir) return; + const value = await promptApp(buildMoveAssetPrompt(currentDir)); + if (value === null) return; + const target = assetMoveTargetDir(value); + if (target === currentDir) return; await moveAsset(vaultRel, target); }, }); diff --git a/packages/app-core/src/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index f8789b9a..f3772ffb 100644 --- a/packages/app-core/src/components/SearchPalette.test.ts +++ b/packages/app-core/src/components/SearchPalette.test.ts @@ -295,7 +295,7 @@ describe('SearchPalette: the create row opens a New note form', () => { type(search(), 'Alph') const rows = [...document.querySelectorAll('[data-search-idx]')] expect(rows.map((r) => r.dataset.searchIdx)).toEqual(['0', '1']) - expect(rows[0].textContent).toBe('Alpha‪inbox‬') + expect(rows[0].textContent).toBe('Alphainbox') expect(rows[1].hasAttribute('data-search-create')).toBe(true) expect(highlighted()).toBe('0') @@ -609,14 +609,14 @@ describe('SearchPalette: each row shows the note\'s folder (#982)', () => { const shown = (): { title: string; folder: string }[] => [...document.querySelectorAll('[data-search-idx]')].map((row) => { const [title, folder] = [...row.querySelectorAll('span')].map((s) => s.textContent ?? '') - return { title, folder: folder.replace(/[‪‬]/g, '') } + return { title, folder: folder.replace(/[\u202A\u202C]/g, '') } }) it('tells notes that share a name apart by their folder', () => { act(() => root.render(createElement(SearchPalette))) expect(shown()).toEqual([ - { title: 'Overview', folder: 'inbox/Projects/Atlas/docs' }, - { title: 'Overview', folder: 'inbox/Projects/Billing/docs' }, + { title: 'Overview', folder: 'Projects/Atlas/docs' }, + { title: 'Overview', folder: 'Projects/Billing/docs' }, { title: 'Overview', folder: 'archive/2025/Atlas' }, { title: 'Overview', folder: 'inbox' } ]) @@ -629,7 +629,7 @@ describe('SearchPalette: each row shows the note\'s folder (#982)', () => { for (const dir of dirs) { // rtl puts the ellipsis on the left; the embedding keeps the path LTR. expect(dir.getAttribute('dir')).toBe('rtl') - expect(dir.textContent?.startsWith('‪')).toBe(true) + expect(dir.textContent?.startsWith('\u202A')).toBe(true) expect(dir.className).toContain('truncate') } const rootRow = document.querySelector('[data-search-idx="3"]')! diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 1562f5da..8c5a30dd 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -9,7 +9,6 @@ import { searchNoteIndex } from '../lib/note-search' import { focusEditorNormalMode } from '../lib/editor-focus' -import { parentDirOf } from '../lib/manual-order' import { destinationLabel, parseDestinationText, @@ -18,7 +17,7 @@ import { type SearchCreateDraft } from '../lib/search-create' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' -import { isPrimaryNotesAtRoot } from '../lib/vault-layout' +import { isPrimaryNotesAtRoot, noteFolderLabel } from '../lib/vault-layout' import { useToastStore } from '../lib/toast' import { Modal } from './ui/Modal' import { SearchCreateForm, type SearchCreateTarget } from './SearchCreateForm' @@ -200,7 +199,7 @@ export function SearchPalette(): JSX.Element {
No matches.
) : ( results.map((n, i) => { - const dir = parentDirOf(n.path) + const dir = noteFolderLabel(n, vaultSettings) return (