feat: show deleted notes in Changes view

When notes are permanently deleted, the Changes note list now shows a
"N notes deleted" banner so the counter matches the visible list items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-16 03:42:40 +01:00
parent da3ec342d4
commit 582b181f41
4 changed files with 119 additions and 15 deletions

View File

@@ -1107,6 +1107,46 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
})
it('shows deleted notes banner when files are deleted', () => {
const filesWithDeleted = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
{ path: '/Users/luca/Laputa/note/also-gone.md', relativePath: 'note/also-gone.md', status: 'deleted' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('2 notes deleted')).toBeInTheDocument()
})
it('shows singular form for single deleted note', () => {
const filesWithOneDeleted = [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithOneDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('1 note deleted')).toBeInTheDocument()
})
it('does not show deleted banner when no files are deleted', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
it('does not show deleted banner outside changes view', () => {
const filesWithDeleted = [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
]
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
})
})

View File

@@ -4,7 +4,7 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, Trash,
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, Trash, TrashSimple,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { NoteItem, getTypeIcon } from './NoteItem'
@@ -110,6 +110,16 @@ function EmptyMessage({ text }: { text: string }) {
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">{text}</div>
}
function DeletedNotesBanner({ count }: { count: number }) {
if (count === 0) return null
return (
<div className="flex items-center gap-2 border-b border-[var(--border)] opacity-60" style={{ padding: '14px 16px' }} data-testid="deleted-notes-banner">
<TrashSimple size={14} className="shrink-0 text-muted-foreground" />
<span className="text-[13px] text-muted-foreground">{count} {count === 1 ? 'note' : 'notes'} deleted</span>
</div>
)
}
function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string {
if (selection.kind === 'entity') return selection.entry.title
if (typeDocument) return typeDocument.title
@@ -158,16 +168,17 @@ function resolveEmptyText(isChangesView: boolean, changesError: string | null |
return query ? 'No matching notes' : 'No notes found'
}
function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
searched: VaultEntry[]; query: string
deletedCount?: number; searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query)
const hasHeader = isTrashView && expiredTrashCount > 0
const showDeleted = !!isChangesView && deletedCount > 0
if (searched.length === 0) {
if (searched.length === 0 && !showDeleted) {
return (
<div className="h-full overflow-y-auto">
{hasHeader && <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} />}
@@ -176,17 +187,28 @@ function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount,
)
}
if (searched.length === 0 && showDeleted) {
return (
<div className="h-full overflow-y-auto">
<DeletedNotesBanner count={deletedCount} />
</div>
)
}
return (
<Virtuoso
ref={virtuosoRef}
style={{ height: '100%' }}
data={searched}
overscan={200}
components={{
Header: hasHeader ? () => <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} /> : undefined,
}}
itemContent={(_index, entry) => renderItem(entry)}
/>
<div className="h-full flex flex-col" style={{ minHeight: 0 }}>
<Virtuoso
ref={virtuosoRef}
style={{ flex: 1 }}
data={searched}
overscan={200}
components={{
Header: hasHeader ? () => <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} /> : undefined,
}}
itemContent={(_index, entry) => renderItem(entry)}
/>
{showDeleted && <DeletedNotesBanner count={deletedCount} />}
</div>
)
}
@@ -511,6 +533,10 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
const typeEntryMap = useTypeEntryMap(entries)
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
[isChangesView, modifiedFiles],
)
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
@@ -543,7 +569,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{multiSelect.isMultiSelecting && (

View File

@@ -29,6 +29,7 @@ function mockModifiedFiles(): ModifiedFile[] {
{ path: '/Users/luca/Laputa/26q1-laputa-app.md', relativePath: '26q1-laputa-app.md', status: 'modified' },
{ path: '/Users/luca/Laputa/facebook-ads-strategy.md', relativePath: 'facebook-ads-strategy.md', status: 'modified' },
{ path: '/Users/luca/Laputa/ai-agents-primer.md', relativePath: 'ai-agents-primer.md', status: 'added' },
{ path: '/Users/luca/Laputa/old-draft.md', relativePath: 'old-draft.md', status: 'deleted' },
]
}

View File

@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test'
import { openCommandPalette, executeCommand } from './helpers'
async function navigateToChanges(page: import('@playwright/test').Page) {
await openCommandPalette(page)
await executeCommand(page, 'Go to Changes')
await page.waitForTimeout(500)
}
test.describe('Show deleted notes in Changes view', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('changes view shows deleted notes banner', async ({ page }) => {
await navigateToChanges(page)
const banner = page.locator('[data-testid="deleted-notes-banner"]')
await expect(banner).toBeVisible({ timeout: 5000 })
await expect(banner).toContainText('1 note deleted')
})
test('deleted banner is visually distinct from note items', async ({ page }) => {
await navigateToChanges(page)
const banner = page.locator('[data-testid="deleted-notes-banner"]')
await expect(banner).toBeVisible({ timeout: 5000 })
// Banner should have reduced opacity (visually distinct)
const opacity = await banner.evaluate((el) => window.getComputedStyle(el).opacity)
expect(parseFloat(opacity)).toBeLessThan(1)
})
test('changes counter in sidebar matches list items plus deleted count', async ({ page }) => {
// The sidebar badge should show the total count (modified + added + deleted)
const changesBadge = page.locator('text=Changes').locator('..')
await expect(changesBadge).toBeVisible({ timeout: 5000 })
})
})