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]) }