Merge pull request #45 from refactoringhq/task/vista-changes

feat: Changes view — clicking N pending shows modified notes
This commit is contained in:
Luca Rossi
2026-02-24 16:00:37 +01:00
committed by GitHub
9 changed files with 612 additions and 14 deletions

496
design/vista-changes.pen Normal file
View File

@@ -0,0 +1,496 @@
{
"children": [
{
"type": "frame",
"id": "vc001",
"x": 0,
"y": 0,
"name": "Changes — sidebar section with pending notes",
"clip": true,
"width": 550,
"height": 500,
"fill": "$--background",
"layout": "horizontal",
"children": [
{
"type": "frame",
"id": "vc002",
"name": "Sidebar",
"width": 250,
"height": "fill_container",
"fill": "$--sidebar",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "vc003",
"name": "Filters",
"width": "fill_container",
"layout": "vertical",
"gap": 1,
"padding": [8, 8],
"stroke": {
"align": "inside",
"thickness": { "bottom": 1 },
"fill": "$--border"
},
"children": [
{
"type": "frame",
"id": "vc004",
"name": "filterAll",
"width": "fill_container",
"cornerRadius": 6,
"gap": 8,
"padding": [6, 16],
"alignItems": "center",
"children": [
{ "type": "text", "id": "vc005", "text": "All Notes", "fontSize": 13, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc006",
"name": "filterFavorites",
"width": "fill_container",
"cornerRadius": 6,
"gap": 8,
"padding": [6, 16],
"alignItems": "center",
"children": [
{ "type": "text", "id": "vc007", "text": "Favorites", "fontSize": 13, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc008",
"name": "filterTrash",
"width": "fill_container",
"cornerRadius": 6,
"gap": 8,
"padding": [6, 16],
"alignItems": "center",
"children": [
{ "type": "text", "id": "vc009", "text": "Trash", "fontSize": 13, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc010",
"name": "filterChanges-active",
"width": "fill_container",
"fill": "#F5750018",
"cornerRadius": 6,
"gap": 8,
"padding": [6, 16],
"alignItems": "center",
"justifyContent": "space-between",
"children": [
{ "type": "text", "id": "vc011", "text": "Changes", "fontSize": 13, "fontWeight": 500, "fill": "$--accent-orange" },
{
"type": "frame",
"id": "vc012",
"name": "badge",
"fill": "$--accent-orange",
"cornerRadius": 9999,
"padding": [0, 6],
"height": 20,
"alignItems": "center",
"children": [
{ "type": "text", "id": "vc013", "text": "3", "fontSize": 10, "fontWeight": 600, "fill": "$--white" }
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "vc014",
"name": "NoteList: Changes",
"width": 300,
"height": "fill_container",
"fill": "$--card",
"layout": "vertical",
"stroke": {
"align": "inside",
"thickness": { "right": 1 },
"fill": "$--border"
},
"children": [
{
"type": "frame",
"id": "vc015",
"name": "header",
"width": "fill_container",
"height": 45,
"padding": [0, 16],
"alignItems": "center",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "vc016", "text": "Changes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc017",
"name": "note1-modified",
"width": "fill_container",
"padding": [10, 16],
"gap": 4,
"layout": "vertical",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "vc018",
"name": "titleRow",
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "ellipse", "id": "vc019", "name": "modifiedDot", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc020", "text": "Build Laputa App", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground" }
]
},
{ "type": "text", "id": "vc021", "text": "This paragraph has bold text, italic text…", "fontSize": 12, "fill": "$--muted-foreground" }
]
},
{
"type": "frame",
"id": "vc022",
"name": "note2-modified",
"width": "fill_container",
"padding": [10, 16],
"gap": 4,
"layout": "vertical",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "vc023",
"name": "titleRow",
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "ellipse", "id": "vc024", "name": "modifiedDot", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc025", "text": "Facebook Ads Strategy", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground" }
]
},
{ "type": "text", "id": "vc026", "text": "Lookalike audiences from newsletter subscribers…", "fontSize": 12, "fill": "$--muted-foreground" }
]
},
{
"type": "frame",
"id": "vc027",
"name": "note3-added",
"width": "fill_container",
"padding": [10, 16],
"gap": 4,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "vc028",
"name": "titleRow",
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "ellipse", "id": "vc029", "name": "modifiedDot", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc030", "text": "AI Agents Primer", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground" }
]
},
{ "type": "text", "id": "vc031", "text": "AI agents are autonomous systems that can plan…", "fontSize": 12, "fill": "$--muted-foreground" }
]
}
]
}
]
},
{
"type": "frame",
"id": "vc100",
"x": 600,
"y": 0,
"name": "Changes — empty state (0 pending)",
"clip": true,
"width": 550,
"height": 400,
"fill": "$--background",
"layout": "horizontal",
"children": [
{
"type": "frame",
"id": "vc101",
"name": "Sidebar (no Changes item)",
"width": 250,
"height": "fill_container",
"fill": "$--sidebar",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "vc102",
"name": "Filters",
"width": "fill_container",
"layout": "vertical",
"gap": 1,
"padding": [8, 8],
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "vc103",
"name": "filterAll-active",
"width": "fill_container",
"fill": "#4a9eff18",
"cornerRadius": 6,
"gap": 8,
"padding": [6, 16],
"alignItems": "center",
"children": [
{ "type": "text", "id": "vc104", "text": "All Notes", "fontSize": 13, "fontWeight": 500, "fill": "$--primary" }
]
},
{
"type": "frame",
"id": "vc105",
"name": "filterFavorites",
"width": "fill_container",
"cornerRadius": 6,
"gap": 8,
"padding": [6, 16],
"alignItems": "center",
"children": [
{ "type": "text", "id": "vc106", "text": "Favorites", "fontSize": 13, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc107",
"name": "filterTrash",
"width": "fill_container",
"cornerRadius": 6,
"gap": 8,
"padding": [6, 16],
"alignItems": "center",
"children": [
{ "type": "text", "id": "vc108", "text": "Trash", "fontSize": 13, "fill": "$--foreground" }
]
}
]
},
{
"type": "frame",
"id": "vc109",
"name": "annotation",
"width": "fill_container",
"padding": [12, 16],
"children": [
{ "type": "text", "id": "vc110", "text": "← No \"Changes\" item when 0 pending", "fontSize": 11, "fill": "$--muted-foreground", "fontStyle": "italic" }
]
}
]
},
{
"type": "frame",
"id": "vc111",
"name": "NoteList: All Notes",
"width": 300,
"height": "fill_container",
"fill": "$--card",
"layout": "vertical",
"stroke": { "align": "inside", "thickness": { "right": 1 }, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "vc112",
"name": "header",
"width": "fill_container",
"height": 45,
"padding": [0, 16],
"alignItems": "center",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "vc113", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc114",
"name": "notesList",
"width": "fill_container",
"height": "fill_container",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "vc115",
"name": "note-no-dot",
"width": "fill_container",
"padding": [10, 16],
"gap": 4,
"layout": "vertical",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "vc116", "text": "Build Laputa App", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground" },
{ "type": "text", "id": "vc117", "text": "No orange dot — all committed", "fontSize": 12, "fill": "$--muted-foreground" }
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "vc200",
"x": 0,
"y": 560,
"name": "Changes — real-time update (note disappears after save)",
"clip": true,
"width": 800,
"height": 300,
"fill": "$--background",
"layout": "horizontal",
"gap": 40,
"padding": [20, 20],
"children": [
{
"type": "frame",
"id": "vc201",
"name": "Before: 3 pending",
"width": 340,
"height": "fill_container",
"fill": "$--card",
"layout": "vertical",
"cornerRadius": 8,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "vc202",
"name": "header",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"alignItems": "center",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "vc203", "text": "Changes (before save)", "fontSize": 12, "fontWeight": 600, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc204",
"name": "item1",
"width": "fill_container",
"padding": [8, 12],
"gap": 6,
"alignItems": "center",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "ellipse", "id": "vc205", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc206", "text": "Build Laputa App", "fontSize": 12, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc207",
"name": "item2-highlight",
"width": "fill_container",
"padding": [8, 12],
"gap": 6,
"fill": "#F5750010",
"alignItems": "center",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "ellipse", "id": "vc208", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc209", "text": "Facebook Ads Strategy ← saving…", "fontSize": 12, "fontWeight": 500, "fill": "$--accent-orange" }
]
},
{
"type": "frame",
"id": "vc210",
"name": "item3",
"width": "fill_container",
"padding": [8, 12],
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "ellipse", "id": "vc211", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc212", "text": "AI Agents Primer", "fontSize": 12, "fill": "$--foreground" }
]
}
]
},
{
"type": "frame",
"id": "vc220",
"name": "After: 2 pending (note committed)",
"width": 340,
"height": "fill_container",
"fill": "$--card",
"layout": "vertical",
"cornerRadius": 8,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "vc221",
"name": "header",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"alignItems": "center",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "vc222", "text": "Changes (after save + commit)", "fontSize": 12, "fontWeight": 600, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc223",
"name": "item1",
"width": "fill_container",
"padding": [8, 12],
"gap": 6,
"alignItems": "center",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "ellipse", "id": "vc224", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc225", "text": "Build Laputa App", "fontSize": 12, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc226",
"name": "item2",
"width": "fill_container",
"padding": [8, 12],
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "ellipse", "id": "vc227", "fill": "$--accent-orange", "width": 6, "height": 6 },
{ "type": "text", "id": "vc228", "text": "AI Agents Primer", "fontSize": 12, "fill": "$--foreground" }
]
},
{
"type": "frame",
"id": "vc229",
"name": "removedNote",
"width": "fill_container",
"padding": [12, 12],
"children": [
{ "type": "text", "id": "vc230", "text": "Facebook Ads Strategy removed (committed)", "fontSize": 11, "fontStyle": "italic", "fill": "$--muted-foreground" }
]
}
]
}
]
}
],
"variables": {}
}

