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:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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))
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user