feat: archive button in breadcrumbs bar + cmd+e shortcut

Add archive/unarchive toggle button to the BreadcrumbBar (Archive icon
for normal notes, ArrowUUpLeft icon for archived notes). Add cmd+e
keyboard shortcut to toggle archive state of the active note.

Refactored useAppKeyboard to use action map pattern (reduces cyclomatic
complexity) and extracted BreadcrumbActions from BreadcrumbBar to split
rendering complexity across two functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-21 18:45:25 +01:00
parent 96252b66eb
commit 4b75eb46b6
5 changed files with 192 additions and 116 deletions

View File

@@ -127,6 +127,18 @@ function App() {
setToastMessage('Note restored from trash')
}, [notes, vault, setToastMessage])
const handleArchiveNote = useCallback(async (path: string) => {
await notes.handleUpdateFrontmatter(path, 'archived', true)
vault.updateEntry(path, { archived: true })
setToastMessage('Note archived')
}, [notes, vault, setToastMessage])
const handleUnarchiveNote = useCallback(async (path: string) => {
await notes.handleUpdateFrontmatter(path, 'archived', false)
vault.updateEntry(path, { archived: false })
setToastMessage('Note unarchived')
}, [notes, vault, setToastMessage])
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {
const typeEntry = vault.entries.find((e) => e.isA === 'Type' && e.title === typeName)
@@ -141,6 +153,7 @@ function App() {
onCreateNote: openCreateDialog,
onSave: () => setToastMessage('Saved'),
onTrashNote: handleTrashNote,
onArchiveNote: handleArchiveNote,
activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef,
})
@@ -221,6 +234,8 @@ function App() {
vaultPath={vaultPath}
onTrashNote={handleTrashNote}
onRestoreNote={handleRestoreNote}
onArchiveNote={handleArchiveNote}
onUnarchiveNote={handleUnarchiveNote}
/>
</div>
</div>

View File

@@ -26,6 +26,11 @@ const baseEntry: VaultEntry = {
color: null,
}
const archivedEntry: VaultEntry = {
...baseEntry,
archived: true,
}
const trashedEntry: VaultEntry = {
...baseEntry,
trashed: true,
@@ -68,3 +73,31 @@ describe('BreadcrumbBar — trash/restore', () => {
expect(onRestore).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — archive/unarchive', () => {
it('shows archive button for non-archived note', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Archive (Cmd+E)')).toBeInTheDocument()
expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument()
})
it('shows unarchive button for archived note', () => {
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Unarchive (Cmd+E)')).toBeInTheDocument()
expect(screen.queryByTitle('Archive (Cmd+E)')).not.toBeInTheDocument()
})
it('calls onArchive when archive button is clicked', () => {
const onArchive = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
fireEvent.click(screen.getByTitle('Archive (Cmd+E)'))
expect(onArchive).toHaveBeenCalledOnce()
})
it('calls onUnarchive when unarchive button is clicked', () => {
const onUnarchive = vi.fn()
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
fireEvent.click(screen.getByTitle('Unarchive (Cmd+E)'))
expect(onUnarchive).toHaveBeenCalledOnce()
})
})

View File

@@ -10,6 +10,8 @@ import {
DotsThree,
Trash,
ArrowCounterClockwise,
Archive,
ArrowUUpLeft,
} from '@phosphor-icons/react'
interface BreadcrumbBarProps {
@@ -26,14 +28,122 @@ interface BreadcrumbBarProps {
onToggleInspector?: () => void
onTrash?: () => void
onRestore?: () => void
onArchive?: () => void
onUnarchive?: () => void
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
onTrash, onRestore, onArchive, onUnarchive,
}: Omit<BreadcrumbBarProps, 'wordCount' | 'isModified'>) {
return (
<div className="flex items-center" style={{ gap: 12 }}>
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
title="Search in file"
>
<MagnifyingGlass size={16} />
</button>
{showDiffToggle ? (
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
diffMode ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={onToggleDiff}
disabled={diffLoading}
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
>
<GitBranch size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="No changes"
tabIndex={-1}
>
<GitBranch size={16} />
</button>
)}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<CursorText size={16} />
</button>
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
showAIChat ? "" : "text-muted-foreground hover:text-foreground"
)}
style={showAIChat ? { color: 'var(--primary)' } : undefined}
onClick={onToggleAIChat}
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
</button>
{entry.archived ? (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onUnarchive}
title="Unarchive (Cmd+E)"
>
<ArrowUUpLeft size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onArchive}
title="Archive (Cmd+E)"
>
<Archive size={16} />
</button>
)}
{entry.trashed ? (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onRestore}
title="Restore from trash"
>
<ArrowCounterClockwise size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
onClick={onTrash}
title="Move to trash (Cmd+Delete)"
>
<Trash size={16} />
</button>
)}
{inspectorCollapsed && (
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onToggleInspector}
title="Open Properties"
>
<SlidersHorizontal size={16} />
</button>
)}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<DotsThree size={16} />
</button>
</div>
)
}
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry, wordCount, isModified, showDiffToggle, diffMode, diffLoading,
onToggleDiff, showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
onTrash, onRestore,
entry, wordCount, isModified, ...actionProps
}: BreadcrumbBarProps) {
return (
<div
@@ -61,89 +171,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
</div>
{/* Right: action icons */}
<div className="flex items-center" style={{ gap: 12 }}>
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
title="Search in file"
>
<MagnifyingGlass size={16} />
</button>
{showDiffToggle ? (
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
diffMode ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={onToggleDiff}
disabled={diffLoading}
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
>
<GitBranch size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="No changes"
tabIndex={-1}
>
<GitBranch size={16} />
</button>
)}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<CursorText size={16} />
</button>
<button
className={cn(
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
showAIChat ? "" : "text-muted-foreground hover:text-foreground"
)}
style={showAIChat ? { color: 'var(--primary)' } : undefined}
onClick={onToggleAIChat}
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
</button>
{entry.trashed ? (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onRestore}
title="Restore from trash"
>
<ArrowCounterClockwise size={16} />
</button>
) : (
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
onClick={onTrash}
title="Move to trash (Cmd+Delete)"
>
<Trash size={16} />
</button>
)}
{inspectorCollapsed && (
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onToggleInspector}
title="Open Properties"
>
<SlidersHorizontal size={16} />
</button>
)}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<DotsThree size={16} />
</button>
</div>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>
)
})

