diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec6ba09b..d04496da 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -665,7 +665,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `get_build_number` | Get app build number | | `save_image` | Save base64 image to vault | | `copy_image_to_vault` | Copy image file to vault | -| `update_menu_state` | Update native menu checkmarks | +| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions | ## Mock Layer @@ -718,6 +718,8 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c | Cmd+[ / Cmd+] | Navigate back / forward | | `[[` in editor | Open wikilink suggestion menu | +Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. + ## Auto-Release & In-App Updates ### Release Pipeline diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index f1d31500..c36f0784 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -282,6 +282,8 @@ type SidebarSelection = `useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`. +Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active. + ## Running Tests ```bash @@ -336,6 +338,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts 1. Register the command in `useAppCommands.ts` via the command registry 2. Add a corresponding menu bar item in `menu.rs` for discoverability 3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts` +4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly ### Modify styling diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index e4f9e801..8dacf551 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -48,6 +48,7 @@ pub fn update_menu_state( has_active_note: bool, has_modified_files: Option, has_conflicts: Option, + has_restorable_deleted_note: Option, ) -> Result<(), String> { menu::set_note_items_enabled(&app_handle, has_active_note); if let Some(v) = has_modified_files { @@ -56,6 +57,9 @@ pub fn update_menu_state( if let Some(v) = has_conflicts { menu::set_git_conflict_items_enabled(&app_handle, v); } + if let Some(v) = has_restorable_deleted_note { + menu::set_restore_deleted_item_enabled(&app_handle, v); + } Ok(()) } @@ -66,6 +70,7 @@ pub fn update_menu_state( _has_active_note: bool, _has_modified_files: Option, _has_conflicts: Option, + _has_restorable_deleted_note: Option, ) -> Result<(), String> { Ok(()) } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 38ca4dbf..5509d431 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -38,6 +38,7 @@ const GO_INBOX: &str = "go-inbox"; const NOTE_ARCHIVE: &str = "note-archive"; const NOTE_DELETE: &str = "note-delete"; const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window"; +const NOTE_RESTORE_DELETED: &str = "note-restore-deleted"; const VAULT_OPEN: &str = "vault-open"; const VAULT_REMOVE: &str = "vault-remove"; @@ -79,6 +80,7 @@ const CUSTOM_IDS: &[&str] = &[ NOTE_ARCHIVE, NOTE_DELETE, NOTE_OPEN_IN_NEW_WINDOW, + NOTE_RESTORE_DELETED, VAULT_OPEN, VAULT_REMOVE, VAULT_RESTORE_GETTING_STARTED, @@ -102,6 +104,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[ NOTE_OPEN_IN_NEW_WINDOW, ]; +/// IDs of menu items that depend on a deleted-note preview being active. +const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED]; + /// IDs of menu items that depend on having uncommitted changes. const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH]; @@ -276,6 +281,10 @@ fn build_note_menu(app: &App) -> MenuResult { .id(NOTE_DELETE) .accelerator("CmdOrCtrl+Backspace") .build(app)?; + let restore_deleted_note = MenuItemBuilder::new("Restore Deleted Note") + .id(NOTE_RESTORE_DELETED) + .enabled(false) + .build(app)?; let open_new_window = MenuItemBuilder::new("Open in New Window") .id(NOTE_OPEN_IN_NEW_WINDOW) .accelerator("CmdOrCtrl+Shift+O") @@ -295,6 +304,7 @@ fn build_note_menu(app: &App) -> MenuResult { Ok(SubmenuBuilder::new(app, "Note") .item(&archive_note) .item(&delete_note) + .item(&restore_deleted_note) .separator() .item(&open_new_window) .separator() @@ -421,6 +431,11 @@ pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) { set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled); } +/// Enable or disable menu items that depend on a deleted note preview being active. +pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) { + set_items_enabled(app_handle, RESTORE_DELETED_DEPENDENT_IDS, enabled); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/App.tsx b/src/App.tsx index fe6e86b0..3c2930b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' +import type { DeletedNoteEntry } from './components/note-list/noteListUtils' import { Editor } from './components/Editor' import { ResizeHandle } from './components/ResizeHandle' import { CreateTypeDialog } from './components/CreateTypeDialog' @@ -59,6 +60,7 @@ import { isNoteWindow, getNoteWindowParams } from './utils/windowMode' import { GitRequiredModal } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' import { trackEvent } from './lib/telemetry' +import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils' import './App.css' // Type declarations for mock content storage and test overrides @@ -313,18 +315,48 @@ function App() { }, [resolvedPath]) const handleDiscardFile = useCallback(async (relativePath: string) => { + const targetFile = vault.modifiedFiles.find((file) => file.relativePath === relativePath) + const activePathBefore = notes.activeTabPath try { if (isTauri()) { await invoke('git_discard_file', { vaultPath: resolvedPath, relativePath }) } else { await mockInvoke('git_discard_file', { vaultPath: resolvedPath, relativePath }) } - await vault.loadModifiedFiles() - await vault.reloadVault() + const reloadedEntries = await vault.reloadVault() + const affectedActiveTab = !!activePathBefore + && (activePathBefore === targetFile?.path || activePathBefore.endsWith('/' + relativePath)) + if (!affectedActiveTab) return + const refreshedEntry = reloadedEntries.find((entry) => + entry.path === targetFile?.path || entry.path.endsWith('/' + relativePath), + ) + if (refreshedEntry) { + await notes.handleReplaceActiveTab(refreshedEntry) + } else { + notes.closeAllTabs() + } } catch (err) { setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes') } - }, [resolvedPath, vault, setToastMessage]) + }, [resolvedPath, vault, notes, setToastMessage]) + + const handleOpenDeletedNote = useCallback(async (entry: DeletedNoteEntry) => { + let previewContent = 'Content not available (untracked)' + let hasDiff = false + try { + const diff = await vault.loadDiff(entry.path) + hasDiff = diff.length > 0 + previewContent = extractDeletedContentFromDiff(diff) ?? previewContent + } catch (err) { + console.warn('Failed to load deleted note preview:', err) + } + notes.openTabWithContent(entry, previewContent) + if (hasDiff) { + setTimeout(() => diffToggleRef.current(), 50) + } else { + setToastMessage('Content not available (untracked)') + } + }, [vault, notes, setToastMessage]) const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected }) const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles]) @@ -440,6 +472,14 @@ function App() { } }, [resolvedPath, vault, setToastMessage]) + const activeDeletedFile = useMemo(() => { + if (!notes.activeTabPath) return null + return vault.modifiedFiles.find((file) => + file.status === 'deleted' + && (file.path === notes.activeTabPath || notes.activeTabPath.endsWith('/' + file.relativePath)), + ) ?? null + }, [notes.activeTabPath, vault.modifiedFiles]) + const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, entries: vault.entries, @@ -462,7 +502,7 @@ function App() { onSetViewMode: setViewMode, onToggleInspector: () => layout.setInspectorCollapsed(c => !c), onToggleDiff: () => diffToggleRef.current(), - onToggleRawEditor: () => rawToggleRef.current(), + onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(), onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, onSelect: handleSetSelection, @@ -495,6 +535,8 @@ function App() { onOpenInNewWindow: handleOpenInNewWindow, onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: entryActions.handleToggleOrganized, + onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, + canRestoreDeletedNote: !!activeDeletedFile, }) const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null @@ -507,7 +549,7 @@ function App() { return filtered.map(e => ({ path: e.path, title: e.title, type: e.isA ?? 'Note', })) - }, [vault.entries, selection, inboxPeriod]) + }, [vault.entries, vault.views, selection, inboxPeriod]) const aiNoteListFilter = useMemo(() => { if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' } @@ -574,7 +616,7 @@ function App() { {selection.kind === 'filter' && selection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - diffToggleRef.current()} views={vault.views} visibleNotesRef={visibleNotesRef} /> + diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} views={vault.views} visibleNotesRef={visibleNotesRef} /> )} @@ -607,14 +649,14 @@ function App() { vaultPath={resolvedPath} noteList={aiNoteList} noteListFilter={aiNoteListFilter} - onToggleFavorite={entryActions.handleToggleFavorite} - onToggleOrganized={entryActions.handleToggleOrganized} - onDeleteNote={deleteActions.handleDeleteNote} - onArchiveNote={entryActions.handleArchiveNote} - onUnarchiveNote={entryActions.handleUnarchiveNote} + onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite} + onToggleOrganized={activeDeletedFile ? undefined : entryActions.handleToggleOrganized} + onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote} + onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote} + onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote} onContentChange={appSave.handleContentChange} onSave={appSave.handleSave} - onTitleSync={appSave.handleTitleSync} + onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync} rawToggleRef={rawToggleRef} diffToggleRef={diffToggleRef} canGoBack={canGoBack} @@ -625,8 +667,8 @@ function App() { onFileCreated={vaultBridge.handleAgentFileCreated} onFileModified={vaultBridge.handleAgentFileModified} onVaultChanged={vaultBridge.handleAgentVaultChanged} - onSetNoteIcon={handleSetNoteIcon} - onRemoveNoteIcon={handleRemoveNoteIcon} + onSetNoteIcon={activeDeletedFile ? undefined : handleSetNoteIcon} + onRemoveNoteIcon={activeDeletedFile ? undefined : handleRemoveNoteIcon} isConflicted={conflictFlow.isConflicted} onKeepMine={conflictFlow.handleKeepMine} onKeepTheirs={conflictFlow.handleKeepTheirs} diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 49837bc8..191a6caa 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -168,6 +168,7 @@ export function EditorContent({ const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false const hasH1 = freshEntry?.hasH1 ?? activeTab?.entry.hasH1 ?? false + const isDeletedPreview = !!activeTab && !freshEntry // Non-markdown text files always use the raw editor (no BlockNote) const isNonMarkdownText = activeTab?.entry.fileKind === 'text' const effectiveRawMode = rawMode || isNonMarkdownText @@ -177,7 +178,7 @@ export function EditorContent({ const isUntitledDraft = !!activeTab && activeTab.entry.filename.startsWith('untitled-') && (activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave') - const showTitleSection = !hasH1 && !isUntitledDraft + const showTitleSection = !isDeletedPreview && !hasH1 && !isUntitledDraft const titleSectionRef = useRef(null) const breadcrumbBarRef = useRef(null) @@ -222,7 +223,7 @@ export function EditorContent({ {isArchived && breadcrumbProps.onUnarchiveNote && ( breadcrumbProps.onUnarchiveNote!(path)} /> @@ -263,7 +264,7 @@ export function EditorContent({ )} - + )} diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 1b88bab5..6e2d3760 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -162,6 +162,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig }) { const isBinary = entry.fileKind === 'binary' const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown' + const isDeletedChange = changeStatus === 'deleted' const te = typeEntryMap[entry.isA ?? ''] const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color) const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color) @@ -189,13 +190,22 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig onMouseEnter={!isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined} data-testid={isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined} data-highlighted={isHighlighted || undefined} + data-note-path={entry.path} + data-change-status={changeStatus} title={isBinary ? 'Cannot open this file type' : undefined} > {changeStatus ? ( <>
-
+
{entry.filename}
diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 213b8beb..b080ac38 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { NoteList } from './NoteList' import { NoteItem } from './NoteItem' @@ -924,7 +924,7 @@ describe('NoteList — virtual list with large datasets', () => { expect(icons).toHaveLength(2) }) - it('shows deleted notes banner when files are deleted', () => { + it('shows deleted notes as individual rows when files are deleted', () => { const filesWithDeleted = [ { path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const }, { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, @@ -934,17 +934,20 @@ describe('NoteList — virtual list with large datasets', () => { ) expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument() - expect(screen.getByText('2 notes deleted')).toBeInTheDocument() + expect(screen.getByText('gone.md')).toBeInTheDocument() + expect(screen.getByText('also-gone.md')).toBeInTheDocument() + expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument() }) - it('shows singular form for single deleted note', () => { + it('renders deleted rows with dimmed strikethrough styling', () => { const filesWithOneDeleted = [ { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, ] render( ) - expect(screen.getByText('1 note deleted')).toBeInTheDocument() + expect(screen.getByText('gone.md')).toHaveClass('line-through') + expect(screen.getByText('gone.md')).toHaveClass('opacity-70') }) it('does not show deleted banner when no files are deleted', () => { @@ -975,6 +978,20 @@ describe('NoteList — virtual list with large datasets', () => { expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument() }) + it('shows "Restore note" on deleted rows in the changes context menu', () => { + const onDiscard = vi.fn() + const filesWithDeleted = [ + { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, + ] + render( + + ) + const noteItem = screen.getByText('gone.md').closest('[class*="border-b"]')! + fireEvent.contextMenu(noteItem) + expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() + expect(screen.getByTestId('restore-note-button')).toBeInTheDocument() + }) + it('does not show context menu when onDiscardFile is not provided', () => { render( @@ -998,15 +1015,41 @@ describe('NoteList — virtual list with large datasets', () => { expect(dialog.textContent).toContain('Build Laputa App') }) + it('opens the restore context menu action from Shift+F10 on a highlighted deleted row', () => { + const onDiscard = vi.fn() + const filesWithDeleted = [ + { path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const }, + ] + render( + + ) + const container = screen.getByTestId('note-list-container') + act(() => { + fireEvent.focus(container) + }) + act(() => { + fireEvent.keyDown(container, { key: 'F10', shiftKey: true }) + }) + expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument() + expect(screen.getByTestId('restore-note-button')).toBeInTheDocument() + }) + it('calls onDiscardFile with relativePath when discard is confirmed', async () => { const onDiscard = vi.fn().mockResolvedValue(undefined) render( ) const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')! - fireEvent.contextMenu(noteItem) - fireEvent.click(screen.getByTestId('discard-changes-button')) - fireEvent.click(screen.getByTestId('discard-confirm-button')) + act(() => { + fireEvent.contextMenu(noteItem) + }) + act(() => { + fireEvent.click(screen.getByTestId('discard-changes-button')) + }) + await act(async () => { + fireEvent.click(screen.getByTestId('discard-confirm-button')) + await Promise.resolve() + }) expect(onDiscard).toHaveBeenCalledWith('project/26q1-laputa-app.md') }) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 126a7275..d398824d 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -10,8 +10,7 @@ import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' import { NoteListHeader } from './note-list/NoteListHeader' import { FilterPills } from './note-list/FilterPills' import { EntityView, ListView } from './note-list/NoteListViews' -import { DeletedNotesBanner } from './note-list/TrashWarningBanner' -import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils' +import { type DeletedNoteEntry, isDeletedNoteEntry, routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils' import { useTypeEntryMap, useNoteListData, useNoteListSearch, useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState, @@ -47,15 +46,29 @@ function useBulkActions( function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { isChangesView: boolean; onDiscardFile?: (relativePath: string) => Promise; modifiedFiles?: ModifiedFile[] }) { const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null) - const [discardTarget, setDiscardTarget] = useState(null) + const [actionTarget, setActionTarget] = useState<{ entry: VaultEntry; action: 'discard' | 'restore'; relativePath: string } | null>(null) const ctxMenuRef = useRef(null) - const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => { + const resolveActionTarget = useCallback((entry: VaultEntry) => { + const file = modifiedFiles?.find((modified) => modified.path === entry.path || entry.path.endsWith('/' + modified.relativePath)) + if (!file) return null + return { + entry, + action: file.status === 'deleted' ? 'restore' as const : 'discard' as const, + relativePath: file.relativePath, + } + }, [modifiedFiles]) + + const openContextMenuForEntry = useCallback((entry: VaultEntry, point: { x: number; y: number }) => { if (!isChangesView || !onDiscardFile) return + setCtxMenu({ x: point.x, y: point.y, entry }) + }, [isChangesView, onDiscardFile]) + + const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - setCtxMenu({ x: e.clientX, y: e.clientY, entry }) - }, [isChangesView, onDiscardFile]) + openContextMenuForEntry(entry, { x: e.clientX, y: e.clientY }) + }, [openContextMenuForEntry]) const closeCtxMenu = useCallback(() => setCtxMenu(null), []) @@ -68,44 +81,54 @@ function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { i return () => document.removeEventListener('mousedown', handler) }, [ctxMenu, closeCtxMenu]) - const handleDiscardConfirm = useCallback(async () => { - if (!discardTarget || !onDiscardFile) return - const mf = modifiedFiles?.find((f) => f.path === discardTarget.path) - if (!mf) return - await onDiscardFile(mf.relativePath) - setDiscardTarget(null) - }, [discardTarget, onDiscardFile, modifiedFiles]) + const handleChangeConfirm = useCallback(async () => { + if (!actionTarget || !onDiscardFile) return + await onDiscardFile(actionTarget.relativePath) + setActionTarget(null) + }, [actionTarget, onDiscardFile]) + + const menuActionTarget = ctxMenu ? resolveActionTarget(ctxMenu.entry) : null + const menuActionLabel = menuActionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes' const contextMenuNode = ctxMenu ? (
) : null const dialogNode = ( - { if (!open) setDiscardTarget(null) }}> - + { if (!open) setActionTarget(null) }}> + - Discard changes + {actionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'} - Discard changes to {discardTarget?.title ?? 'this file'}? This cannot be undone. + {actionTarget?.action === 'restore' + ? <>Restore {actionTarget?.entry.filename ?? 'this file'} from Git? + : <>Discard changes to {actionTarget?.entry.title ?? 'this file'}? This cannot be undone. + } - - + + ) - return { handleNoteContextMenu, contextMenuNode, dialogNode } + return { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } } interface NoteListProps { @@ -130,11 +153,12 @@ interface NoteListProps { onOpenInNewWindow?: (entry: VaultEntry) => void onDiscardFile?: (relativePath: string) => Promise onAutoTriggerDiff?: () => void + onOpenDeletedNote?: (entry: DeletedNoteEntry) => void views?: ViewFile[] visibleNotesRef?: React.MutableRefObject } -function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkDeletePermanently, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views, visibleNotesRef }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkDeletePermanently, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, onOpenDeletedNote, views, visibleNotesRef }: NoteListProps) { const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection) @@ -160,35 +184,49 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot } return map }, [isChangesView, modifiedFiles]) - const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views }) + const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views }) // Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow) if (visibleNotesRef) { visibleNotesRef.current = isEntityView - ? searchedGroups.flatMap((g) => g.entries) - : searched + ? searchedGroups.flatMap((g) => g.entries).filter((entry) => !isDeletedNoteEntry(entry)) + : searched.filter((entry) => !isDeletedNoteEntry(entry)) } - const deletedCount = useMemo( - () => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0, - [isChangesView, modifiedFiles], - ) const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null - const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView }) + const handleKeyboardOpen = useCallback((entry: VaultEntry) => { + if (isDeletedNoteEntry(entry)) { + onOpenDeletedNote?.(entry) + return + } + onReplaceActiveTab(entry) + }, [onOpenDeletedNote, onReplaceActiveTab]) + const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => { + if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path) + }, []) + const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: handleKeyboardOpen, onPrefetch: handleKeyboardPrefetch, enabled: !isEntityView }) const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null) useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => { + if (isDeletedNoteEntry(entry)) { + routeNoteClick(entry, e, { + onReplace: () => onOpenDeletedNote?.(entry), + onSelect: () => onOpenDeletedNote?.(entry), + multiSelect, + }) + return + } routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect }) if (isChangesView && onAutoTriggerDiff) { // Small delay to let the tab open before triggering diff setTimeout(onAutoTriggerDiff, 50) } - }, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff]) + }, [onOpenDeletedNote, onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff]) const { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView) useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently) - const { handleNoteContextMenu, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }) + const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }) const getChangeStatus = useCallback((path: string) => { if (!changeStatusMap) return undefined @@ -201,8 +239,25 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot return undefined }, [changeStatusMap]) + const handleListKeyDown = useCallback((e: React.KeyboardEvent) => { + if (isChangesView && onDiscardFile && e.shiftKey && e.key === 'F10' && noteListKeyboard.highlightedPath) { + const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath) + if (!entry) return + e.preventDefault() + e.stopPropagation() + const row = document.querySelector(`[data-note-path="${entry.path}"]`) + const rect = row?.getBoundingClientRect() + openContextMenuForEntry(entry, { + x: rect ? rect.left + 24 : 160, + y: rect ? rect.bottom - 8 : 160, + }) + return + } + noteListKeyboard.handleKeyDown(e) + }, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry]) + const renderItem = useCallback((entry: VaultEntry) => ( - + ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu]) const handleCreateNote = useCallback(() => { @@ -214,15 +269,14 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot return (
-
+
{entitySelection ? ( ) : ( - + )}
- {isChangesView && deletedCount > 0 && } {showFilterPills && }
{multiSelect.isMultiSelecting && ( diff --git a/src/components/note-list/NoteListViews.tsx b/src/components/note-list/NoteListViews.tsx index 560b3a52..b4c9226b 100644 --- a/src/components/note-list/NoteListViews.tsx +++ b/src/components/note-list/NoteListViews.tsx @@ -33,16 +33,15 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, ) } -export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, deletedCount = 0, searched, query, renderItem, virtuosoRef }: { +export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, searched, query, renderItem, virtuosoRef }: { isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null - deletedCount?: number; searched: VaultEntry[]; query: string + searched: VaultEntry[]; query: string renderItem: (entry: VaultEntry) => React.ReactNode virtuosoRef?: React.RefObject }) { const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, !!isArchivedView, !!isInboxView, query) - const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0 - if (searched.length === 0 && !hasDeletedOnly) { + if (searched.length === 0) { return (
@@ -50,10 +49,6 @@ export function ListView({ isArchivedView, isChangesView, isInboxView, changesEr ) } - if (hasDeletedOnly) { - return
- } - return ( , modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) { +export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set, modifiedSuffixes: string[], modifiedFiles?: ModifiedFile[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) { const isEntityView = selection.kind === 'entity' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox' return useMemo(() => { if (isEntityView) return [] - if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes)) + if (isChangesView) { + if (modifiedFiles) return buildChangesEntries(entries, modifiedFiles) + return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes)) + } if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month') return filterEntries(entries, selection, subFilter, views) - }, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views]) + }, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views]) } // --- useNoteListData --- @@ -38,16 +41,17 @@ interface NoteListDataParams { entries: VaultEntry[]; selection: SidebarSelection query: string; listSort: SortOption; listDirection: SortDirection modifiedPathSet: Set; modifiedSuffixes: string[] + modifiedFiles?: ModifiedFile[] subFilter?: NoteListFilter inboxPeriod?: InboxPeriod views?: ViewFile[] } -export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views }: NoteListDataParams) { +export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived' - const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views) + const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views) const searched = useMemo(() => { const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection)) @@ -160,7 +164,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS } }, [typeDocument, persistence]) - const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod) + const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, undefined, subFilter, inboxPeriod) const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries]) const listSort = useMemo(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties]) const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc' diff --git a/src/components/note-list/noteListUtils.ts b/src/components/note-list/noteListUtils.ts index fb6e5e6d..69f235af 100644 --- a/src/components/note-list/noteListUtils.ts +++ b/src/components/note-list/noteListUtils.ts @@ -1,6 +1,11 @@ import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types' import type { RelationshipGroup } from '../../utils/noteListHelpers' +export interface DeletedNoteEntry extends VaultEntry { + __deletedNotePreview: true + __deletedRelativePath: string +} + export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[]): string { if (selection.kind === 'view') { const view = views?.find((v) => v.filename === selection.filename) @@ -60,3 +65,84 @@ export function isModifiedEntry(path: string, pathSet: Set, suffixes: st if (pathSet.has(path)) return true return suffixes.some((suffix) => path.endsWith(suffix)) } + +export function isDeletedNoteEntry(entry: VaultEntry): entry is DeletedNoteEntry { + return '__deletedNotePreview' in entry && entry.__deletedNotePreview === true +} + +function matchesModifiedFile(entry: VaultEntry, file: ModifiedFile): boolean { + return entry.path === file.path || entry.path.endsWith('/' + file.relativePath) +} + +function createDeletedNoteEntry(file: ModifiedFile): DeletedNoteEntry { + const filename = file.relativePath.split('/').pop() ?? file.relativePath + return { + path: file.path, + filename, + title: filename.replace(/\.md$/, ''), + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: null, + createdAt: null, + fileSize: 0, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + __deletedNotePreview: true, + __deletedRelativePath: file.relativePath, + } +} + +export function buildChangesEntries(entries: VaultEntry[], modifiedFiles: ModifiedFile[] | undefined): VaultEntry[] { + if (!modifiedFiles || modifiedFiles.length === 0) return [] + + const liveEntries = entries.filter((entry) => + modifiedFiles.some((file) => file.status !== 'deleted' && matchesModifiedFile(entry, file)), + ) + + const deletedEntries = modifiedFiles + .filter((file) => file.status === 'deleted') + .filter((file) => !entries.some((entry) => matchesModifiedFile(entry, file))) + .map(createDeletedNoteEntry) + + return [...liveEntries, ...deletedEntries] +} + +export function extractDeletedContentFromDiff(diff: string): string | null { + const lines: string[] = [] + let inHunk = false + + for (const line of diff.split('\n')) { + if (line.startsWith('@@')) { + inHunk = true + continue + } + if (!inHunk) continue + if (line.startsWith('\\')) continue + if (line.startsWith('-') || line.startsWith(' ')) { + lines.push(line.slice(1)) + } + } + + return lines.length > 0 ? lines.join('\n') : null +} diff --git a/src/hooks/commands/noteCommands.ts b/src/hooks/commands/noteCommands.ts index 429a8d63..d00239db 100644 --- a/src/hooks/commands/noteCommands.ts +++ b/src/hooks/commands/noteCommands.ts @@ -19,6 +19,8 @@ interface NoteCommandsConfig { isFavorite?: boolean onToggleOrganized?: (path: string) => void isOrganized?: boolean + onRestoreDeletedNote?: () => void + canRestoreDeletedNote?: boolean } export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] { @@ -29,6 +31,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] { onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite, onToggleOrganized, isOrganized, + onRestoreDeletedNote, canRestoreDeletedNote, } = config return [ @@ -46,6 +49,12 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] { keywords: ['archive'], enabled: hasActiveNote, execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) }, }, + { + id: 'restore-deleted-note', label: 'Restore Deleted Note', group: 'Note', + keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'], + enabled: !!canRestoreDeletedNote && !!onRestoreDeletedNote, + execute: () => onRestoreDeletedNote?.(), + }, { id: 'toggle-favorite', label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', group: 'Note', shortcut: '⌘D', keywords: ['favorite', 'star', 'bookmark', 'pin'], diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 6200048d..afa0df97 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -67,6 +67,8 @@ interface AppCommandsConfig { onOpenInNewWindow?: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void + onRestoreDeletedNote?: () => void + canRestoreDeletedNote?: boolean } /** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */ @@ -149,9 +151,11 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onReloadVault: config.onReloadVault, onRepairVault: config.onRepairVault, onOpenInNewWindow: config.onOpenInNewWindow, + onRestoreDeletedNote: config.onRestoreDeletedNote, activeTabPathRef: config.activeTabPathRef, activeTabPath: config.activeTabPath, modifiedCount: config.modifiedCount, + hasRestorableDeletedNote: config.canRestoreDeletedNote, }) const commands = useCommandRegistry({ @@ -205,6 +209,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onOpenInNewWindow: config.onOpenInNewWindow, onToggleFavorite: config.onToggleFavorite, onToggleOrganized: config.onToggleOrganized, + onRestoreDeletedNote: config.onRestoreDeletedNote, + canRestoreDeletedNote: config.canRestoreDeletedNote, }) useKeyboardNavigation({ diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index b31ed578..009888de 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -150,6 +150,21 @@ describe('useCommandRegistry', () => { findCommand(result.current, 'set-note-icon')!.execute() expect(onSetNoteIcon).toHaveBeenCalled() }) + + it('includes restore deleted note command when provided', () => { + const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: true }) + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'restore-deleted-note') + expect(cmd).toBeDefined() + expect(cmd!.enabled).toBe(true) + }) + + it('disables restore deleted note when there is no deleted preview', () => { + const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: false }) + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'restore-deleted-note') + expect(cmd!.enabled).toBe(false) + }) }) describe('pluralizeType', () => { diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 3e8dcd6a..21e8ddaa 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -30,6 +30,8 @@ interface CommandRegistryConfig { onOpenInNewWindow?: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void + onRestoreDeletedNote?: () => void + canRestoreDeletedNote?: boolean onQuickOpen: () => void onCreateNote: () => void onCreateNoteOfType: (type: string) => void @@ -85,6 +87,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, onToggleOrganized, + onRestoreDeletedNote, canRestoreDeletedNote, selection, noteListFilter, onSetNoteListFilter, } = config @@ -108,6 +111,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onDeleteNote, onArchiveNote, onUnarchiveNote, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite, onToggleOrganized, isOrganized: activeEntry?.organized ?? false, + onRestoreDeletedNote, canRestoreDeletedNote, }), ...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }), ...buildViewCommands({ @@ -137,6 +141,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, isSectionGroup, noteListFilter, onSetNoteListFilter, onOpenInNewWindow, onToggleFavorite, isFavorite, - onToggleOrganized, activeEntry, + onToggleOrganized, onRestoreDeletedNote, canRestoreDeletedNote, activeEntry, ]) } diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index 2fdaee04..1578f294 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -35,8 +35,10 @@ function makeHandlers(): MenuEventHandlers { onInstallMcp: vi.fn(), onReloadVault: vi.fn(), onOpenInNewWindow: vi.fn(), + onRestoreDeletedNote: vi.fn(), activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, activeTabPath: '/vault/test.md', + hasRestorableDeletedNote: false, } } @@ -199,6 +201,12 @@ describe('dispatchMenuEvent', () => { expect(h.onToggleAIChat).toHaveBeenCalled() }) + it('note-restore-deleted triggers restore deleted note', () => { + const h = makeHandlers() + dispatchMenuEvent('note-restore-deleted', h) + expect(h.onRestoreDeletedNote).toHaveBeenCalled() + }) + it('view-toggle-backlinks triggers toggle inspector', () => { const h = makeHandlers() dispatchMenuEvent('view-toggle-backlinks', h) diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 92511a63..8695b492 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -37,10 +37,12 @@ export interface MenuEventHandlers { onOpenInNewWindow?: () => void onReloadVault?: () => void onRepairVault?: () => void + onRestoreDeletedNote?: () => void activeTabPathRef: React.MutableRefObject activeTabPath: string | null modifiedCount?: number conflictCount?: number + hasRestorableDeletedNote?: boolean } const VIEW_MODE_MAP: Record = { @@ -78,7 +80,7 @@ type OptionalHandler = | 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted' | 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault' - | 'onOpenInNewWindow' + | 'onOpenInNewWindow' | 'onRestoreDeletedNote' const OPTIONAL_EVENT_MAP: Record = { 'view-go-back': 'onGoBack', @@ -99,6 +101,7 @@ const OPTIONAL_EVENT_MAP: Record = { 'vault-reload': 'onReloadVault', 'vault-repair': 'onRepairVault', 'note-open-in-new-window': 'onOpenInNewWindow', + 'note-restore-deleted': 'onRestoreDeletedNote', } function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean { @@ -162,7 +165,8 @@ export function useMenuEvents(handlers: MenuEventHandlers) { hasActiveNote: handlers.activeTabPath !== null, hasModifiedFiles: handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined, hasConflicts: handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined, + hasRestorableDeletedNote: handlers.hasRestorableDeletedNote, }) }).catch(() => {}) - }, [handlers.activeTabPath, handlers.modifiedCount, handlers.conflictCount]) + }, [handlers.activeTabPath, handlers.modifiedCount, handlers.conflictCount, handlers.hasRestorableDeletedNote]) } diff --git a/src/hooks/useNoteListKeyboard.ts b/src/hooks/useNoteListKeyboard.ts index 0cd2b985..a36979d4 100644 --- a/src/hooks/useNoteListKeyboard.ts +++ b/src/hooks/useNoteListKeyboard.ts @@ -1,17 +1,17 @@ import { useState, useCallback, useEffect, useRef } from 'react' import type { VirtuosoHandle } from 'react-virtuoso' import type { VaultEntry } from '../types' -import { prefetchNoteContent } from './useTabManagement' interface NoteListKeyboardOptions { items: VaultEntry[] selectedNotePath: string | null onOpen: (entry: VaultEntry) => void + onPrefetch?: (entry: VaultEntry) => void enabled: boolean } export function useNoteListKeyboard({ - items, selectedNotePath, onOpen, enabled, + items, selectedNotePath, onOpen, onPrefetch, enabled, }: NoteListKeyboardOptions) { const [highlightedIndex, setHighlightedIndex] = useState(-1) const virtuosoRef = useRef(null) @@ -30,7 +30,7 @@ export function useNoteListKeyboard({ setHighlightedIndex(prev => { const next = Math.min((prev < 0 ? -1 : prev) + 1, items.length - 1) virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' }) - if (next >= 0 && next < items.length) prefetchNoteContent(items[next].path) + if (next >= 0 && next < items.length) onPrefetch?.(items[next]) return next }) } else if (e.key === 'ArrowUp') { @@ -38,14 +38,14 @@ export function useNoteListKeyboard({ setHighlightedIndex(prev => { const next = Math.max((prev < 0 ? items.length : prev) - 1, 0) virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' }) - if (next >= 0 && next < items.length) prefetchNoteContent(items[next].path) + if (next >= 0 && next < items.length) onPrefetch?.(items[next]) return next }) } else if (e.key === 'Enter' && highlightedIndex >= 0 && highlightedIndex < items.length) { e.preventDefault() onOpen(items[highlightedIndex]) } - }, [enabled, items, highlightedIndex, onOpen]) + }, [enabled, items, highlightedIndex, onOpen, onPrefetch]) const handleFocus = useCallback(() => { if (highlightedIndex >= 0 || items.length === 0) return diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index e887121f..3637df75 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -490,8 +490,8 @@ describe('resolveNoteStatus', () => { expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean') }) - it('returns clean for deleted files', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('clean') + it('returns modified for deleted files so deleted previews keep diff affordances', () => { + expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified') }) it('newPaths takes priority over git modified', () => { diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 04725ab9..d9fdcd29 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -83,7 +83,7 @@ export function resolveNoteStatus( const gitEntry = modifiedFiles.find((f) => f.path === path) if (!gitEntry) return 'clean' if (gitEntry.status === 'untracked' || gitEntry.status === 'added') return 'new' - if (gitEntry.status === 'modified') return 'modified' + if (gitEntry.status === 'modified' || gitEntry.status === 'deleted') return 'modified' return 'clean' } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 0f4a8edf..ec6dfb53 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -35,6 +35,22 @@ function mockModifiedFiles(): ModifiedFile[] { function mockFileDiff(path: string): string { const filename = path.split('/').pop() ?? 'unknown' + if (filename === 'old-draft.md') { + return `diff --git a/${filename} b/${filename} +deleted file mode 100644 +index abc1234..0000000 +--- a/${filename} ++++ /dev/null +@@ -1,7 +0,0 @@ +---- +-title: Old Draft +-type: Note +---- +- +-# Old Draft +- +-This note was deleted.` + } return `diff --git a/${filename} b/${filename} index abc1234..def5678 100644 --- a/${filename} diff --git a/tests/smoke/show-deleted-notes-in-changes-view.spec.ts b/tests/smoke/show-deleted-notes-in-changes-view.spec.ts index 9bdf060c..4d1ee85b 100644 --- a/tests/smoke/show-deleted-notes-in-changes-view.spec.ts +++ b/tests/smoke/show-deleted-notes-in-changes-view.spec.ts @@ -13,25 +13,24 @@ test.describe('Show deleted notes in Changes view', () => { await page.waitForLoadState('networkidle') }) - test('changes view shows deleted notes banner', async ({ page }) => { + test('changes view shows deleted notes as rows instead of a banner', async ({ page }) => { await navigateToChanges(page) - const banner = page.locator('[data-testid="deleted-notes-banner"]') - await expect(banner).toBeVisible({ timeout: 5000 }) - await expect(banner).toContainText('1 note deleted') + const deletedRow = page.locator('[data-change-status="deleted"]').filter({ hasText: 'old-draft.md' }) + await expect(deletedRow).toBeVisible({ timeout: 5000 }) + await expect(page.locator('[data-testid="deleted-notes-banner"]')).toHaveCount(0) }) - test('deleted banner is visually distinct from note items', async ({ page }) => { + test('clicking a deleted row opens its deleted diff preview', async ({ page }) => { await navigateToChanges(page) - const banner = page.locator('[data-testid="deleted-notes-banner"]') - await expect(banner).toBeVisible({ timeout: 5000 }) - // Banner should have reduced opacity (visually distinct) - const opacity = await banner.evaluate((el) => window.getComputedStyle(el).opacity) - expect(parseFloat(opacity)).toBeLessThan(1) + await page.getByText('old-draft.md').click() + await expect(page.getByText('Back to editor')).toBeVisible({ timeout: 5000 }) + await expect(page.getByText('This note was deleted.')).toBeVisible({ timeout: 5000 }) }) - test('changes counter in sidebar matches list items plus deleted count', async ({ page }) => { - // The sidebar badge should show the total count (modified + added + deleted) - const changesBadge = page.locator('text=Changes').locator('..') - await expect(changesBadge).toBeVisible({ timeout: 5000 }) + test('deleted rows expose a restore action from the context menu', async ({ page }) => { + await navigateToChanges(page) + await page.getByText('old-draft.md').click({ button: 'right' }) + await expect(page.locator('[data-testid="changes-context-menu"]')).toBeVisible({ timeout: 5000 }) + await expect(page.locator('[data-testid="restore-note-button"]')).toBeVisible({ timeout: 5000 }) }) })