From 4b75eb46b652852f4e8e1f879244ecd27519e885 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 21 Feb 2026 18:45:25 +0100 Subject: [PATCH] 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 --- src/App.tsx | 15 ++ src/components/BreadcrumbBar.test.tsx | 33 +++++ src/components/BreadcrumbBar.tsx | 200 +++++++++++++++----------- src/components/Editor.tsx | 5 + src/hooks/useAppKeyboard.ts | 55 ++++--- 5 files changed, 192 insertions(+), 116 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4aa7a017..73fcffe6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} /> diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index e0110a52..6528af9b 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -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() + expect(screen.getByTitle('Archive (Cmd+E)')).toBeInTheDocument() + expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument() + }) + + it('shows unarchive button for archived note', () => { + render() + 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() + fireEvent.click(screen.getByTitle('Archive (Cmd+E)')) + expect(onArchive).toHaveBeenCalledOnce() + }) + + it('calls onUnarchive when unarchive button is clicked', () => { + const onUnarchive = vi.fn() + render() + fireEvent.click(screen.getByTitle('Unarchive (Cmd+E)')) + expect(onUnarchive).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index c05b2418..f904fcd9 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -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) { + return ( +
+ + {showDiffToggle ? ( + + ) : ( + + )} + + + {entry.archived ? ( + + ) : ( + + )} + {entry.trashed ? ( + + ) : ( + + )} + {inspectorCollapsed && ( + + )} + +
+ ) +} + 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 (
{/* Right: action icons */} -
- - {showDiffToggle ? ( - - ) : ( - - )} - - - {entry.trashed ? ( - - ) : ( - - )} - {inspectorCollapsed && ( - - )} - -
+
) }) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index c325ac33..8005dcc5 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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(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 diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 64b00155..4ada4e22 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -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 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 => ({ + 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]) }