From 32b8e2ee577a4983a7eaa42e8115b8ec1ded9774 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Mon, 2 Mar 2026 11:55:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20toggle=20archive/trash=20shortcuts=20?= =?UTF-8?q?=E2=80=94=20Cmd+E=20unarchives,=20Cmd+=E2=8C=AB=20restores=20(#?= =?UTF-8?q?175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+E and Cmd+Delete now toggle: archiving a normal note or unarchiving an archived one, trashing a normal note or restoring a trashed one. Command palette labels update to reflect the current state. Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- src/App.tsx | 4 +-- src/hooks/useAppCommands.ts | 25 +++++++++++++++--- src/hooks/useCommandRegistry.test.ts | 39 ++++++++++++++++++++++++++++ src/hooks/useCommandRegistry.ts | 14 +++++++--- 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e236546a..b831440c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -286,8 +286,8 @@ function App() { onCreateNoteOfType: notes.handleCreateNoteImmediate, onSave: handleSave, onOpenSettings: dialogs.openSettings, - onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote, - onUnarchiveNote: entryActions.handleUnarchiveNote, + onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote, + onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode, onToggleInspector: () => layout.setInspectorCollapsed(c => !c), onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 2113bbcf..0d2bbcb2 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -1,3 +1,4 @@ +import { useCallback, useRef } from 'react' import { useAppKeyboard } from './useAppKeyboard' import { useCommandRegistry } from './useCommandRegistry' import type { CommandAction } from './useCommandRegistry' @@ -26,6 +27,7 @@ interface AppCommandsConfig { onSave: () => void onOpenSettings: () => void onTrashNote: (path: string) => void + onRestoreNote: (path: string) => void onArchiveNote: (path: string) => void onUnarchiveNote: (path: string) => void onCommitPush: () => void @@ -54,6 +56,20 @@ interface AppCommandsConfig { /** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { + const entriesRef = useRef(config.entries) + // eslint-disable-next-line react-hooks/refs + entriesRef.current = config.entries + + const toggleArchive = useCallback((path: string) => { + const entry = entriesRef.current.find(e => e.path === path) + ;(entry?.archived ? config.onUnarchiveNote : config.onArchiveNote)(path) + }, [config.onArchiveNote, config.onUnarchiveNote]) + + const toggleTrash = useCallback((path: string) => { + const entry = entriesRef.current.find(e => e.path === path) + ;(entry?.trashed ? config.onRestoreNote : config.onTrashNote)(path) + }, [config.onTrashNote, config.onRestoreNote]) + useAppKeyboard({ onQuickOpen: config.onQuickOpen, onCommandPalette: config.onCommandPalette, @@ -62,8 +78,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onOpenDailyNote: config.onOpenDailyNote, onSave: config.onSave, onOpenSettings: config.onOpenSettings, - onTrashNote: config.onTrashNote, - onArchiveNote: config.onArchiveNote, + onTrashNote: toggleTrash, + onArchiveNote: toggleArchive, onSetViewMode: config.onSetViewMode, onZoomIn: config.onZoomIn, onZoomOut: config.onZoomOut, @@ -87,8 +103,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onZoomIn: config.onZoomIn, onZoomOut: config.onZoomOut, onZoomReset: config.onZoomReset, - onArchiveNote: config.onArchiveNote, - onTrashNote: config.onTrashNote, + onArchiveNote: toggleArchive, + onTrashNote: toggleTrash, onSearch: config.onSearch, onGoBack: config.onGoBack, onGoForward: config.onGoForward, @@ -107,6 +123,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onSave: config.onSave, onOpenSettings: config.onOpenSettings, onTrashNote: config.onTrashNote, + onRestoreNote: config.onRestoreNote, onArchiveNote: config.onArchiveNote, onUnarchiveNote: config.onUnarchiveNote, onCommitPush: config.onCommitPush, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index dd003c4f..e0d60ff4 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -42,6 +42,7 @@ function makeConfig(overrides: Record = {}) { onSave: vi.fn(), onOpenSettings: vi.fn(), onTrashNote: vi.fn(), + onRestoreNote: vi.fn(), onArchiveNote: vi.fn(), onUnarchiveNote: vi.fn(), onCommitPush: vi.fn(), @@ -117,6 +118,44 @@ describe('useCommandRegistry', () => { expect(archiveCmd!.label).toBe('Archive Note') }) + it('shows "Restore Note" when active note is trashed', () => { + const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + const trashCmd = result.current.find(c => c.id === 'trash-note') + expect(trashCmd!.label).toBe('Restore Note') + }) + + it('shows "Trash Note" when active note is not trashed', () => { + const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + const trashCmd = result.current.find(c => c.id === 'trash-note') + expect(trashCmd!.label).toBe('Trash Note') + }) + + it('calls onRestoreNote when trash command executes on trashed note', () => { + const onRestoreNote = vi.fn() + const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onRestoreNote })), + ) + result.current.find(c => c.id === 'trash-note')!.execute() + expect(onRestoreNote).toHaveBeenCalledWith('/vault/note/test.md') + }) + + it('calls onTrashNote when trash command executes on non-trashed note', () => { + const onTrashNote = vi.fn() + const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onTrashNote })), + ) + result.current.find(c => c.id === 'trash-note')!.execute() + expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md') + }) + it('disables commit when no modified files', () => { const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 }))) expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 318c1394..6a394ce8 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -26,6 +26,7 @@ interface CommandRegistryConfig { onOpenSettings: () => void onOpenVault?: () => void onTrashNote: (path: string) => void + onRestoreNote: (path: string) => void onArchiveNote: (path: string) => void onUnarchiveNote: (path: string) => void onCommitPush: () => void @@ -128,7 +129,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction const { activeTabPath, entries, modifiedCount, onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, - onTrashNote, onArchiveNote, onUnarchiveNote, + onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, @@ -143,6 +144,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction [entries, activeTabPath, hasActiveNote], ) const isArchived = activeEntry?.archived ?? false + const isTrashed = activeEntry?.trashed ?? false const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries]) @@ -163,7 +165,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction { id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote }, { id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave }, { id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } }, - { id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } }, + { + id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫', + keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote, + execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) }, + }, { id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E', keywords: ['archive'], enabled: hasActiveNote, @@ -197,9 +203,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction return cmds }, [ - hasActiveNote, activeTabPath, isArchived, modifiedCount, + hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, - onTrashNote, onArchiveNote, onUnarchiveNote, + onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab,