Skip to content
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.74.0",
"version": "2.75.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/quicklook/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/share-viewer/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.74.0",
"version": "2.75.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
59 changes: 39 additions & 20 deletions packages/app-core/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
<div className="flex min-w-0 shrink items-center gap-1 overflow-hidden text-sm text-ink-500">
{ancestors.map((c, i) => (
<span key={i} className="flex shrink-0 items-center gap-1">
<button
data-crumb-menu=""
onClick={c.onClick}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setCrumbMenu({ x: e.clientX, y: e.clientY, subpath: c.subpath })
}}
className="truncate rounded px-1 hover:bg-paper-200/70 hover:text-ink-800"
title={`Go to ${c.label}, right-click${vimMode ? ' (or m)' : ''} to create here`}
>
{c.label}
</button>
<span className="text-ink-400">›</span>
<div className="flex min-w-0 shrink items-center overflow-hidden text-sm text-ink-500">
{ancestors.length > 0 && (
<span className="flex min-w-0 items-center justify-end overflow-hidden">
{ancestors.map((c, i) => (
<span key={i} className="flex shrink-0 items-center gap-1 pr-1">
<button
data-crumb-menu=""
onClick={c.onClick}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setCrumbMenu({ x: e.clientX, y: e.clientY, subpath: c.subpath })
}}
className="truncate rounded px-1 hover:bg-paper-200/70 hover:text-ink-800"
title={`Go to ${c.label}, right-click${vimMode ? ' (or m)' : ''} to create here`}
>
{c.label}
</button>
<span className="text-ink-400">›</span>
</span>
))}
</span>
))}
)}
{crumbMenu && (
<ContextMenu
x={crumbMenu.x}
Expand Down Expand Up @@ -5281,8 +5296,12 @@ function Breadcrumb({
}}
title={warning || 'Rename note'}
aria-invalid={warning ? 'true' : 'false'}
// As wide as the name, up to the whole row, so the field shows the
// name being renamed rather than its tail. An engine without
// field-sizing keeps the default width, still clear of the crumbs.
style={{ fieldSizing: 'content' }}
className={[
'min-w-[88px] max-w-[360px] rounded px-1.5 py-0.5 text-sm font-semibold text-ink-900 outline-none',
'min-w-[88px] max-w-full shrink-0 rounded px-1.5 py-0.5 text-sm font-semibold text-ink-900 outline-none',
warning ? 'bg-red-500/12 ring-1 ring-red-500/60' : 'bg-paper-200/60'
].join(' ')}
/>
Expand All @@ -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'}
</button>
Expand Down
83 changes: 81 additions & 2 deletions packages/app-core/src/components/SearchPalette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('[data-search-idx]')].map(
(row) => row.textContent?.replace(/inbox$/i, '').trim() ?? ''
(row) => row.querySelector('span')?.textContent?.trim() ?? ''
)
const ctrlD = async (): Promise<void> => {
await act(async () => {
Expand Down Expand Up @@ -294,7 +295,7 @@ describe('SearchPalette: the create row opens a New note form', () => {
type(search(), 'Alph')
const rows = [...document.querySelectorAll<HTMLElement>('[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')

Expand Down Expand Up @@ -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<typeof useStore.getState>
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<HTMLButtonElement>('[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<HTMLElement>('[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')
})
})
Loading
Loading