feat: add Inbox sidebar section showing unlinked notes
Inbox shows notes without valid outgoing relationships (body wikilinks or frontmatter refs), helping users find captured but unorganized notes. Includes time-period filter pills (This week/month/quarter/All time), Cmd+K command, and macOS menu bar entry. Broken wikilinks (targeting non-existent notes) are not counted as relationships. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
const GO_INBOX: &str = "go-inbox";
|
||||
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
@@ -267,6 +268,7 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?;
|
||||
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
|
||||
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
|
||||
let go_back = MenuItemBuilder::new("Go Back")
|
||||
.id(VIEW_GO_BACK)
|
||||
.accelerator("CmdOrCtrl+[")
|
||||
@@ -281,6 +283,7 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
.item(&inbox)
|
||||
.separator()
|
||||
.item(&go_back)
|
||||
.item(&go_forward)
|
||||
|
||||
17
src/App.tsx
17
src/App.tsx
@@ -49,9 +49,9 @@ import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
|
||||
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, VaultEntry } from './types'
|
||||
import type { SidebarSelection, VaultEntry, InboxPeriod } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import { flushEditorContent } from './utils/autoSave'
|
||||
import './App.css'
|
||||
@@ -71,6 +71,7 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
function App() {
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
|
||||
const handleSetSelection = useCallback((sel: SidebarSelection) => {
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
@@ -513,11 +514,15 @@ function App() {
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
|
||||
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
return filterEntries(vault.entries, selection).map(e => ({
|
||||
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection)
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
}, [vault.entries, selection])
|
||||
}, [vault.entries, selection, inboxPeriod])
|
||||
|
||||
const aiNoteListFilter = useMemo(() => {
|
||||
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
|
||||
@@ -541,7 +546,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} inboxCount={inboxCount} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -552,7 +557,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
@@ -9,6 +9,7 @@ import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import { NoteListHeader } from './note-list/NoteListHeader'
|
||||
import { FilterPills } from './note-list/FilterPills'
|
||||
import { InboxFilterPills } from './note-list/InboxFilterPills'
|
||||
import { EntityView, ListView } from './note-list/NoteListViews'
|
||||
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
|
||||
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
|
||||
@@ -23,6 +24,8 @@ interface NoteListProps {
|
||||
selectedNote: VaultEntry | null
|
||||
noteListFilter: NoteListFilter
|
||||
onNoteListFilterChange: (filter: NoteListFilter) => void
|
||||
inboxPeriod?: InboxPeriod
|
||||
onInboxPeriodChange?: (period: InboxPeriod) => void
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
modifiedFilesError?: string | null
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
@@ -39,10 +42,11 @@ interface NoteListProps {
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const subFilter = isSectionGroup ? noteListFilter : undefined
|
||||
|
||||
const filterCounts = useMemo(
|
||||
@@ -50,12 +54,17 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
[entries, isSectionGroup, selection],
|
||||
)
|
||||
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry })
|
||||
const inboxCounts = useMemo(
|
||||
() => isInboxView ? countInboxByPeriod(entries) : { week: 0, month: 0, quarter: 0, all: 0 },
|
||||
[entries, isInboxView],
|
||||
)
|
||||
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter })
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined })
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const deletedCount = useMemo(
|
||||
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
|
||||
@@ -91,12 +100,13 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
{isSectionGroup && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} />}
|
||||
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{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} isArchivedView={isArchivedView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
<ListView isTrashView={isTrashView} isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
)}
|
||||
</div>
|
||||
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, Tray,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ interface SidebarProps {
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
modifiedCount?: number
|
||||
inboxCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
isGitVault?: boolean
|
||||
@@ -222,7 +223,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
|
||||
modifiedCount = 0, inboxCount = 0, onCommitPush, onCollapse, isGitVault = false,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -312,6 +313,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
|
||||
)}
|
||||
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} />
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
</div>
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
|
||||
44
src/components/note-list/InboxFilterPills.tsx
Normal file
44
src/components/note-list/InboxFilterPills.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { memo } from 'react'
|
||||
import type { InboxPeriod } from '../../types'
|
||||
|
||||
interface InboxFilterPillsProps {
|
||||
active: InboxPeriod
|
||||
counts: Record<InboxPeriod, number>
|
||||
onChange: (period: InboxPeriod) => void
|
||||
}
|
||||
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'week', label: 'This week' },
|
||||
{ value: 'month', label: 'This month' },
|
||||
{ value: 'quarter', label: 'This quarter' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
]
|
||||
|
||||
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
|
||||
return (
|
||||
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="inbox-filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => onChange(value)}
|
||||
data-testid={`inbox-pill-${value}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
|
||||
{counts[value]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InboxFilterPills = memo(InboxFilterPillsInner)
|
||||
@@ -11,11 +11,12 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
|
||||
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
}
|
||||
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, query: string): string {
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, 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'
|
||||
}
|
||||
|
||||
@@ -39,13 +40,13 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
|
||||
)
|
||||
}
|
||||
|
||||
export function ListView({ isTrashView, isArchivedView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
|
||||
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
|
||||
deletedCount?: number; searched: VaultEntry[]; query: string
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
|
||||
}) {
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, query)
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, !!isInboxView, query)
|
||||
const hasHeader = isTrashView && expiredTrashCount > 0
|
||||
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
|
||||
getSortComparator, extractSortableProperties,
|
||||
buildRelationshipGroups, filterEntries,
|
||||
buildRelationshipGroups, filterEntries, filterInboxEntries,
|
||||
loadSortPreferences, saveSortPreferences,
|
||||
parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage,
|
||||
} from '../../utils/noteListHelpers'
|
||||
import type { InboxPeriod } from '../../types'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
@@ -19,14 +20,16 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
|
||||
|
||||
// --- useFilteredEntries ---
|
||||
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter) {
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
return useMemo(() => {
|
||||
if (isEntityView) return []
|
||||
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
|
||||
if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month')
|
||||
return filterEntries(entries, selection, subFilter)
|
||||
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes, subFilter])
|
||||
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod])
|
||||
}
|
||||
|
||||
// --- useNoteListData ---
|
||||
@@ -36,14 +39,15 @@ interface NoteListDataParams {
|
||||
query: string; listSort: SortOption; listDirection: SortDirection
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
inboxPeriod?: InboxPeriod
|
||||
}
|
||||
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter }: NoteListDataParams) {
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod }: 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)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
|
||||
|
||||
const searched = useMemo(() => {
|
||||
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
|
||||
@@ -125,11 +129,12 @@ export interface UseNoteListSortParams {
|
||||
modifiedPathSet: Set<string>
|
||||
modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
inboxPeriod?: InboxPeriod
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
}
|
||||
|
||||
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
|
||||
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
|
||||
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
|
||||
|
||||
const typeDocument = useMemo(() => {
|
||||
@@ -157,7 +162,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
|
||||
}
|
||||
}, [typeDocument, persistence])
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
|
||||
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
|
||||
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
|
||||
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
|
||||
|
||||
@@ -7,6 +7,7 @@ export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: Va
|
||||
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
|
||||
if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash'
|
||||
if (selection.kind === 'filter' && selection.filter === 'changes') return 'Changes'
|
||||
if (selection.kind === 'filter' && selection.filter === 'inbox') return 'Inbox'
|
||||
return 'Notes'
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
||||
import { useMenuEvents } from './useMenuEvents'
|
||||
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
|
||||
import type { SidebarSelection, SidebarFilter, ThemeFile, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
|
||||
@@ -97,7 +97,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
|
||||
const { onSelect } = config
|
||||
|
||||
const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
const selectFilter = useCallback((filter: SidebarFilter) => {
|
||||
onSelect({ kind: 'filter', filter })
|
||||
}, [onSelect])
|
||||
|
||||
|
||||
@@ -242,6 +242,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ const FILTER_MAP: Record<string, SidebarFilter> = {
|
||||
'go-archived': 'archived',
|
||||
'go-trash': 'trash',
|
||||
'go-changes': 'changes',
|
||||
'go-inbox': 'inbox',
|
||||
}
|
||||
|
||||
type OptionalHandler =
|
||||
|
||||
@@ -176,7 +176,9 @@ export interface PulseCommit {
|
||||
deleted: number
|
||||
}
|
||||
|
||||
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse' | 'inbox'
|
||||
|
||||
export type InboxPeriod = 'week' | 'month' | 'quarter' | 'all'
|
||||
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig } from './noteListHelpers'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries } from './noteListHelpers'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
@@ -485,3 +485,135 @@ describe('parseSortConfig', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- Inbox ---
|
||||
|
||||
describe('buildValidLinkTargets', () => {
|
||||
it('builds a set of titles, filename stems, and path stems', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', aliases: ['MP'] }),
|
||||
makeEntry({ path: '/vault/note/test.md', filename: 'test.md', title: 'Test Note' }),
|
||||
]
|
||||
const targets = buildValidLinkTargets(entries)
|
||||
expect(targets.has('My Project')).toBe(true)
|
||||
expect(targets.has('Test Note')).toBe(true)
|
||||
expect(targets.has('my-project')).toBe(true)
|
||||
expect(targets.has('test')).toBe(true)
|
||||
expect(targets.has('MP')).toBe(true)
|
||||
// path stems (last 2 segments without .md)
|
||||
expect(targets.has('project/my-project')).toBe(true)
|
||||
expect(targets.has('note/test')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInboxEntry', () => {
|
||||
const allEntries = [
|
||||
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI', aliases: [] }),
|
||||
makeEntry({ path: '/vault/project/laputa.md', filename: 'laputa.md', title: 'Laputa' }),
|
||||
]
|
||||
const validTargets = buildValidLinkTargets(allEntries)
|
||||
|
||||
it('returns true for a note with no outgoing links and no relationships', () => {
|
||||
const note = makeEntry({ outgoingLinks: [], relationships: {}, belongsTo: [], relatedTo: [] })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a trashed note', () => {
|
||||
const note = makeEntry({ trashed: true, outgoingLinks: [], relationships: {} })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for an archived note', () => {
|
||||
const note = makeEntry({ archived: true, outgoingLinks: [], relationships: {} })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a note with valid outgoing links', () => {
|
||||
const note = makeEntry({ outgoingLinks: ['AI'] })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a note with only broken outgoing links (non-existent targets)', () => {
|
||||
const note = makeEntry({ outgoingLinks: ['NonExistent Page', 'Another Missing'] })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a note with valid frontmatter relationships', () => {
|
||||
const note = makeEntry({ outgoingLinks: [], relationships: { 'Related to': ['[[AI]]'] } })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a note with belongsTo pointing to real note', () => {
|
||||
const note = makeEntry({ outgoingLinks: [], belongsTo: ['[[Laputa]]'] })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a note with relatedTo pointing to real note', () => {
|
||||
const note = makeEntry({ outgoingLinks: [], relatedTo: ['[[AI]]'] })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a note with only broken relationship refs', () => {
|
||||
const note = makeEntry({ outgoingLinks: [], relationships: { 'Relates': ['[[Ghost]]'] }, belongsTo: ['[[Missing]]'] })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes Type entries from inbox', () => {
|
||||
const note = makeEntry({ isA: 'Type', outgoingLinks: [], relationships: {} })
|
||||
expect(isInboxEntry(note, validTargets)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterInboxEntries', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const DAY = 86400
|
||||
|
||||
const allEntries = [
|
||||
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'A', createdAt: now - 2 * DAY, outgoingLinks: [] }),
|
||||
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'B', createdAt: now - 15 * DAY, outgoingLinks: [] }),
|
||||
makeEntry({ path: '/vault/c.md', filename: 'c.md', title: 'C', createdAt: now - 60 * DAY, outgoingLinks: [] }),
|
||||
makeEntry({ path: '/vault/d.md', filename: 'd.md', title: 'D', createdAt: now - 120 * DAY, outgoingLinks: [] }),
|
||||
makeEntry({ path: '/vault/linked.md', filename: 'linked.md', title: 'Linked', createdAt: now - 1 * DAY, outgoingLinks: ['A'] }),
|
||||
]
|
||||
|
||||
it('filters by "week" period (last 7 days)', () => {
|
||||
const result = filterInboxEntries(allEntries, 'week')
|
||||
expect(result.map(e => e.title)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('filters by "month" period (last 30 days)', () => {
|
||||
const result = filterInboxEntries(allEntries, 'month')
|
||||
expect(result.map(e => e.title)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('filters by "quarter" period (last 90 days)', () => {
|
||||
const result = filterInboxEntries(allEntries, 'quarter')
|
||||
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('filters by "all" period', () => {
|
||||
const result = filterInboxEntries(allEntries, 'all')
|
||||
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C', 'D'])
|
||||
})
|
||||
|
||||
it('sorts by createdAt descending', () => {
|
||||
const result = filterInboxEntries(allEntries, 'all')
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect((result[i - 1].createdAt ?? 0)).toBeGreaterThanOrEqual((result[i].createdAt ?? 0))
|
||||
}
|
||||
})
|
||||
|
||||
it('excludes linked notes', () => {
|
||||
const result = filterInboxEntries(allEntries, 'all')
|
||||
expect(result.find(e => e.title === 'Linked')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns empty array when all notes have valid outgoing links', () => {
|
||||
const linked = [
|
||||
makeEntry({ path: '/vault/x.md', title: 'X', outgoingLinks: ['Y'] }),
|
||||
makeEntry({ path: '/vault/y.md', title: 'Y', outgoingLinks: ['X'] }),
|
||||
]
|
||||
const result = filterInboxEntries(linked, 'all')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import type { VaultEntry, SidebarSelection, InboxPeriod } from '../types'
|
||||
|
||||
export type NoteListFilter = 'open' | 'archived' | 'trashed'
|
||||
|
||||
@@ -353,3 +353,88 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
|
||||
}
|
||||
return { open, archived, trashed }
|
||||
}
|
||||
|
||||
// --- Inbox ---
|
||||
|
||||
/** Build a set of all valid link targets (titles, aliases, filename stems, path stems). */
|
||||
export function buildValidLinkTargets(entries: VaultEntry[]): Set<string> {
|
||||
const targets = new Set<string>()
|
||||
for (const e of entries) {
|
||||
targets.add(e.title)
|
||||
const fileStem = e.filename.replace(/\.md$/, '')
|
||||
targets.add(fileStem)
|
||||
// path stem: everything after vault root, minus .md
|
||||
// E.g. /Users/luca/Laputa/project/foo.md → project/foo
|
||||
const parts = e.path.replace(/\.md$/, '').split('/')
|
||||
// Try from index that gives "folder/name" pattern — skip first segments
|
||||
if (parts.length >= 2) {
|
||||
const last2 = parts.slice(-2).join('/')
|
||||
if (last2 !== fileStem) targets.add(last2)
|
||||
}
|
||||
for (const alias of e.aliases) targets.add(alias)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
function extractRef(raw: string): string {
|
||||
return raw.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
|
||||
}
|
||||
|
||||
function hasValidRef(refs: string[], validTargets: Set<string>): boolean {
|
||||
return refs.some((raw) => {
|
||||
const inner = extractRef(raw)
|
||||
return validTargets.has(inner) || validTargets.has(inner.split('/').pop() ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
/** Check if entry has any valid outgoing link (body or frontmatter) that resolves to a real note. */
|
||||
export function isInboxEntry(entry: VaultEntry, validTargets: Set<string>): boolean {
|
||||
if (entry.trashed || entry.archived) return false
|
||||
if (entry.isA === 'Type') return false
|
||||
|
||||
// Check body outgoing links
|
||||
if (entry.outgoingLinks.some((link) => validTargets.has(link) || validTargets.has(link.split('/').pop() ?? ''))) return false
|
||||
|
||||
// Check frontmatter relationship refs
|
||||
if (entry.belongsTo?.length && hasValidRef(entry.belongsTo, validTargets)) return false
|
||||
if (entry.relatedTo?.length && hasValidRef(entry.relatedTo, validTargets)) return false
|
||||
if (entry.relationships) {
|
||||
for (const refs of Object.values(entry.relationships)) {
|
||||
if (hasValidRef(refs, validTargets)) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const INBOX_PERIOD_DAYS: Record<InboxPeriod, number> = {
|
||||
week: 7, month: 30, quarter: 90, all: Infinity,
|
||||
}
|
||||
|
||||
/** Filter entries for the Inbox view: no valid relationships, within the given time period, sorted by createdAt desc. */
|
||||
export function filterInboxEntries(entries: VaultEntry[], period: InboxPeriod): VaultEntry[] {
|
||||
const validTargets = buildValidLinkTargets(entries)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const cutoff = period === 'all' ? 0 : now - INBOX_PERIOD_DAYS[period] * 86400
|
||||
|
||||
return entries
|
||||
.filter((e) => isInboxEntry(e, validTargets) && (e.createdAt ?? 0) >= cutoff)
|
||||
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
|
||||
}
|
||||
|
||||
/** Count inbox entries per period. */
|
||||
export function countInboxByPeriod(entries: VaultEntry[]): Record<InboxPeriod, number> {
|
||||
const validTargets = buildValidLinkTargets(entries)
|
||||
const inbox = entries.filter((e) => isInboxEntry(e, validTargets))
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
let week = 0, month = 0, quarter = 0
|
||||
for (const e of inbox) {
|
||||
const age = now - (e.createdAt ?? 0)
|
||||
if (age <= 7 * 86400) week++
|
||||
if (age <= 30 * 86400) month++
|
||||
if (age <= 90 * 86400) quarter++
|
||||
}
|
||||
|
||||
return { week, month, quarter, all: inbox.length }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user