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:
15
src/App.tsx
15
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user