diff --git a/design/trashed-note-editor.pen b/design/trashed-note-editor.pen new file mode 100644 index 00000000..a604284b --- /dev/null +++ b/design/trashed-note-editor.pen @@ -0,0 +1 @@ +{"children":[{"id":"trashed-banner-frame","type":"frame","name":"Trashed Note Banner","x":0,"y":0,"width":720,"height":340,"fill":"#FFFFFF","cornerRadius":[8,8,8,8],"children":[{"id":"breadcrumb","type":"frame","name":"Breadcrumb Bar","width":720,"height":45,"fill":"#FFFFFF","layout":"horizontal","mainAxisAlignment":"space-between","crossAxisAlignment":"center","padding":16,"children":[{"id":"breadcrumb-left","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","children":[{"id":"type-label","type":"text","content":"Note","fontSize":12,"textColor":"#6B7280"},{"id":"sep","type":"text","content":"โ€บ","fontSize":12,"textColor":"#6B7280"},{"id":"title","type":"text","content":"My Trashed Note","fontSize":12,"fontWeight":"600","textColor":"#1F2937"},{"id":"dot","type":"text","content":"ยท","fontSize":12,"textColor":"#6B7280"},{"id":"words","type":"text","content":"1,234 words","fontSize":12,"textColor":"#6B7280"}]},{"id":"breadcrumb-right","type":"frame","layout":"horizontal","gap":12,"crossAxisAlignment":"center","children":[{"id":"restore-icon","type":"text","content":"โ†บ","fontSize":14,"textColor":"#6B7280"},{"id":"inspector-icon","type":"text","content":"โš™","fontSize":14,"textColor":"#6B7280"}]}]},{"id":"banner","type":"frame","name":"Trashed Note Banner","width":720,"height":32,"fill":"#FEF2F2","layout":"horizontal","crossAxisAlignment":"center","gap":8,"padding":16,"children":[{"id":"trash-icon","type":"text","content":"๐Ÿ—‘","fontSize":12,"textColor":"#DC2626"},{"id":"banner-text","type":"text","content":"This note is in the Trash","fontSize":12,"textColor":"#6B7280","width":400},{"id":"restore-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"restore-icon-2","type":"text","content":"โ†บ","fontSize":11,"textColor":"#2563EB"},{"id":"restore-label","type":"text","content":"Restore","fontSize":11,"textColor":"#2563EB"}]},{"id":"delete-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"delete-icon","type":"text","content":"๐Ÿ—‘","fontSize":11,"textColor":"#DC2626"},{"id":"delete-label","type":"text","content":"Delete permanently","fontSize":11,"textColor":"#DC2626"}]}]},{"id":"editor-area","type":"frame","name":"Editor (read-only, muted)","width":720,"height":260,"fill":"#FAFAFA","padding":32,"children":[{"id":"h1","type":"text","content":"My Trashed Note","fontSize":24,"fontWeight":"700","textColor":"#9CA3AF"},{"id":"p1","type":"text","content":"This is the content of a trashed note. The editor is non-editable.","fontSize":14,"textColor":"#9CA3AF","y":40},{"id":"p2","type":"text","content":"Navigation and copy still work, but typing and editing are disabled.","fontSize":14,"textColor":"#9CA3AF","y":64}]}]}],"variables":{}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5731f6b7..26860cc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -206,6 +206,12 @@ fn purge_trash(vault_path: String) -> Result, String> { vault::purge_trash(&vault_path) } +#[tauri::command] +fn delete_note(path: String) -> Result { + let path = expand_tilde(&path); + vault::delete_note(&path) +} + #[tauri::command] fn migrate_is_a_to_type(vault_path: String) -> Result { let vault_path = expand_tilde(&vault_path); @@ -544,6 +550,7 @@ pub fn run() { save_image, copy_image_to_vault, purge_trash, + delete_note, migrate_is_a_to_type, batch_archive_notes, batch_trash_notes, diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 661c560f..6f580a55 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -11,7 +11,7 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; pub use rename::{rename_note, RenameResult}; -pub use trash::purge_trash; +pub use trash::{delete_note, purge_trash}; use parsing::{ capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, diff --git a/src-tauri/src/vault/trash.rs b/src-tauri/src/vault/trash.rs index bf1bc984..289a720d 100644 --- a/src-tauri/src/vault/trash.rs +++ b/src-tauri/src/vault/trash.rs @@ -42,6 +42,21 @@ fn try_purge_file(path: &Path) -> Option { } } +/// Permanently delete a single note file. +/// Returns the deleted path on success, or an error if the file doesn't exist. +pub fn delete_note(path: &str) -> Result { + let file = Path::new(path); + if !file.exists() { + return Err(format!("File does not exist: {}", path)); + } + if !file.is_file() { + return Err(format!("Path is not a file: {}", path)); + } + fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?; + log::info!("Permanently deleted note: {}", path); + Ok(path.to_string()) +} + /// Scan all markdown files in the vault and delete those where /// `Trashed at` frontmatter is more than 30 days ago. /// Returns the list of deleted file paths. @@ -95,6 +110,28 @@ mod tests { file.write_all(content.as_bytes()).unwrap(); } + #[test] + fn test_delete_note_removes_file() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "doomed.md", + "---\ntitle: Doomed\n---\n# Doomed\n", + ); + let path = dir.path().join("doomed.md"); + assert!(path.exists()); + let result = delete_note(path.to_str().unwrap()); + assert!(result.is_ok()); + assert!(!path.exists()); + } + + #[test] + fn test_delete_note_nonexistent_file() { + let result = delete_note("/nonexistent/path/that/does/not/exist.md"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + #[test] fn test_purge_trash_deletes_old_trashed_files() { let dir = TempDir::new().unwrap(); diff --git a/src/App.tsx b/src/App.tsx index 28bafdee..ade057a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,8 @@ import { useBuildNumber } from './hooks/useBuildNumber' import { useOnboarding } from './hooks/useOnboarding' import { useThemeManager } from './hooks/useThemeManager' import { UpdateBanner } from './components/UpdateBanner' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from './mock-tauri' import { extractOutgoingLinks } from './utils/wikilinks' import type { SidebarSelection } from './types' import './App.css' @@ -248,6 +250,18 @@ function App() { createTypeEntry: notes.createTypeEntrySilent, }) + const handleDeleteNote = useCallback(async (path: string) => { + try { + if (isTauri()) await invoke('delete_note', { path }) + else await mockInvoke('delete_note', { path }) + notes.handleCloseTab(path) + vault.removeEntry(path) + setToastMessage('Note permanently deleted') + } catch (e) { + setToastMessage(`Failed to delete note: ${e}`) + } + }, [notes, vault, setToastMessage]) + const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory) const handleCreateType = useCallback((name: string) => { @@ -410,6 +424,7 @@ function App() { vaultPath={resolvedPath} onTrashNote={entryActions.handleTrashNote} onRestoreNote={entryActions.handleRestoreNote} + onDeleteNote={handleDeleteNote} onArchiveNote={entryActions.handleArchiveNote} onUnarchiveNote={entryActions.handleUnarchiveNote} onRenameTab={handleRenameTab} diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 93f39568..34ca31b5 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -44,7 +44,7 @@ vi.mock('@blocknote/react', () => ({ })) vi.mock('@blocknote/mantine', () => ({ - BlockNoteView: ({ children }: { children?: React.ReactNode }) =>
{children}
, + BlockNoteView: ({ children, editable }: { children?: React.ReactNode; editable?: boolean }) =>
{children}
, })) vi.mock('@blocknote/mantine/style.css', () => ({})) @@ -294,6 +294,41 @@ describe('Editor', () => { mockEditor.replaceBlocks.mockClear() mockEditor.insertBlocks.mockClear() }) + describe('trashed note behavior', () => { + const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 } + const trashedTab = { entry: trashedEntry, content: mockContent } + + function renderTrashed(overrides: Partial[0]> = {}) { + return render() + } + + it('shows banner and read-only editor when note is trashed', () => { + renderTrashed() + expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument() + expect(screen.getByText('This note is in the Trash')).toBeInTheDocument() + expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false') + }) + + it('does not show banner and sets editable for normal notes', () => { + render() + expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument() + expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') + }) + + it('calls onRestoreNote when banner restore is clicked', () => { + const onRestoreNote = vi.fn() + renderTrashed({ onRestoreNote }) + fireEvent.click(screen.getByTestId('trashed-banner-restore')) + expect(onRestoreNote).toHaveBeenCalledWith(trashedEntry.path) + }) + + it('calls onDeleteNote when banner delete is clicked', () => { + const onDeleteNote = vi.fn() + renderTrashed({ onDeleteNote }) + fireEvent.click(screen.getByTestId('trashed-banner-delete')) + expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path) + }) + }) }) describe('wikilink autocomplete', () => { diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index babec93a..bea1d6e3 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -50,6 +50,7 @@ interface EditorProps { vaultPath?: string onTrashNote?: (path: string) => void onRestoreNote?: (path: string) => void + onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void onRenameTab?: (path: string, newTitle: string) => void @@ -110,7 +111,7 @@ export const Editor = memo(function Editor({ onUpdateFrontmatter, onDeleteProperty, onAddProperty, showAIChat, onToggleAIChat, vaultPath, - onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, + onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote, onRenameTab, onContentChange, onSave, onTitleSync, canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed, isDarkTheme, @@ -157,7 +158,6 @@ export const Editor = memo(function Editor({ const isLoadingNewTab = activeTabPath !== null && !activeTab const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean' const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified')) - const showRightPanel = !!(showAIChat || !inspectorCollapsed) return (
@@ -202,13 +202,14 @@ export const Editor = memo(function Editor({ onEditorChange={handleEditorChange} onTrashNote={onTrashNote} onRestoreNote={onRestoreNote} + onDeleteNote={onDeleteNote} onArchiveNote={onArchiveNote} onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} isDarkTheme={isDarkTheme} /> } - {showRightPanel && } + {(showAIChat || !inspectorCollapsed) && } void onTrashNote?: (path: string) => void onRestoreNote?: (path: string) => void + onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void vaultPath?: string @@ -97,7 +99,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) { function ActiveTabBreadcrumb({ activeTab, props }: { activeTab: Tab - props: Omit + props: Omit }) { const wordCount = countWords(activeTab.content) const path = activeTab.entry.path @@ -124,14 +126,38 @@ function ActiveTabBreadcrumb({ activeTab, props }: { ) } +function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed }: { + activeTab: Tab | null; isLoadingNewTab: boolean; entries: VaultEntry[] + editor: ReturnType + diffMode: boolean; diffContent: string | null; onToggleDiff: () => void + rawMode: boolean; onRawContentChange?: (path: string, content: string) => void; onSave?: () => void + onNavigateWikilink: (target: string) => void; onEditorChange?: () => void + vaultPath?: string; isDarkTheme?: boolean; isTrashed: boolean +}) { + const showEditor = !diffMode && !rawMode + return ( + <> + {diffMode && } + + {showEditor && activeTab && ( +
+ +
+ )} + {isLoadingNewTab && showEditor && } + + ) +} + export function EditorContent({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, + onDeleteNote, ...breadcrumbProps }: EditorContentProps) { - const showEditor = !diffMode && !rawMode + const isTrashed = activeTab?.entry.trashed ?? false return (
@@ -141,14 +167,13 @@ export function EditorContent({ props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }} /> )} - {diffMode && } - - {showEditor && activeTab && ( -
- -
+ {activeTab && isTrashed && ( + breadcrumbProps.onRestoreNote?.(activeTab.entry.path)} + onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)} + /> )} - {isLoadingNewTab && showEditor && } +
) } diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index c438e371..1c18fa0f 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -23,13 +23,14 @@ function useInsertImageCallback(editor: ReturnType) { } /** Single BlockNote editor view โ€” content is swapped via replaceBlocks */ -export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: { +export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: { editor: ReturnType entries: VaultEntry[] onNavigateWikilink: (target: string) => void onChange?: () => void vaultPath?: string isDarkTheme?: boolean + editable?: boolean }) { const navigateRef = useRef(onNavigateWikilink) useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink]) @@ -103,6 +104,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange editor={editor} theme={isDarkTheme ? 'dark' : 'light'} onChange={onChange} + editable={editable} > { + it('renders the banner with trash message', () => { + render() + expect(screen.getByText('This note is in the Trash')).toBeInTheDocument() + expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument() + }) + + it('shows Restore and Delete permanently buttons', () => { + render() + expect(screen.getByText('Restore')).toBeInTheDocument() + expect(screen.getByText('Delete permanently')).toBeInTheDocument() + }) + + it('calls onRestore when Restore button is clicked', () => { + const onRestore = vi.fn() + render() + fireEvent.click(screen.getByTestId('trashed-banner-restore')) + expect(onRestore).toHaveBeenCalledOnce() + }) + + it('calls onDeletePermanently when Delete permanently button is clicked', () => { + const onDeletePermanently = vi.fn() + render() + fireEvent.click(screen.getByTestId('trashed-banner-delete')) + expect(onDeletePermanently).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/TrashedNoteBanner.tsx b/src/components/TrashedNoteBanner.tsx new file mode 100644 index 00000000..09ed9f1e --- /dev/null +++ b/src/components/TrashedNoteBanner.tsx @@ -0,0 +1,44 @@ +import { memo } from 'react' +import { Trash, ArrowCounterClockwise } from '@phosphor-icons/react' + +interface TrashedNoteBannerProps { + onRestore: () => void + onDeletePermanently: () => void +} + +export const TrashedNoteBanner = memo(function TrashedNoteBanner({ + onRestore, + onDeletePermanently, +}: TrashedNoteBannerProps) { + return ( +
+ + This note is in the Trash + + +
+ ) +}) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 3b01ae81..ac9aafa8 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -221,6 +221,7 @@ export const mockHandlers: Record any> = { }), clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`, purge_trash: () => [], + delete_note: (args: { path: string }) => args.path, migrate_is_a_to_type: () => 0, batch_archive_notes: (args: { paths: string[] }) => args.paths.length, batch_trash_notes: (args: { paths: string[] }) => args.paths.length,