Compare commits
8 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f503691e | ||
|
|
17a327173b | ||
|
|
6eb4963952 | ||
|
|
b51b464605 | ||
|
|
a3b5b2244e | ||
|
|
38f83b55e0 | ||
|
|
a5652b3f4d | ||
|
|
56faffdcc8 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.72
|
||||
AVERAGE_THRESHOLD=9.34
|
||||
HOTSPOT_THRESHOLD=9.56
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
|
||||
@@ -102,7 +102,7 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
if (folders.length === 0 && !isCreating) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
{/* Header */}
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
|
||||
@@ -958,4 +958,17 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
it('SECTIONS header and its entries share the same border-b container (no separator inside group)', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const sectionsHeader = screen.getByText('SECTIONS')
|
||||
const projectsSection = screen.getByText('Projects')
|
||||
// Walk up from SECTIONS header to find the border-b container
|
||||
const borderContainer = sectionsHeader.closest('.border-b')
|
||||
expect(borderContainer).not.toBeNull()
|
||||
// The section entry should be inside the same border-b container
|
||||
expect(borderContainer!.contains(projectsSection)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,7 +223,7 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
if (favorites.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px 0' }}>
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
@@ -430,7 +430,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px 0' }}>
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
@@ -457,7 +457,6 @@ export const Sidebar = memo(function Sidebar({
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
{onDeleteView && (
|
||||
<button
|
||||
@@ -475,39 +474,41 @@ export const Sidebar = memo(function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} className="border-b border-border" style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
onClick={() => toggleGroup('sections')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>SECTIONS</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
title="Customize sections"
|
||||
aria-label="Customize sections"
|
||||
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
|
||||
{/* Sections header + entries */}
|
||||
<div className="border-b border-border">
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
onClick={() => toggleGroup('sections')}
|
||||
>
|
||||
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>SECTIONS</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
title="Customize sections"
|
||||
aria-label="Customize sections"
|
||||
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
|
||||
>
|
||||
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
</div>
|
||||
|
||||
{/* Sortable section groups */}
|
||||
{!groupCollapsed.sections && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
{/* Sortable section groups */}
|
||||
{!groupCollapsed.sections && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder tree */}
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} collapsed={groupCollapsed.folders} onToggle={() => toggleGroup('folders')} />
|
||||
|
||||
@@ -36,8 +36,12 @@ describe('TitleField', () => {
|
||||
expect(input).toHaveValue('Keep This')
|
||||
})
|
||||
|
||||
it('shows filename indicator when slug differs from current filename', () => {
|
||||
it('shows filename indicator when slug differs and title is focused', () => {
|
||||
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
|
||||
// Not shown when unfocused
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
// Shown when focused
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md')
|
||||
})
|
||||
|
||||
@@ -148,4 +152,17 @@ describe('TitleField', () => {
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves vault-relative path when paths differ by symlink prefix', () => {
|
||||
// vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/...
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Workspace/laputa-app/demo-vault-v2" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
|
||||
it('handles vaultPath with trailing slash', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa/" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
|
||||
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
||||
useOptimisticTitle(title, onTitleChange)
|
||||
|
||||
// Auto-resize textarea to fit content
|
||||
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
@@ -75,7 +75,22 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}, [])
|
||||
|
||||
useEffect(() => { autoResize() }, [value, autoResize])
|
||||
useEffect(() => {
|
||||
autoResize()
|
||||
// Schedule a second measurement after the browser has painted, to catch
|
||||
// cases where scrollHeight is stale on the first read (e.g. tab switch).
|
||||
const id = requestAnimationFrame(autoResize)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [value, autoResize])
|
||||
|
||||
// Re-measure when container width changes (text may re-wrap)
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(autoResize)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [autoResize])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
@@ -104,15 +119,25 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
|
||||
const currentStem = filename.replace(/\.md$/, '')
|
||||
|
||||
// Compute vault-relative path (only for notes in subdirectories)
|
||||
const relativePath = notePath && vaultPath
|
||||
? notePath.replace(vaultPath + '/', '').replace(/\.md$/, '')
|
||||
: null
|
||||
const relativePath = (() => {
|
||||
if (!notePath || !vaultPath) return null
|
||||
const vp = vaultPath.replace(/\/+$/, '')
|
||||
const np = notePath.replace(/\.md$/, '')
|
||||
if (np.startsWith(vp + '/')) return np.slice(vp.length + 1)
|
||||
// Fallback: match by vault directory name for symlink-resolved paths
|
||||
const vaultName = vp.split('/').pop()
|
||||
if (!vaultName) return null
|
||||
const segments = np.split('/')
|
||||
const idx = segments.lastIndexOf(vaultName)
|
||||
if (idx >= 0) return segments.slice(idx + 1).join('/')
|
||||
return null
|
||||
})()
|
||||
const isSubdirectory = relativePath != null && relativePath.includes('/')
|
||||
|
||||
// Show path only when title is focused and note is in a subdirectory
|
||||
const showRelativePath = isFocused && isSubdirectory
|
||||
// Show filename hint when slug differs, but suppress when path is already visible
|
||||
const showFilename = !showRelativePath && (isEditing || currentStem !== expectedSlug)
|
||||
// Show filename hint when slug differs, but only when focused (not always)
|
||||
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
|
||||
|
||||
return (
|
||||
<div className="title-field" data-testid="title-field">
|
||||
|
||||
Reference in New Issue
Block a user