Merge pull request #28 from refactoringhq/task/modified-notes-indicator

feat: modified notes indicator (orange dot in NoteList, TabBar, StatusBar)
This commit is contained in:
Luca Rossi
2026-02-23 22:41:14 +01:00
committed by GitHub
15 changed files with 736 additions and 35 deletions

View File

@@ -1,18 +1,20 @@
Task: performance-note-list
Summary: Implemented virtual list rendering for NoteList using react-virtuoso.
Task: modified-notes-indicator
Summary: Implemented visual indicators for uncommitted modified notes across the UI.
Changes:
- NoteList ListView now uses <Virtuoso> component instead of .map() for flat lists
- Only visible items are rendered in the DOM (overscan: 200px)
- EntityView (relationship groups) unchanged — groups are small (<100 items)
- mock-tauri.ts generates 9000 bulk entries for testing
- Test setup mocks react-virtuoso for JSDOM compatibility
- 7 new tests covering large dataset rendering, search, sorting, filtering
- Design file: design/performance-note-list.pen (4 frames)
- Docs updated: ARCHITECTURE.md notes virtual rendering approach
- NoteItem: Orange dot (accent-orange) before title for notes in git modified/added state
- TabBar: Orange dot between title and close button for tabs with uncommitted changes
- StatusBar: "N pending" counter with CircleDot icon when modified files exist
- useEditorSave: Added onAfterSave callback to refresh modified files list after save
- mock-tauri: Dynamically tracks saved files as modified, clears on commit
- Design file: 3 frames showing NoteList, TabBar, and StatusBar indicator states
Product decisions:
- Only virtualized ListView (flat lists), not EntityView — relationship groups
are inherently small and do not need virtualization
- Used overscan={200} for smooth scrolling experience
- PinnedCard and TrashWarningBanner rendered as Virtuoso Header component
- Used accent-orange (#D9730D) for the indicator dot to stand out from type-colored icons
- Dot appears before title in NoteList (consistent with how editors show file state)
- Dot appears between title and close button in TabBar (visible but non-intrusive)
- StatusBar shows "N pending" text (not just a number) for clarity
- Indicators disappear after git commit (mockHasChanges + mockSavedPaths cleared)
Tests: 12 new tests covering NoteList (4), TabBar (3), StatusBar (3), useEditorSave (2)
All 543 tests pass. Build succeeds. Code health gates passed.

View File

@@ -0,0 +1,537 @@
{
"version": "2.8",
"children": [
{
"type": "frame",
"id": "mni01",
"x": 0,
"y": 0,
"name": "Modified Note Indicator — NoteList States",
"width": 340,
"height": "fit_content(600)",
"fill": "$--card",
"layout": "vertical",
"children": [
{
"type": "text",
"id": "mni01t",
"fill": "$--muted-foreground",
"content": "NoteList — Modified vs Clean",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600",
"textTransform": "uppercase",
"letterSpacing": 1,
"width": "fill_container",
"padding": [8, 16]
},
{
"type": "frame",
"id": "mni02",
"name": "Note Item — Modified",
"width": "fill_container",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"children": [
{
"type": "frame",
"id": "mni02r",
"name": "titleRow",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "mni02tg",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "ellipse",
"id": "mni02dot",
"name": "Modified Indicator",
"width": 6,
"height": 6,
"fill": "$--accent-orange"
},
{
"type": "text",
"id": "mni02ti",
"fill": "$--foreground",
"content": "Build Laputa App",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
}
]
},
{
"type": "icon_font",
"id": "mni02ic",
"width": 14,
"height": 14,
"iconFontName": "wrench",
"iconFontFamily": "phosphor",
"fill": "$--accent-red"
}
]
},
{
"type": "text",
"id": "mni02sn",
"fill": "$--muted-foreground",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Personal knowledge management app built with Tauri + React.",
"lineHeight": 1.5,
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "text",
"id": "mni02tm",
"fill": "$--muted-foreground",
"content": "2m ago",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "mni03",
"name": "Note Item — Clean (no indicator)",
"width": "fill_container",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"children": [
{
"type": "frame",
"id": "mni03r",
"name": "titleRow",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "mni03ti",
"fill": "$--foreground",
"content": "Facebook Ads Strategy",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "mni03ic",
"width": 14,
"height": 14,
"iconFontName": "file-text",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
}
]
},
{
"type": "text",
"id": "mni03sn",
"fill": "$--muted-foreground",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Lookalike audiences convert 3x better than cold.",
"lineHeight": 1.5,
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "text",
"id": "mni03tm",
"fill": "$--muted-foreground",
"content": "1h ago",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "mni04",
"name": "Note Item — Added (new file)",
"width": "fill_container",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"children": [
{
"type": "frame",
"id": "mni04r",
"name": "titleRow",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "mni04tg",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "ellipse",
"id": "mni04dot",
"name": "Modified Indicator",
"width": 6,
"height": 6,
"fill": "$--accent-orange"
},
{
"type": "text",
"id": "mni04ti",
"fill": "$--foreground",
"content": "AI Agents Primer",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
}
]
},
{
"type": "icon_font",
"id": "mni04ic",
"width": 14,
"height": 14,
"iconFontName": "file-text",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
}
]
},
{
"type": "text",
"id": "mni04sn",
"fill": "$--muted-foreground",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "How autonomous agents are changing software development.",
"lineHeight": 1.5,
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "text",
"id": "mni04tm",
"fill": "$--muted-foreground",
"content": "5m ago",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
}
]
}
]
},
{
"type": "frame",
"id": "mni10",
"x": 380,
"y": 0,
"name": "Modified Note Indicator — TabBar States",
"width": 800,
"height": 120,
"fill": "$--sidebar",
"layout": "vertical",
"children": [
{
"type": "text",
"id": "mni10t",
"fill": "$--muted-foreground",
"content": "TabBar — Modified Dot on Dirty Tabs",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600",
"textTransform": "uppercase",
"letterSpacing": 1,
"padding": [8, 16]
},
{
"type": "frame",
"id": "mni11",
"name": "Tab Bar with Modified Indicators",
"width": "fill_container",
"height": 45,
"fill": "$--sidebar",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--sidebar-border" },
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "mni11a",
"name": "Tab Active — Modified",
"height": "fill_container",
"fill": "$--background",
"stroke": { "align": "inside", "thickness": { "right": 1 }, "fill": "$--border" },
"gap": 6,
"padding": [0, 14],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "mni11at",
"fill": "$--foreground",
"content": "Build Laputa App",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "ellipse",
"id": "mni11adot",
"name": "Tab Modified Dot",
"width": 6,
"height": 6,
"fill": "$--accent-orange"
},
{
"type": "icon_font",
"id": "mni11ax",
"width": 14,
"height": 14,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "mni11b",
"name": "Tab Inactive — Clean",
"height": "fill_container",
"stroke": { "align": "inside", "thickness": { "right": 1, "bottom": 1 }, "fill": "$--sidebar-border" },
"gap": 6,
"padding": [0, 14],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "mni11bt",
"fill": "$--muted-foreground",
"content": "Facebook Ads Strategy",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "icon_font",
"id": "mni11bx",
"opacity": 0,
"width": 14,
"height": 14,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "mni11c",
"name": "Tab Inactive — Modified",
"height": "fill_container",
"stroke": { "align": "inside", "thickness": { "right": 1, "bottom": 1 }, "fill": "$--sidebar-border" },
"gap": 6,
"padding": [0, 14],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "mni11ct",
"fill": "$--muted-foreground",
"content": "AI Agents Primer",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "ellipse",
"id": "mni11cdot",
"name": "Tab Modified Dot",
"width": 6,
"height": 6,
"fill": "$--accent-orange"
},
{
"type": "icon_font",
"id": "mni11cx",
"opacity": 0,
"width": 14,
"height": 14,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "mni11sp",
"name": "tabSpacer",
"width": "fill_container",
"height": "fill_container",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }
},
{
"type": "frame",
"id": "mni11ctrl",
"name": "tabControls",
"height": "fill_container",
"stroke": { "align": "inside", "thickness": { "bottom": 1, "left": 1 }, "fill": "$--border" },
"gap": 12,
"padding": [0, 12],
"justifyContent": "space_around",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "mni11plus",
"width": 16,
"height": 16,
"iconFontName": "plus",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "mni20",
"x": 0,
"y": 400,
"name": "Modified Note Indicator — StatusBar Pending Count",
"width": 800,
"height": 80,
"fill": "$--background",
"layout": "vertical",
"children": [
{
"type": "text",
"id": "mni20t",
"fill": "$--muted-foreground",
"content": "StatusBar — Pending Changes Counter",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600",
"textTransform": "uppercase",
"letterSpacing": 1,
"padding": [8, 16]
},
{
"type": "frame",
"id": "mni21",
"name": "StatusBar with Pending Count",
"width": "fill_container",
"height": 30,
"fill": "$--sidebar",
"stroke": { "align": "inside", "thickness": { "top": 1 }, "fill": "$--border" },
"padding": [0, 8],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "mni21l",
"name": "left",
"gap": 12,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "mni21v",
"gap": 4,
"alignItems": "center",
"children": [
{ "type": "icon_font", "id": "mni21fi", "width": 13, "height": 13, "iconFontName": "folder-open", "iconFontFamily": "lucide", "fill": "$--muted-foreground" },
{ "type": "text", "id": "mni21fl", "fill": "$--muted-foreground", "content": "Demo v2", "fontFamily": "Inter", "fontSize": 11 }
]
},
{ "type": "text", "id": "mni21s1", "fill": "$--border", "content": "|", "fontFamily": "Inter", "fontSize": 11 },
{
"type": "frame",
"id": "mni21br",
"gap": 4,
"alignItems": "center",
"children": [
{ "type": "icon_font", "id": "mni21gi", "width": 13, "height": 13, "iconFontName": "git-branch", "iconFontFamily": "lucide", "fill": "$--muted-foreground" },
{ "type": "text", "id": "mni21gl", "fill": "$--muted-foreground", "content": "main", "fontFamily": "Inter", "fontSize": 11 }
]
},
{ "type": "text", "id": "mni21s2", "fill": "$--border", "content": "|", "fontFamily": "Inter", "fontSize": 11 },
{
"type": "frame",
"id": "mni21sy",
"gap": 4,
"alignItems": "center",
"children": [
{ "type": "icon_font", "id": "mni21ri", "width": 13, "height": 13, "iconFontName": "refresh-cw", "iconFontFamily": "lucide", "fill": "$--accent-green" },
{ "type": "text", "id": "mni21rl", "fill": "$--muted-foreground", "content": "Synced 2m ago", "fontFamily": "Inter", "fontSize": 11 }
]
},
{ "type": "text", "id": "mni21s3", "fill": "$--border", "content": "|", "fontFamily": "Inter", "fontSize": 11 },
{
"type": "frame",
"id": "mni21pd",
"name": "Pending Count",
"gap": 4,
"alignItems": "center",
"children": [
{ "type": "icon_font", "id": "mni21pi", "width": 13, "height": 13, "iconFontName": "circle-dot", "iconFontFamily": "lucide", "fill": "$--accent-orange" },
{ "type": "text", "id": "mni21pl", "fill": "$--muted-foreground", "content": "3 pending", "fontFamily": "Inter", "fontSize": 11 }
]
}
]
},
{
"type": "frame",
"id": "mni21r",
"name": "right",
"gap": 12,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "mni21nc",
"gap": 4,
"alignItems": "center",
"children": [
{ "type": "icon_font", "id": "mni21ni", "width": 13, "height": 13, "iconFontName": "file-text", "iconFontFamily": "lucide", "fill": "$--muted-foreground" },
{ "type": "text", "id": "mni21nl", "fill": "$--muted-foreground", "content": "9,200 notes", "fontFamily": "Inter", "fontSize": 11 }
]
},
{ "type": "icon_font", "id": "mni21set", "width": 14, "height": 14, "iconFontName": "settings", "iconFontFamily": "lucide", "fill": "$--muted-foreground" }
]
}
]
}
]
}
],
"themes": { "Mode": ["Dark"] },
"variables": {},
"fonts": []
}

