feat: trash notes — sidebar filter, list view, relationship indicators

- Sidebar: replaced disabled Trash placeholder with active filter showing
  count badge and destructive color when selected
- Sidebar: trashed notes excluded from section groups and All Notes count
- NoteList: trash filter shows only trashed entries with TRASHED badge
- NoteList: 30-day warning banner when expired trash notes exist
- NoteList: per-note warning text for notes past 30-day retention
- Inspector: trash icon shown before name in relationship pills for
  trashed entries, with (trashed) label and dimmed styling
- Inspector: backlinks show trash icon for trashed entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-21 16:54:59 +01:00
parent 66a15e4ddb
commit 2a01f44374
3 changed files with 89 additions and 26 deletions

View File

@@ -4,7 +4,7 @@ import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import {
SlidersHorizontal, X, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple,
Users, CalendarBlank, Tag, FileText, StackSimple, Trash,
} from '@phosphor-icons/react'
import { parseFrontmatter, type ParsedFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel, RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
@@ -84,6 +84,8 @@ function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string
const resolved = resolveRef(ref, entries)
const refType = resolved?.isA ?? undefined
const isArchived = resolved?.archived ?? false
const isTrashed = resolved?.trashed ?? false
const isDimmed = isArchived || isTrashed
const color = refType ? getTypeColor(refType) : 'var(--accent-blue)'
const bgColor = refType ? getTypeLightColor(refType) : 'var(--accent-blue-light)'
const TypeIcon = getTypeIcon(refType)
@@ -92,19 +94,21 @@ function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string
key={`${ref}-${idx}`}
className="flex items-center justify-between gap-2 border-none text-left cursor-pointer hover:opacity-80"
style={{
background: isArchived ? 'var(--muted)' : bgColor,
color: isArchived ? 'var(--muted-foreground)' : color,
background: isDimmed ? 'var(--muted)' : bgColor,
color: isDimmed ? 'var(--muted-foreground)' : color,
borderRadius: 6, padding: '6px 10px', fontSize: 12, fontWeight: 500,
opacity: isArchived ? 0.7 : 1,
opacity: isDimmed ? 0.7 : 1,
}}
onClick={() => onNavigate(wikilinkTarget(ref))}
title={isArchived ? 'Archived' : undefined}
title={isTrashed ? 'Trashed' : isArchived ? 'Archived' : undefined}
>
<span className="flex-1 truncate">
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{wikilinkDisplay(ref)}
{isArchived && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
{isTrashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
{isArchived && !isTrashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
</span>
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: isArchived ? 'var(--muted-foreground)' : color }} />
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />
</button>
)
})}
@@ -182,19 +186,22 @@ function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; on
{backlinks.map((e) => {
const color = e.isA ? getTypeColor(e.isA) : 'var(--accent-blue)'
const TypeIcon = getTypeIcon(e.isA ?? undefined)
const isDimmed = e.archived || e.trashed
return (
<button
key={e.path}
className="flex items-center justify-between gap-2 border-none bg-transparent p-0 py-1 text-left text-[13px] cursor-pointer hover:opacity-80"
style={{ color: e.archived ? 'var(--muted-foreground)' : color, opacity: e.archived ? 0.7 : 1 }}
style={{ color: isDimmed ? 'var(--muted-foreground)' : color, opacity: isDimmed ? 0.7 : 1 }}
onClick={() => onNavigate(e.title)}
title={e.archived ? 'Archived' : undefined}
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
>
<span className="flex-1 truncate">
<span className="flex items-center gap-1 flex-1 truncate">
{e.trashed && <Trash size={12} className="shrink-0" />}
{e.title}
{e.archived && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
{e.trashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
{e.archived && !e.trashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
</span>
{e.isA && <TypeIcon width={14} height={14} className="shrink-0" style={{ color: e.archived ? 'var(--muted-foreground)' : color }} />}
{e.isA && <TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />}
</button>
)
})}

View File

@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple,
Trash, Warning,
} from '@phosphor-icons/react'
import type { ComponentType, SVGAttributes } from 'react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -203,20 +204,22 @@ export function filterEntries(entries: VaultEntry[], selection: SidebarSelection
case 'filter':
switch (selection.filter) {
case 'all':
return entries.filter((e) => !e.archived)
return entries.filter((e) => !e.archived && !e.trashed)
case 'favorites':
return []
case 'archived':
return entries.filter((e) => e.archived)
return entries.filter((e) => e.archived && !e.trashed)
case 'trash':
return entries.filter((e) => e.trashed)
}
break
case 'sectionGroup':
return entries.filter((e) => e.isA === selection.type && !e.archived)
return entries.filter((e) => e.isA === selection.type && !e.archived && !e.trashed)
case 'entity':
return []
case 'topic': {
const topic = selection.entry
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived)
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived && !e.trashed)
}
}
}
@@ -228,6 +231,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const isEntityView = selection.kind === 'entity'
const isSectionGroup = selection.kind === 'sectionGroup'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => {
@@ -289,6 +293,12 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
)
const expiredTrashCount = useMemo(() => {
if (!isTrashView) return 0
const now = Date.now() / 1000
return searched.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
}, [isTrashView, searched])
const renderItem = useCallback((entry: VaultEntry, isPinned = false) => {
const isSelected = selectedNote?.path === entry.path && !isPinned
const te = typeEntryMap[entry.isA ?? '']
@@ -334,13 +344,23 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
ARCHIVED
</span>
)}
{entry.trashed && (
<span
className="ml-1.5 inline-block align-middle"
style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}
>
TRASHED
</span>
)}
</div>
</div>
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{relativeDate(getDisplayDate(entry))}
<div className="mt-0.5 text-[10px] text-muted-foreground" style={entry.trashed && entry.trashedAt && (Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? { color: 'var(--destructive)', fontWeight: 500 } : undefined}>
{entry.trashed && entry.trashedAt
? `Trashed ${relativeDate(entry.trashedAt)}${(Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? ' — will be permanently deleted' : ''}`
: relativeDate(getDisplayDate(entry))}
</div>
</div>
)
@@ -423,7 +443,9 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
? typeDocument.title
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'archived'
? 'Archive'
: 'Notes'}
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'trash'
? 'Trash'
: 'Notes'}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<button
@@ -491,8 +513,27 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
</div>
)
})()}
{/* 30-day warning banner for trash view */}
{isTrashView && expiredTrashCount > 0 && (
<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 }}>
{expiredTrashCount} {expiredTrashCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period
</div>
</div>
</div>
)}
{searched.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
{isTrashView ? 'Trash is empty' : 'No notes found'}
</div>
) : (
searched.map((entry) => renderItem(entry))
)}

