From 582b181f41e0995a09c1927f0de0f062d352e14c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 16 Mar 2026 03:42:40 +0100 Subject: [PATCH] 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) --- src/components/NoteList.test.tsx | 40 +++++++++++++ src/components/NoteList.tsx | 56 ++++++++++++++----- src/mock-tauri/mock-handlers.ts | 1 + ...show-deleted-notes-in-changes-view.spec.ts | 37 ++++++++++++ 4 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 tests/smoke/show-deleted-notes-in-changes-view.spec.ts diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 00a451cd..e61b757c 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -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( + + ) + 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( + + ) + expect(screen.getByText('1 note deleted')).toBeInTheDocument() + }) + + it('does not show deleted banner when no files are deleted', () => { + render( + + ) + 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( + + ) + expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() + }) }) }) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 86f1c843..a517f7c8 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -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
{text}
} +function DeletedNotesBanner({ count }: { count: number }) { + if (count === 0) return null + return ( +
+ + {count} {count === 1 ? 'note' : 'notes'} deleted +
+ ) +} + 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 }) { 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 (
{hasHeader && } @@ -176,17 +187,28 @@ function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, ) } + if (searched.length === 0 && showDeleted) { + return ( +
+ +
+ ) + } + return ( - : undefined, - }} - itemContent={(_index, entry) => renderItem(entry)} - /> +
+ : undefined, + }} + itemContent={(_index, entry) => renderItem(entry)} + /> + {showDeleted && } +
) } @@ -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 ? ( ) : ( - + )}
{multiSelect.isMultiSelecting && ( diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 483ab378..045fc102 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -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' }, ] } diff --git a/tests/smoke/show-deleted-notes-in-changes-view.spec.ts b/tests/smoke/show-deleted-notes-in-changes-view.spec.ts new file mode 100644 index 00000000..9bdf060c --- /dev/null +++ b/tests/smoke/show-deleted-notes-in-changes-view.spec.ts @@ -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 }) + }) +})