View File

@@ -51,6 +51,8 @@ interface EditorProps {
vaultPath?: string
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
}
// --- Custom Inline Content: WikiLink ---
@@ -155,6 +157,7 @@ export const Editor = memo(function Editor({
showAIChat, onToggleAIChat,
vaultPath,
onTrashNote, onRestoreNote,
onArchiveNote, onUnarchiveNote,
}: EditorProps) {
const [diffMode, setDiffMode] = useState(false)
const [diffContent, setDiffContent] = useState<string | null>(null)
@@ -367,6 +370,8 @@ export const Editor = memo(function Editor({
onToggleInspector={onToggleInspector}
onTrash={onTrashNote ? () => onTrashNote(activeTab.entry.path) : undefined}
onRestore={onRestoreNote ? () => onRestoreNote(activeTab.entry.path) : undefined}
onArchive={onArchiveNote ? () => onArchiveNote(activeTab.entry.path) : undefined}
onUnarchive={onUnarchiveNote ? () => onUnarchiveNote(activeTab.entry.path) : undefined}
/>
) : null

View File

@@ -1,52 +1,47 @@
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
interface KeyboardActions {
onQuickOpen: () => void
onCreateNote: () => void
onSave: () => void
onTrashNote: (path: string) => void
onArchiveNote: (path: string) => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
type ShortcutHandler = () => void
export function useAppKeyboard({
onQuickOpen, onCreateNote, onSave, onTrashNote,
onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote,
activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
const path = activeTabPathRef.current
if (path) fn(path)
}
const keyMap = useMemo((): Record<string, ShortcutHandler> => ({
p: onQuickOpen,
n: onCreateNote,
s: onSave,
e: withActiveTab(onArchiveNote),
w: withActiveTab((path) => handleCloseTabRef.current(path)),
Backspace: withActiveTab(onTrashNote),
Delete: withActiveTab(onTrashNote),
}), [onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
switch (e.key) {
case 'p':
e.preventDefault()
onQuickOpen()
break
case 'n':
e.preventDefault()
onCreateNote()
break
case 's':
e.preventDefault()
onSave()
break
case 'w': {
e.preventDefault()
const path = activeTabPathRef.current
if (path) handleCloseTabRef.current(path)
break
}
case 'Backspace':
case 'Delete': {
e.preventDefault()
const path = activeTabPathRef.current
if (path) onTrashNote(path)
break
}
const handler = keyMap[e.key]
if (handler) {
e.preventDefault()
handler()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCreateNote, onSave, onTrashNote, activeTabPathRef, handleCloseTabRef])
}, [keyMap])
}