Compare commits

...

3 Commits

Author SHA1 Message Date
Test
8f8954a6f7 fix: inbox sidebar ordering, filter chip wrapping, and editor banner architecture
- Move Inbox to first position in sidebar (before All Notes)
- Add whitespace-nowrap to filter pills + flex-wrap on container so chips
  wrap as whole units instead of breaking text internally
- Editor now reads trashed/archived state from fresh vault entries instead
  of potentially stale tab entry, ensuring banners appear regardless of
  navigation context
- Update tests to pass correct entries prop alongside tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:36:59 +01:00
Test
99f5716508 feat: show emoji icon in sidebar note items
Pass the icon field from VaultEntry to SectionChildItem and render
it before the title when present, matching the pattern used in
NoteItem and other contexts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:58:34 +01:00
Test
b8f29c9530 fix: align type selector chip left using grid layout matching property rows
The type selector was using flex justify-between, pushing the chip to the
right side of the Properties panel. Switch to grid-cols-2 layout (matching
PropertyRow and InfoRow) so the chip sits left-aligned in the value column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:27:51 +01:00
8 changed files with 83 additions and 49 deletions

View File

@@ -300,7 +300,7 @@ describe('Editor', () => {
const trashedTab = { entry: trashedEntry, content: mockContent }
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
return render(<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
}
it('shows banner and read-only editor when note is trashed', () => {
@@ -336,9 +336,10 @@ describe('Editor', () => {
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
const updatedTab = { entry: trashedEntryUpdated, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[trashedEntryUpdated]} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
@@ -346,13 +347,14 @@ describe('Editor', () => {
it('removes trash banner immediately when entry is restored (reactive)', () => {
const { rerender } = render(
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null }
const restoredTab = { entry: restoredEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[restoredEntry]} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
@@ -408,6 +410,7 @@ describe('click empty editor space', () => {
render(
<Editor
{...defaultProps}
entries={[trashedEntry]}
tabs={[{ entry: trashedEntry, content: mockContent }]}
activeTabPath={trashedEntry.path}
/>
@@ -429,9 +432,10 @@ describe('archived note behavior', () => {
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
const archivedEntry = { ...mockEntry, archived: true }
const archivedTab = { entry: archivedEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
})
@@ -440,13 +444,14 @@ describe('archived note behavior', () => {
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
const archivedTab = { entry: archivedEntry, content: mockContent }
const { rerender } = render(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
const unarchivedEntry = { ...archivedEntry, archived: false }
const unarchivedTab = { entry: unarchivedEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[unarchivedEntry]} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
})

View File

@@ -161,7 +161,11 @@ export function EditorContent({
isConflicted, onKeepMine, onKeepTheirs,
...breadcrumbProps
}: EditorContentProps) {
const isTrashed = activeTab?.entry.trashed ?? false
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
// so the banner appears regardless of navigation context.
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
const showEditor = !diffMode && !rawMode
const entryIcon = activeTab?.entry.icon ?? null
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
@@ -188,7 +192,7 @@ export function EditorContent({
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
{activeTab && isArchived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
{activeTab && isConflicted && (

View File

@@ -1038,4 +1038,24 @@ describe('Sidebar', () => {
const mondaySections = screen.getAllByText(/Monday Ideas/i)
expect(mondaySections).toHaveLength(1)
})
it('renders Inbox as the first item in the top nav', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={5} />)
const topNav = screen.getByTestId('sidebar-top-nav')
const items = topNav.children
expect(items[0].textContent).toContain('Inbox')
expect(items[1].textContent).toContain('All Notes')
})
it('displays inbox count badge', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={12} />)
expect(screen.getByText('12')).toBeInTheDocument()
})
it('calls onSelect with inbox filter when clicking Inbox', () => {
const onSelect = vi.fn()
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} inboxCount={3} />)
fireEvent.click(screen.getByText('Inbox'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
})
})

View File

@@ -306,10 +306,10 @@ export const Sidebar = memo(function Sidebar({
<nav className="flex-1 overflow-y-auto">
{/* Top nav */}
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
</div>
{/* Sections header + visibility popover */}

View File

@@ -1,6 +1,7 @@
import { type ComponentType, useState, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { isEmoji } from '../utils/emoji'
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
@@ -148,7 +149,7 @@ function SectionChildList({ items, selection, sectionColor, sectionLightColor, o
const active = isSelectionActive(selection, sel)
return (
<SectionChildItem
key={entry.path} title={entry.title} isActive={active}
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
sectionColor={active ? sectionColor : undefined}
sectionLightColor={active ? sectionLightColor : undefined}
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
@@ -237,8 +238,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
)
}
function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: {
title: string; isActive: boolean
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
title: string; icon?: string | null; isActive: boolean
sectionColor?: string; sectionLightColor?: string
onClick: () => void
}) {
@@ -248,7 +249,7 @@ function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, on
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
onClick={onClick}
>
{title}
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
</div>
)
}

View File

@@ -22,17 +22,19 @@ function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
{onNavigate ? (
<button
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
>{isA}</button>
) : (
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
)}
<div className="min-w-0">
{onNavigate ? (
<button
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
>{isA}</button>
) : (
<span className="text-[12px] text-secondary-foreground">{isA}</span>
)}
</div>
</div>
)
}
@@ -55,23 +57,24 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
const typeLightColor = isA ? getTypeLightColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
return (
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger
size="sm"
className={`h-auto shrink-0 gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
style={{
background: typeLightColor ?? undefined,
color: typeColor ?? undefined,
borderRadius: 6,
padding: '4px 8px',
fontSize: 12,
fontWeight: 500,
}}
>
<SelectValue placeholder="None" />
</SelectTrigger>
<div className="min-w-0">
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger
size="sm"
className={`h-auto max-w-full gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
style={{
background: typeLightColor ?? undefined,
color: typeColor ?? undefined,
borderRadius: 6,
padding: '4px 8px',
fontSize: 12,
fontWeight: 500,
}}
>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent position="popper" side="left">
<SelectItem value={TYPE_NONE}>None</SelectItem>
<SelectSeparator />
@@ -81,7 +84,8 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
</SelectItem>
))}
</SelectContent>
</Select>
</Select>
</div>
</div>
)
}

View File

@@ -15,14 +15,14 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
return (
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="filter-pills">
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'

View File

@@ -16,14 +16,14 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
return (
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="inbox-filter-pills">
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="inbox-filter-pills">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'