diff --git a/apps/desktop/package.json b/apps/desktop/package.json index de08e7bc7..f51e9d96e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.74.0", + "version": "2.75.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 10ece4e61..8cd59b9bc 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.74.0", + "version": "2.75.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 fc9e5482d..caf0f9053 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.74.0", + "version": "2.75.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 6682ca5fc..f825378cd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.74.0", + "version": "2.75.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 78d55f9f6..e9d4604c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.74.0", + "version": "2.75.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.74.0", + "version": "2.75.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.74.0", + "version": "2.75.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.74.0", + "version": "2.75.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.74.0", + "version": "2.75.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.74.0", + "version": "2.75.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -8377,9 +8377,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.74.0", + "version": "2.75.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.74.0", + "version": "2.75.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.74.0", + "version": "2.75.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16519,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.74.0" + "version": "2.75.0" } } } diff --git a/package.json b/package.json index eca26806a..fd54730e3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.74.0", + "version": "2.75.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 f74694e0a..a442be6f5 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.74.0", + "version": "2.75.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 4998f2001..263c75934 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -5140,6 +5140,9 @@ function Breadcrumb({ const raf = requestAnimationFrame(() => { inputRef.current?.focus() inputRef.current?.select() + // Focusing scrolls a name wider than the row to its tail. Show its start + // instead, still fully selected (zennotesandroid#126). + if (inputRef.current) inputRef.current.scrollLeft = 0 if (autoFocus) onAutoFocusHandled() }) return () => cancelAnimationFrame(raf) @@ -5187,26 +5190,38 @@ function Breadcrumb({ return true } + // The note's name outranks its folders when the row runs out + // (zennotesandroid#126). On a phone or a narrow split pane, full-width crumbs + // used to squeeze the title of a note a few folders deep to a sliver. The + // crumbs share one group that gives way first, clipped at the vault end so + // the nearest folder stays beside the title, which truncates only once it + // alone fills the row. Crumbs carry their trailing space as padding, not a + // flex gap, so a collapsed group leaves nothing to push the title past the + // edge. return ( -
- {ancestors.map((c, i) => ( - - - › +
+ {ancestors.length > 0 && ( + + {ancestors.map((c, i) => ( + + + › + + ))} - ))} + )} {crumbMenu && ( @@ -5291,7 +5310,7 @@ function Breadcrumb({ type="button" onClick={() => setEditing(true)} title="Rename note" - className="truncate rounded px-1.5 py-0.5 text-sm font-semibold text-ink-900 hover:bg-paper-200/70" + className="max-w-full shrink-0 truncate rounded px-1.5 py-0.5 text-sm font-semibold text-ink-900 hover:bg-paper-200/70" > {note.title || 'Untitled'} diff --git a/packages/app-core/src/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index 1d198049b..f8789b9a1 100644 --- a/packages/app-core/src/components/SearchPalette.test.ts +++ b/packages/app-core/src/components/SearchPalette.test.ts @@ -93,9 +93,10 @@ describe('SearchPalette: Ctrl+D moves the highlighted note to Trash', () => { if (!el) throw new Error('search input not rendered') return el } + // The titles only: each row also shows the note's folder. const rows = (): string[] => [...document.querySelectorAll('[data-search-idx]')].map( - (row) => row.textContent?.replace(/inbox$/i, '').trim() ?? '' + (row) => row.querySelector('span')?.textContent?.trim() ?? '' ) const ctrlD = async (): Promise => { await act(async () => { @@ -294,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('Alphainbox') + expect(rows[0].textContent).toBe('Alpha‪inbox‬') expect(rows[1].hasAttribute('data-search-create')).toBe(true) expect(highlighted()).toBe('0') @@ -558,3 +559,81 @@ describe('SearchPalette: the create row opens a New note form', () => { expect(form()).toBeNull() }) }) + +describe('SearchPalette: each row shows the note\'s folder (#982)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let originalScrollIntoView: PropertyDescriptor | undefined + + const at = (path: string, folder: NoteMeta['folder'] = 'inbox'): NoteMeta => ({ + ...note('Overview'), + path, + folder + }) + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + useStore.setState({ + notes: [ + at('inbox/Projects/Atlas/docs/Overview.md'), + at('inbox/Projects/Billing/docs/Overview.md'), + at('archive/2025/Atlas/Overview.md', 'archive'), + // A root-mode vault keeps its primary notes at the vault root. + at('Overview.md') + ], + selectedPath: null, + noteContents: {}, + noteDirty: {}, + searchOpen: true + }) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + } else { + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView + } + useStore.setState(originalState, true) + }) + + 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, '') } + }) + + 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: 'archive/2025/Atlas' }, + { title: 'Overview', folder: 'inbox' } + ]) + }) + + it('cuts a long folder from the left and keeps a root note\'s area label', () => { + act(() => root.render(createElement(SearchPalette))) + const dirs = [...document.querySelectorAll('[data-search-dir]')] + expect(dirs).toHaveLength(3) + 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.className).toContain('truncate') + } + const rootRow = document.querySelector('[data-search-idx="3"]')! + expect(rootRow.querySelector('[data-search-dir]')).toBeNull() + expect(rootRow.querySelectorAll('span')[1].className).toContain('uppercase') + }) +}) diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 8394930f1..1562f5dae 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -9,6 +9,7 @@ import { searchNoteIndex } from '../lib/note-search' import { focusEditorNormalMode } from '../lib/editor-focus' +import { parentDirOf } from '../lib/manual-order' import { destinationLabel, parseDestinationText, @@ -198,25 +199,45 @@ export function SearchPalette(): JSX.Element { {rowCount === 0 ? (
No matches.
) : ( - results.map((n, i) => ( - - )) + results.map((n, i) => { + const dir = parentDirOf(n.path) + return ( + + ) + }) )} {createRow && results.length > 0 && (