View File

@@ -136,6 +136,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
)
const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries])
const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries])
// Close context menu on outside click
useEffect(() => {
@@ -192,7 +193,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
}, [customizeTarget, typeEntryMap, onCustomizeType])
const renderSection = ({ label, type, Icon, customColor }: SectionGroup) => {
const items = entries.filter((e) => e.isA === type && !e.archived)
const items = entries.filter((e) => e.isA === type && !e.archived && !e.trashed)
const isCollapsed = collapsed[type] ?? false
const isTopic = type === 'Topic'
const isTypeSection = type === 'Type'
@@ -293,7 +294,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
{/* Top nav — All Notes + Favorites */}
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
{TOP_NAV.map(({ label, filter, Icon }) => {
const count = filter === 'all' ? entries.filter((e) => !e.archived).length : 0
const count = filter === 'all' ? entries.filter((e) => !e.archived && !e.trashed).length : 0
return (
<div
key={filter}
@@ -350,13 +351,27 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
<TagSimple size={16} />
<span className="flex-1 text-[13px] font-medium">Untagged</span>
</div>
{/* Trash filter */}
<div
className="flex select-none items-center gap-2 rounded text-foreground"
style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }}
title="Coming soon"
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter: 'trash' })
? "bg-destructive/10 text-destructive"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter: 'trash' })}
>
<Trash size={16} />
<span className="flex-1 text-[13px] font-medium">Trash</span>
{trashedCount > 0 && (
<span
className="flex items-center justify-center text-muted-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
>
{trashedCount}
</span>
)}
</div>
</div>