refactor: remove Trash system — delete is now permanent with confirm modal

Remove all vestiges of the abandoned Trash system: trashed/trashedAt fields
from types, frontmatter parsing, sidebar filtering, editor banners, inspector
components, mock data, and all related tests. Delete is already permanent via
useDeleteActions with a confirmation dialog. Notes with trashed:true in
existing vault frontmatter are now treated as normal notes (the flag is
ignored by the parser).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-04-06 12:21:56 +02:00
parent 53072eb4f3
commit d0c3a6b889
123 changed files with 359 additions and 3166 deletions

View File

@@ -11,7 +11,6 @@ interface FilterPillsProps {
const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'open', label: 'Open' },
{ value: 'archived', label: 'Archived' },
{ value: 'trashed', label: 'Trashed' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'

View File

@@ -1,4 +1,4 @@
import { MagnifyingGlass, Plus, Trash } from '@phosphor-icons/react'
import { MagnifyingGlass, Plus } from '@phosphor-icons/react'
import type { VaultEntry } from '../../types'
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
import { Input } from '@/components/ui/input'
@@ -6,12 +6,10 @@ import { useDragRegion } from '../../hooks/useDragRegion'
import { SortDropdown } from '../SortDropdown'
import { ListPropertiesPopover } from './ListPropertiesPopover'
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash, onUpdateTypeProperty }: {
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onUpdateTypeProperty }: {
title: string
typeDocument: VaultEntry | null
isEntityView: boolean
isTrashView: boolean
trashCount: number
listSort: SortOption
listDirection: SortDirection
customProperties: string[]
@@ -25,7 +23,6 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
onOpenType: (entry: VaultEntry) => void
onToggleSearch: () => void
onSearchChange: (value: string) => void
onEmptyTrash?: () => void
onUpdateTypeProperty?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
}) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
@@ -48,21 +45,9 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
{isSectionGroup && typeDocument && entries && onUpdateTypeProperty && (
<ListPropertiesPopover typeDocument={typeDocument} entries={entries} onSave={onUpdateTypeProperty} />
)}
{isTrashView && trashCount > 0 && (
<button
className="flex items-center text-destructive transition-colors hover:text-destructive/80"
onClick={onEmptyTrash}
title="Empty Trash"
data-testid="empty-trash-btn"
>
<Trash size={16} />
</button>
)}
{!isTrashView && (
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
<Plus size={16} />
</button>
)}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
<Plus size={16} />
</button>
</div>
</div>
{searchVisible && (

View File

@@ -3,18 +3,11 @@ import type { VaultEntry } from '../../types'
import type { SortOption, SortDirection, SortConfig, RelationshipGroup } from '../../utils/noteListHelpers'
import { PinnedCard } from './PinnedCard'
import { RelationshipGroupSection } from './RelationshipGroupSection'
import { TrashWarningBanner, EmptyMessage } from './TrashWarningBanner'
import { EmptyMessage } from './TrashWarningBanner'
function ListViewHeader({ isTrashView, expiredTrashCount }: {
isTrashView: boolean; expiredTrashCount: number
}) {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, isInboxView: boolean, query: string): string {
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isArchivedView: boolean, isInboxView: boolean, query: string): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isTrashView) return 'Trash is empty'
if (isArchivedView) return 'No archived notes'
if (isInboxView) return query ? 'No matching notes' : 'All notes are organized'
return query ? 'No matching notes' : 'No notes found'
@@ -40,20 +33,18 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
)
}
export function ListView({ isTrashView, isArchivedView, isChangesView, isInboxView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null; expiredTrashCount: number
export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null
deletedCount?: number; searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, !!isInboxView, query)
const hasHeader = isTrashView && expiredTrashCount > 0
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, !!isArchivedView, !!isInboxView, query)
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0
if (searched.length === 0 && !hasDeletedOnly) {
return (
<div className="h-full overflow-y-auto">
{hasHeader && <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} />}
<EmptyMessage text={emptyText} />
</div>
)
@@ -69,9 +60,6 @@ export function ListView({ isTrashView, isArchivedView, isChangesView, isInboxVi
style={{ height: '100%' }}
data={searched}
overscan={200}
components={{
Header: hasHeader ? () => <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} /> : undefined,
}}
itemContent={(_index, entry) => renderItem(entry)}
/>
)

View File

@@ -1,17 +1,4 @@
import { Warning, TrashSimple } from '@phosphor-icons/react'
export function TrashWarningBanner({ expiredCount }: { expiredCount: number }) {
if (expiredCount === 0) return null
return (
<div className="flex items-start gap-2 border-b border-[var(--border)]" style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}>
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>Notes in trash for 30+ days will be permanently deleted</div>
<div className="text-muted-foreground" style={{ fontSize: 11 }}>{expiredCount} {expiredCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period</div>
</div>
</div>
)
}
import { TrashSimple } from '@phosphor-icons/react'
export function EmptyMessage({ text }: { text: string }) {
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">{text}</div>

View File

@@ -9,7 +9,7 @@ import {
} from '../../utils/noteListHelpers'
import type { InboxPeriod } from '../../types'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import { filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
// --- useTypeEntryMap ---
@@ -45,7 +45,6 @@ interface NoteListDataParams {
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views)
@@ -64,12 +63,7 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, query])
const expiredTrashCount = useMemo(
() => isTrashView ? countExpiredTrash(searched) : 0,
[isTrashView, searched],
)
return { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount }
return { isEntityView, isArchivedView, searched, searchedGroups }
}
// --- useNoteListSearch ---
@@ -193,22 +187,22 @@ function handleSelectAllKey(e: KeyboardEvent, multiSelect: MultiSelectState, isE
multiSelect.selectAll()
}
function handleBulkActionKey(e: KeyboardEvent, multiSelect: MultiSelectState, onArchive: () => void, onTrash: () => void) {
function handleBulkActionKey(e: KeyboardEvent, multiSelect: MultiSelectState, onArchive: () => void, onDelete: () => void) {
if (!multiSelect.isMultiSelecting || !(e.metaKey || e.ctrlKey)) return
if (e.key === 'e') { e.preventDefault(); e.stopPropagation(); onArchive() }
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); onTrash() }
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); onDelete() }
}
export function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boolean, onBulkArchive: () => void, onBulkTrash: () => void) {
export function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boolean, onBulkArchive: () => void, onBulkDelete: () => void) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
handleEscapeKey(e, multiSelect)
handleSelectAllKey(e, multiSelect, isEntityView)
handleBulkActionKey(e, multiSelect, onBulkArchive, onBulkTrash)
handleBulkActionKey(e, multiSelect, onBulkArchive, onBulkDelete)
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [multiSelect, isEntityView, onBulkArchive, onBulkTrash])
}, [multiSelect, isEntityView, onBulkArchive, onBulkDelete])
}
// --- useModifiedFilesState ---

View File

@@ -6,7 +6,7 @@ function makeEntry(path = '/test.md'): VaultEntry {
return {
path, filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
archived: false,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null,

View File

@@ -24,11 +24,6 @@ export function filterGroupsByQuery(groups: RelationshipGroup[], query: string):
return groups.map((g) => ({ ...g, entries: filterByQuery(g.entries, query) })).filter((g) => g.entries.length > 0)
}
export function countExpiredTrash(entries: VaultEntry[]): number {
const now = Date.now() / 1000
return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
}
export interface ClickActions {
onReplace: (entry: VaultEntry) => void
onSelect: (entry: VaultEntry) => void