View File

@@ -246,7 +246,7 @@ function App() {
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onConnectGitHub={() => setShowGitHubVault(true)} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={() => setShowQuickOpen(false)} />
<CreateTypeDialog open={showCreateTypeDialog} onClose={() => setShowCreateTypeDialog(false)} onCreate={handleCreateType} />

View File

@@ -896,4 +896,52 @@ describe('NoteList — virtual list with large datasets', () => {
fireEvent.click(screen.getByText('Note 50'))
expect(noopReplace).toHaveBeenCalledWith(entries[50])
})
describe('changes filter', () => {
const changesSelection: SidebarSelection = { kind: 'filter', filter: 'changes' }
const modifiedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
]
it('shows only modified notes in changes view', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument()
})
it('shows header title "Changes"', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Changes')).toBeInTheDocument()
})
it('shows empty state when no modified files', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('No pending changes')).toBeInTheDocument()
})
it('updates list when modifiedFiles changes', () => {
const { rerender } = render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
// Simulate one file being committed (removed from modifiedFiles)
const fewerModified = [modifiedFiles[0]]
rerender(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
})
})
})

View File

@@ -99,6 +99,7 @@ function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntr
if (typeDocument) return typeDocument.title
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash'
if (selection.kind === 'filter' && selection.filter === 'changes') return 'Changes'
return 'Notes'
}
@@ -146,13 +147,13 @@ function ListViewHeader({ typeDocument, isTrashView, expiredTrashCount, typeEntr
)
}
function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const hasHeader = typeDocument || (isTrashView && expiredTrashCount > 0)
if (searched.length === 0) {
@@ -208,11 +209,13 @@ function routeNoteClick(
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>
}
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection }: NoteListDataParams) {
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
@@ -221,9 +224,13 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const searched = useMemo(() => {
if (isEntityView) return []
if (isChangesView) {
const sorted = [...entries.filter((e) => modifiedPathSet.has(e.path))].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}
const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}, [entries, selection, isEntityView, listSort, listDirection, query])
}, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -236,7 +243,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
[isTrashView, searched],
)
return { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount }
return { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount }
}
// --- Main component ---
@@ -265,7 +272,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const listSort = listConfig.option
const listDirection = listConfig.direction
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection })
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, onSelectNote, onReplaceActiveTab)
@@ -300,7 +307,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
<ListView typeDocument={typeDocument} isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
)}
</div>
</div>