View File

@@ -69,13 +69,13 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
│Events │ │ │ │
│Topics │ │ │ │
├────────┴─────────────┴─────────────────────────┴────────────┤
│ StatusBar: v0.4.2 │ main │ Synced 2m ago 1,247 notes│
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ 3 pending notes│
└─────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.)
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, and relationship groups. The flat list view (All Notes, type sections, etc.) uses **react-virtuoso** for virtual rendering — only visible items are in the DOM, enabling smooth scrolling with 9000+ notes. Entity/relationship views are not virtualized (groups are small).
- **Editor** (flex, fills remaining space): Tab bar, breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files.
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, relationship groups, and orange dot indicators for uncommitted modified notes.
- **Editor** (flex, fills remaining space): Tab bar (with orange modified dots on dirty tabs), breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files.
- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them.
Panels are separated by `ResizeHandle` components that support drag-to-resize.

View File

@@ -91,6 +91,7 @@ function App() {
updateVaultContent: vault.updateContent,
setTabs: notes.setTabs,
setToastMessage,
onAfterSave: vault.loadModifiedFiles,
})
const entryActions = useEntryActions({
@@ -271,7 +272,7 @@ function App() {
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onCreateNewVault={handleCreateNewVault} 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} onCreateNewVault={handleCreateNewVault} onConnectGitHub={() => setShowGitHubVault(true)} 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

@@ -439,6 +439,7 @@ export const Editor = memo(function Editor({
<TabBar
tabs={tabs}
activeTabPath={activeTabPath}
isModified={isModified}
onSwitchTab={onSwitchTab}
onCloseTab={onCloseTab}
onCreateNote={onCreateNote}

View File

@@ -46,9 +46,10 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) {
)
}
export function NoteItem({ entry, isSelected, typeEntryMap, onClickNote }: {
export function NoteItem({ entry, isSelected, isModified, typeEntryMap, onClickNote }: {
entry: VaultEntry
isSelected: boolean
isModified?: boolean
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
@@ -74,6 +75,14 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onClickNote }: {
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
{isModified && (
<span
className="mr-1.5 inline-block align-middle"
style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-orange)', verticalAlign: 'middle' }}
data-testid="modified-indicator"
title="Modified (uncommitted)"
/>
)}
{entry.title}
{entry.archived && (
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>

View File

@@ -716,6 +716,47 @@ describe('filterEntries — trash', () => {
})
})
describe('NoteList — modified indicators', () => {
it('shows modified indicator dot for entries in modifiedFiles', () => {
const modifiedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
]
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} onCreateNote={vi.fn()} />
)
const indicators = screen.getAllByTestId('modified-indicator')
expect(indicators).toHaveLength(1)
// The indicator's parent div contains the title text
const noteRow = indicators[0].closest('[data-testid="modified-indicator"]')!.parentElement!.parentElement!
expect(noteRow.textContent).toContain('Build Laputa App')
})
it('does not show modified indicator when modifiedFiles is empty', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={[]} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
})
it('shows multiple modified indicators for multiple modified files', () => {
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 },
]
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} onCreateNote={vi.fn()} />
)
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
})
it('does not show modified indicator when modifiedFiles prop is undefined', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
})
})
describe('NoteList — trash view', () => {
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }

View File

@@ -247,6 +247,11 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const modifiedPathSet = useMemo(
() => new Set((modifiedFiles ?? []).map((f) => f.path)),
[modifiedFiles],
)
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}, [])
@@ -267,8 +272,8 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
}, [onSelectNote, onReplaceActiveTab])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap])
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isModified={modifiedPathSet.has(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, modifiedPathSet])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>

