feat: archive notes button + cmd+e shortcut

This commit is contained in:
lucaronin
2026-02-22 10:02:17 +01:00
6 changed files with 9702 additions and 116 deletions

File diff suppressed because it is too large Load Diff

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 ---
@@ -195,6 +197,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)
@@ -410,6 +413,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])
}