feat: show deleted notes as restorable changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
70
src/App.tsx
70
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' ? (
|
||||
<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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user