feat: show deleted notes as restorable changes

This commit is contained in:
lucaronin
2026-04-07 20:09:04 +02:00
parent 59205c4012
commit 8218bcf558
23 changed files with 426 additions and 105 deletions

View File

@@ -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

View File

@@ -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/<slug>.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

View File

@@ -48,6 +48,7 @@ pub fn update_menu_state(
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
) -> 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<bool>,
_has_conflicts: Option<bool>,
_has_restorable_deleted_note: Option<bool>,
) -> Result<(), String> {
Ok(())
}

View File

@@ -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::*;

View File

@@ -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' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} visibleNotesRef={visibleNotesRef} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} views={vault.views} visibleNotesRef={visibleNotesRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -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}

View File

@@ -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<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
@@ -222,7 +223,7 @@ export function EditorContent({
<ActiveTabBreadcrumb
activeTab={activeTab}
barRef={breadcrumbBarRef}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, forceRawMode: isNonMarkdownText, ...breadcrumbProps }}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, forceRawMode: isNonMarkdownText || isDeletedPreview, ...breadcrumbProps }}
/>
{isArchived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(path)} />
@@ -263,7 +264,7 @@ export function EditorContent({
</>
)}
</div>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable />
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isDeletedPreview} />
</div>
</div>
)}

View File

@@ -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 ? (
<>
<ChangeStatusIcon status={changeStatus} />
<div className="pr-5">
<div className={cn("truncate text-[13px] font-mono", isSelected ? "font-semibold" : "font-normal")} style={{ fontSize: 12 }}>
<div
className={cn(
"truncate text-[13px] font-mono",
isSelected ? "font-semibold" : "font-normal",
isDeletedChange && "text-muted-foreground line-through opacity-70",
)}
style={{ fontSize: 12 }}
>
{entry.filename}
</div>
</div>

View File

@@ -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', () => {
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
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(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithOneDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
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(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
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(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
@@ -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(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
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(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
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')
})

View File

@@ -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<void>; modifiedFiles?: ModifiedFile[] }) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [discardTarget, setDiscardTarget] = useState<VaultEntry | null>(null)
const [actionTarget, setActionTarget] = useState<{ entry: VaultEntry; action: 'discard' | 'restore'; relativePath: string } | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(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 ? (
<div ref={ctxMenuRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }} data-testid="changes-context-menu">
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => { setDiscardTarget(ctxMenu.entry); closeCtxMenu() }}
data-testid="discard-changes-button"
onClick={() => {
if (!menuActionTarget) return
setActionTarget(menuActionTarget)
closeCtxMenu()
}}
data-testid={menuActionTarget?.action === 'restore' ? 'restore-note-button' : 'discard-changes-button'}
>
Discard changes
{menuActionLabel}
</button>
</div>
) : null
const dialogNode = (
<Dialog open={!!discardTarget} onOpenChange={(open) => { if (!open) setDiscardTarget(null) }}>
<DialogContent showCloseButton={false} data-testid="discard-confirm-dialog">
<Dialog open={!!actionTarget} onOpenChange={(open) => { if (!open) setActionTarget(null) }}>
<DialogContent showCloseButton={false} data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-dialog' : 'discard-confirm-dialog'}>
<DialogHeader>
<DialogTitle>Discard changes</DialogTitle>
<DialogTitle>{actionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'}</DialogTitle>
<DialogDescription>
Discard changes to <strong>{discardTarget?.title ?? 'this file'}</strong>? This cannot be undone.
{actionTarget?.action === 'restore'
? <>Restore <strong>{actionTarget?.entry.filename ?? 'this file'}</strong> from Git?</>
: <>Discard changes to <strong>{actionTarget?.entry.title ?? 'this file'}</strong>? This cannot be undone.</>
}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDiscardConfirm} data-testid="discard-confirm-button">Discard</Button>
<Button variant="outline" onClick={() => setActionTarget(null)}>Cancel</Button>
<Button variant={actionTarget?.action === 'restore' ? 'default' : 'destructive'} onClick={handleChangeConfirm} data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-button' : 'discard-confirm-button'}>
{actionTarget?.action === 'restore' ? 'Restore' : 'Discard'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
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<void>
onAutoTriggerDiff?: () => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
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<HTMLDivElement>) => {
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<HTMLElement>(`[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) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [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 (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onUpdateTypeProperty={onUpdateTypeSort} />
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={handleListKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
</div>
{multiSelect.isMultiSelecting && (

View File

@@ -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<VirtuosoHandle | null>
}) {
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 (
<div className="h-full overflow-y-auto">
<EmptyMessage text={emptyText} />
@@ -50,10 +49,6 @@ export function ListView({ isArchivedView, isChangesView, isInboxView, changesEr
)
}
if (hasDeletedOnly) {
return <div className="h-full" />
}
return (
<Virtuoso
ref={virtuosoRef}

View File

@@ -9,7 +9,7 @@ import {
} from '../../utils/noteListHelpers'
import type { InboxPeriod } from '../../types'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import { buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
// --- useTypeEntryMap ---
@@ -20,16 +20,19 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
// --- useFilteredEntries ---
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) {
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, 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<string>; 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<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'

View File

@@ -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<string>, 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
}

View File

@@ -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'],

View File

@@ -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({

View File

@@ -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', () => {

View File

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

View File

@@ -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<string | null>,
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)

View File

@@ -37,10 +37,12 @@ export interface MenuEventHandlers {
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onRestoreDeletedNote?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
activeTabPath: string | null
modifiedCount?: number
conflictCount?: number
hasRestorableDeletedNote?: boolean
}
const VIEW_MODE_MAP: Record<string, ViewMode> = {
@@ -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<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -99,6 +101,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'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])
}

View File

@@ -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<VirtuosoHandle>(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

View File

@@ -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', () => {

View File

@@ -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'
}

View File

@@ -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}

View File

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