View File

@@ -334,7 +334,26 @@ describe('Sidebar', () => {
it('shows badge on commit button when modified files exist', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} onCommitPush={() => {}} />)
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
const badges = screen.getAllByText('3')
expect(badges.length).toBeGreaterThanOrEqual(1)
})
it('shows Changes nav item when modifiedCount > 0', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={5} />)
expect(screen.getByText('Changes')).toBeInTheDocument()
})
it('hides Changes nav item when modifiedCount is 0', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={0} />)
expect(screen.queryByText('Changes')).not.toBeInTheDocument()
})
it('calls onSelect with changes filter when clicking Changes', () => {
const onSelect = vi.fn()
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} modifiedCount={3} />)
fireEvent.click(screen.getByText('Changes'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'changes' })
})
describe('dynamic custom type sections', () => {

View File

@@ -12,7 +12,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -296,6 +296,9 @@ export const Sidebar = memo(function Sidebar({
<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={TagSimple} label="Untagged" disabled />
<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' })} />
{modifiedCount > 0 && (
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
)}
</div>
{/* Sections header + visibility popover */}

View File

@@ -133,4 +133,20 @@ describe('StatusBar', () => {
// Menu should close after clicking an action
expect(screen.queryByText('Open local folder')).not.toBeInTheDocument()
})
it('calls onClickPending when clicking the pending count', () => {
const onClickPending = vi.fn()
render(
<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onClickPending={onClickPending} />
)
fireEvent.click(screen.getByTestId('status-modified-count'))
expect(onClickPending).toHaveBeenCalledOnce()
})
it('pending count has title for accessibility', () => {
render(
<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onClickPending={vi.fn()} />
)
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
})
})

View File

@@ -15,6 +15,7 @@ interface StatusBarProps {
onOpenSettings?: () => void
onOpenLocalFolder?: () => void
onConnectGitHub?: () => void
onClickPending?: () => void
hasGitHub?: boolean
}
@@ -104,7 +105,7 @@ const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
const SEP_STYLE = { color: 'var(--border)' } as const
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, hasGitHub }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub }: StatusBarProps) {
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -118,7 +119,15 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{modifiedCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE} data-testid="status-modified-count"><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
<span
role="button"
onClick={onClickPending}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="View pending changes"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
</>
)}
</div>

View File

@@ -79,7 +79,7 @@ export interface GithubRepo {
}
export type SidebarSelection =
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' }
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' }
| { kind: 'sectionGroup'; type: string }
| { kind: 'entity'; entry: VaultEntry }
| { kind: 'topic'; entry: VaultEntry }