View File

@@ -128,6 +128,22 @@ describe('StatusBar', () => {
expect(screen.getByText('Connect GitHub repo')).toBeInTheDocument()
})
it('shows modified count when modifiedCount is > 0', () => {
render(<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.getByTestId('status-modified-count')).toBeInTheDocument()
expect(screen.getByText('3 pending')).toBeInTheDocument()
})
it('does not show modified count when modifiedCount is 0', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
})
it('does not show modified count when modifiedCount is not provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
})
it('closes menu after clicking "Open local folder"', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, FolderPlus } from 'lucide-react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, FolderPlus, CircleDot } from 'lucide-react'
export interface VaultOption {
label: string
@@ -8,6 +8,7 @@ export interface VaultOption {
interface StatusBarProps {
noteCount: number
modifiedCount?: number
vaultPath: string
vaults: VaultOption[]
onSwitchVault: (path: string) => void
@@ -121,7 +122,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, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onCreateNewVault, onConnectGitHub, hasGitHub }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onCreateNewVault, onConnectGitHub, 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 }}>
@@ -132,6 +133,12 @@ export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenS
<span style={ICON_STYLE}><GitBranch size={13} />main</span>
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><RefreshCw size={13} style={{ color: 'var(--accent-green)' }} />Synced 2m ago</span>
{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>
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>

View File

@@ -110,6 +110,28 @@ describe('TabBar', () => {
expect(onReorderTabs).not.toHaveBeenCalled()
})
it('shows modified indicator dot on modified tabs', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const isModified = (path: string) => path === tabs[0].entry.path
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} isModified={isModified} {...defaultProps} />)
const indicators = screen.getAllByTestId('tab-modified-indicator')
expect(indicators).toHaveLength(1)
})
it('does not show modified indicator when no tabs are modified', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const isModified = () => false
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} isModified={isModified} {...defaultProps} />)
expect(screen.queryByTestId('tab-modified-indicator')).not.toBeInTheDocument()
})
it('shows modified indicator on multiple tabs', () => {
const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
const isModified = () => true
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} isModified={isModified} {...defaultProps} />)
expect(screen.getAllByTestId('tab-modified-indicator')).toHaveLength(3)
})
it('switches tab on click', () => {
const onSwitchTab = vi.fn()
const tabs = makeTabs(['Alpha', 'Beta'])

View File

@@ -12,6 +12,7 @@ interface Tab {
interface TabBarProps {
tabs: Tab[]
activeTabPath: string | null
isModified?: (path: string) => boolean
onSwitchTab: (path: string) => void
onCloseTab: (path: string) => void
onCreateNote?: () => void
@@ -151,10 +152,11 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
)
}
function TabItem({ tab, isActive, isEditing, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
function TabItem({ tab, isActive, isEditing, isModified, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
tab: Tab
isActive: boolean
isEditing: boolean
isModified: boolean
isDragging: boolean
showDropBefore: boolean
showDropAfter: boolean
@@ -192,6 +194,14 @@ function TabItem({ tab, isActive, isEditing, isDragging, showDropBefore, showDro
{tab.entry.title}
</span>
)}
{isModified && (
<span
className="shrink-0"
style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-orange)' }}
data-testid="tab-modified-indicator"
title="Modified (uncommitted)"
/>
)}
<button
className={cn(
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
@@ -233,7 +243,7 @@ function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
// --- Main TabBar ---
export const TabBar = memo(function TabBar({
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
tabs, activeTabPath, isModified, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
}: TabBarProps) {
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
const [editingPath, setEditingPath] = useState<string | null>(null)
@@ -251,6 +261,7 @@ export const TabBar = memo(function TabBar({
tab={tab}
isActive={tab.entry.path === activeTabPath}
isEditing={editingPath === tab.entry.path}
isModified={isModified?.(tab.entry.path) ?? false}
isDragging={dragIndex !== null}
showDropBefore={dropIndex === index}
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useEditorSave } from './useEditorSave'
@@ -15,9 +15,9 @@ vi.mock('../mock-tauri', () => ({
}))
describe('useEditorSave', () => {
let updateVaultContent: vi.Mock
let setTabs: vi.Mock
let setToastMessage: vi.Mock
let updateVaultContent: Mock
let setTabs: Mock
let setToastMessage: Mock
beforeEach(() => {
updateVaultContent = vi.fn()
@@ -107,6 +107,43 @@ describe('useEditorSave', () => {
})
})
it('calls onAfterSave callback after successful save', async () => {
const onAfterSave = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
)
act(() => {
result.current.handleContentChange('/test/note.md', 'new content')
})
await act(async () => {
await result.current.handleSave()
})
expect(onAfterSave).toHaveBeenCalledOnce()
})
it('does not call onAfterSave when save fails', async () => {
mockInvokeFn.mockRejectedValueOnce(new Error('Disk full'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const onAfterSave = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
)
act(() => {
result.current.handleContentChange('/test/note.md', 'content')
})
await act(async () => {
await result.current.handleSave()
})
expect(onAfterSave).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('handleContentChange buffers the latest content', () => {
const { result } = renderSaveHook()

View File

@@ -12,13 +12,14 @@ interface EditorSaveConfig {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tab types vary between layers
setTabs: (fn: SetStateAction<any[]>) => void
setToastMessage: (msg: string | null) => void
onAfterSave?: () => void
}
/**
* Hook that manages explicit save (Cmd+S) for editor content.
* Tracks pending (unsaved) content and provides save + pre-rename helpers.
*/
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage }: EditorSaveConfig) {
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave }: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const updateTabAndContent = useCallback((path: string, content: string) => {
@@ -41,11 +42,12 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage }:
await saveNote(pending.path, pending.content)
pendingContentRef.current = null
setToastMessage('Saved')
onAfterSave?.()
} catch (err) {
console.error('Save failed:', err)
setToastMessage(`Save failed: ${err}`)
}
}, [saveNote, setToastMessage])
}, [saveNote, setToastMessage, onAfterSave])
/** Called by Editor onChange — buffers the latest content without saving */
const handleContentChange = useCallback((path: string, content: string) => {

View File

@@ -1654,9 +1654,9 @@ function mockModifiedFiles(): ModifiedFile[] {
status: 'modified',
},
{
path: '/Users/luca/Laputa/note/new-draft.md',
relativePath: 'note/new-draft.md',
status: 'untracked',
path: '/Users/luca/Laputa/essay/ai-agents-primer.md',
relativePath: 'essay/ai-agents-primer.md',
status: 'added',
},
]
}
@@ -1700,6 +1700,7 @@ index abc1234..${shortHash} 100644
}
let mockHasChanges = true
const mockSavedPaths = new Set<string>()
let mockSettings: Settings = {
anthropic_key: null,
@@ -1714,11 +1715,19 @@ const mockHandlers: Record<string, (args: any) => any> = {
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
get_all_content: () => MOCK_CONTENT,
get_file_history: (args: { path: string }) => mockFileHistory(args.path),
get_modified_files: () => mockHasChanges ? mockModifiedFiles() : [],
get_modified_files: () => {
const base = mockHasChanges ? mockModifiedFiles() : []
const basePaths = new Set(base.map((f) => f.path))
const extra: ModifiedFile[] = [...mockSavedPaths]
.filter((p) => !basePaths.has(p))
.map((p) => ({ path: p, relativePath: p.split('/').slice(-2).join('/'), status: 'modified' as const }))
return [...base, ...extra]
},
get_file_diff: (args: { path: string }) => mockFileDiff(args.path),
get_file_diff_at_commit: (args: { path: string; commitHash: string }) => mockFileDiffAtCommit(args.path, args.commitHash),
git_commit: (args: { message: string }) => {
mockHasChanges = false
mockSavedPaths.clear()
return `[main abc1234] ${args.message}\n 3 files changed`
},
git_push: () => {
@@ -1743,6 +1752,7 @@ const mockHandlers: Record<string, (args: any) => any> = {
},
save_note_content: (args: { path: string; content: string }) => {
MOCK_CONTENT[args.path] = args.content
mockSavedPaths.add(args.path)
if (typeof window !== 'undefined') {
window.__mockContent = MOCK_CONTENT
}