Merge pull request #45 from refactoringhq/task/vista-changes
feat: Changes view — clicking N pending shows modified notes
This commit is contained in:
496
design/vista-changes.pen
Normal file
496
design/vista-changes.pen
Normal 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": {}
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user