From 74656caf6128ac52718f2a91b5bdca16897f29b4 Mon Sep 17 00:00:00 2001 From: Barbara Leth Date: Tue, 5 May 2026 14:17:37 +0200 Subject: [PATCH 1/4] Add attachment previews and file icons --- src-tauri/src/lib.rs | 210 +++++++++++++++++++++++- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 26 ++- src/components/icons/index.tsx | 57 +++++++ src/components/layout/Sidebar.tsx | 3 +- src/components/notes/FileTypeIcon.tsx | 22 +++ src/components/notes/FolderTreeView.tsx | 146 +++++++++++++++- src/components/notes/NoteList.tsx | 102 +++++++++++- src/components/preview/FilePreview.tsx | 180 ++++++++++++++++++++ src/components/ui/index.tsx | 3 + src/context/NotesContext.tsx | 59 ++++++- src/lib/folderTree.ts | 31 +++- src/services/files.ts | 4 + src/services/notes.ts | 6 +- src/types/note.ts | 13 ++ 15 files changed, 829 insertions(+), 35 deletions(-) create mode 100644 src/components/notes/FileTypeIcon.tsx create mode 100644 src/components/preview/FilePreview.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04f54a80..f2201bcd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,6 +27,27 @@ pub struct NoteMetadata { pub modified: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AttachmentKind { + Image, + Pdf, + Text, + File, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentMetadata { + pub id: String, + pub name: String, + pub path: String, + pub extension: String, + pub kind: AttachmentKind, + pub modified: i64, + pub size: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CliStatus { pub supported: bool, @@ -671,6 +692,60 @@ fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String] } } +fn attachment_kind_from_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => Some(AttachmentKind::Image), + "pdf" => Some(AttachmentKind::Pdf), + "txt" | "text" | "log" | "csv" | "tsv" | "json" | "xml" | "yaml" | "yml" => { + Some(AttachmentKind::Text) + } + _ => None, + } +} + +fn attachment_from_abs_path( + notes_root: &Path, + file_path: &Path, + ignored_dirs: &[String], +) -> Option { + let rel = file_path.strip_prefix(notes_root).ok()?; + + for component in rel.parent().unwrap_or(Path::new("")).components() { + if let std::path::Component::Normal(name) = component { + let name_str = name.to_str()?; + if EXCLUDED_DIRS.contains(&name_str) || ignored_dirs.iter().any(|d| d == name_str) { + return None; + } + } + } + + let ext = file_path.extension()?.to_str()?.to_ascii_lowercase(); + if ext == "md" || ext == "markdown" { + return None; + } + let kind = attachment_kind_from_extension(&ext)?; + let metadata = std::fs::metadata(file_path).ok()?; + if !metadata.is_file() { + return None; + } + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + Some(AttachmentMetadata { + id: rel.to_str()?.replace(std::path::MAIN_SEPARATOR, "/"), + name: file_path.file_name()?.to_str()?.to_string(), + path: file_path.to_string_lossy().into_owned(), + extension: ext, + kind, + modified, + size: metadata.len(), + }) +} + /// Convert a note ID to an absolute file path. Validates against path traversal. fn abs_path_from_id(notes_root: &Path, id: &str) -> Result { if id.contains('\\') { @@ -983,6 +1058,50 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str Ok(notes) } +#[tauri::command] +async fn list_attachments(state: State<'_, AppState>) -> Result, String> { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + + let path = PathBuf::from(&folder); + if !path.exists() { + return Ok(vec![]); + } + + let ignored_dirs = { + let settings = state.settings.read().expect("settings read lock"); + get_effective_ignored_dirs(&settings) + }; + + tokio::task::spawn_blocking(move || { + use walkdir::WalkDir; + let mut attachments = Vec::new(); + for entry in WalkDir::new(&path) + .max_depth(10) + .into_iter() + .filter_entry(|e| is_visible_notes_entry(e, &ignored_dirs)) + .flatten() + { + let file_path = entry.path(); + if !file_path.is_file() { + continue; + } + if let Some(attachment) = attachment_from_abs_path(&path, file_path, &ignored_dirs) { + attachments.push(attachment); + } + } + attachments.sort_by(|a, b| a.id.cmp(&b.id)); + attachments + }) + .await + .map_err(|e| format!("Failed to list attachments: {}", e)) +} + #[tauri::command] async fn read_note(id: String, state: State<'_, AppState>) -> Result { let folder = { @@ -1907,6 +2026,44 @@ async fn save_file_direct(path: String, content: String) -> Result) -> Result { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + let folder_root = PathBuf::from(&folder) + .canonicalize() + .map_err(|e| format!("Cannot resolve notes folder: {}", e))?; + let file_path = PathBuf::from(&path) + .canonicalize() + .map_err(|e| format!("Cannot resolve file path: {}", e))?; + + if !file_path.starts_with(&folder_root) { + return Err("File is outside the notes folder".to_string()); + } + + let extension = file_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + + if !matches!( + extension.as_str(), + "txt" | "text" | "log" | "csv" | "tsv" | "json" | "xml" | "yaml" | "yml" + ) { + return Err("Only text files can be read as text".to_string()); + } + + fs::read_to_string(&file_path) + .await + .map_err(|_| "Failed to read text file".to_string()) +} + #[tauri::command] async fn import_file_to_folder( app: AppHandle, @@ -2127,6 +2284,7 @@ struct FileChangeEvent { kind: String, path: String, changed_ids: Vec, + changed_paths: Vec, } fn setup_file_watcher( @@ -2150,10 +2308,35 @@ fn setup_file_watcher( DEFAULT_IGNORED_DIRS.iter().map(|s| s.to_string()).collect() }; - let note_id = match id_from_abs_path(¬es_root, path, &ignored_dirs) { - Some(id) => id, - None => continue, - }; + if let Ok(rel) = path.strip_prefix(¬es_root) { + let is_ignored = rel + .parent() + .unwrap_or(Path::new("")) + .components() + .any(|component| { + if let std::path::Component::Normal(name) = component { + if let Some(name_str) = name.to_str() { + return EXCLUDED_DIRS.contains(&name_str) + || ignored_dirs.iter().any(|d| d == name_str); + } + } + false + }); + if is_ignored { + continue; + } + } + + let note_id = id_from_abs_path(¬es_root, path, &ignored_dirs); + let is_attachment = note_id.is_none() + && path + .extension() + .and_then(|e| e.to_str()) + .and_then(attachment_kind_from_extension) + .is_some(); + if note_id.is_none() && !is_attachment { + continue; + } // Debounce with cleanup { @@ -2182,7 +2365,9 @@ fn setup_file_watcher( }; // Update search index for external file changes - if let Some(state) = app_handle.try_state::() { + if let (Some(state), Some(changed_note_id)) = + (app_handle.try_state::(), note_id.as_ref()) + { let index = state.search_index.lock().expect("search index mutex"); if let Some(ref search_index) = *index { match kind { @@ -2196,18 +2381,18 @@ fn setup_file_watcher( .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(0); - let _ = search_index.index_note(¬e_id, &title, &content, modified); + let _ = search_index.index_note(changed_note_id, &title, &content, modified); } Err(_) => { // File gone between event and read — treat as deletion if !path.exists() { - let _ = search_index.delete_note(¬e_id); + let _ = search_index.delete_note(changed_note_id); } } } } "deleted" => { - let _ = search_index.delete_note(¬e_id); + let _ = search_index.delete_note(changed_note_id); } _ => {} } @@ -2227,7 +2412,12 @@ fn setup_file_watcher( FileChangeEvent { kind: effective_kind.to_string(), path: path.to_string_lossy().into_owned(), - changed_ids: vec![note_id.clone()], + changed_ids: note_id.clone().into_iter().collect(), + changed_paths: if is_attachment { + vec![path.to_string_lossy().into_owned()] + } else { + Vec::new() + }, }, ); } @@ -3810,6 +4000,7 @@ pub fn run() { get_notes_folder, set_notes_folder, list_notes, + list_attachments, read_note, save_note, delete_note, @@ -3856,6 +4047,7 @@ pub fn run() { ai_execute_ollama, read_file_direct, save_file_direct, + read_text_attachment, import_file_to_folder, open_file_preview, install_cli, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b4e2ab1b..1bc0fcf7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ } ], "security": { - "csp": "default-src 'self' ipc: http://ipc.localhost https://ipc.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data:; style-src 'self' 'unsafe-inline'", + "csp": "default-src 'self' ipc: http://ipc.localhost https://ipc.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data:; frame-src 'self' asset: http://asset.localhost https://asset.localhost; object-src 'self' asset: http://asset.localhost https://asset.localhost; style-src 'self' 'unsafe-inline'", "assetProtocol": { "enable": true, "scope": { diff --git a/src/App.tsx b/src/App.tsx index 6bbf132b..4b9b3f4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { AiEditModal } from "./components/ai/AiEditModal"; import { AiResponseToast } from "./components/ai/AiResponseToast"; import { KeyboardShortcutsModal } from "./components/shortcuts/KeyboardShortcutsModal"; import { PreviewApp } from "./components/preview/PreviewApp"; +import { FilePreview } from "./components/preview/FilePreview"; import { check as checkForUpdate, type Update, @@ -54,6 +55,7 @@ function AppContent() { duplicateNote, notes, selectedNoteId, + selectedAttachment, selectNote, searchQuery, searchResults, @@ -476,14 +478,22 @@ function AppContent() { > - { - editorRef.current = editor; - }} - /> + {selectedAttachment ? ( + + ) : ( + { + editorRef.current = editor; + }} + /> + )} )} diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index c2647688..2adde941 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -398,6 +398,63 @@ export function ImageIcon({ className = "w-4.5 h-4.5" }: IconProps) { ); } +export function FileIcon({ className = "w-4.5 h-4.5" }: IconProps) { + return ( + + + + + ); +} + +export function FileTextIcon({ className = "w-4.5 h-4.5" }: IconProps) { + return ( + + + + + + + ); +} + +export function FilePdfIcon({ className = "w-4.5 h-4.5" }: IconProps) { + return ( + + + + + + + + + ); +} + export function InlineCodeIcon({ className = "w-4.5 h-4.5" }: IconProps) { return (
Notes
- {notes.length} + {notes.length + attachments.length}
diff --git a/src/components/notes/FileTypeIcon.tsx b/src/components/notes/FileTypeIcon.tsx new file mode 100644 index 00000000..7c423736 --- /dev/null +++ b/src/components/notes/FileTypeIcon.tsx @@ -0,0 +1,22 @@ +import type { AttachmentKind } from "../../types/note"; +import { + FileIcon, + FilePdfIcon, + FileTextIcon, + ImageIcon, +} from "../icons"; + +interface FileTypeIconProps { + kind: AttachmentKind; + className?: string; +} + +export function FileTypeIcon({ + kind, + className = "w-4 h-4 stroke-[1.6] opacity-50 shrink-0", +}: FileTypeIconProps) { + if (kind === "image") return ; + if (kind === "pdf") return ; + if (kind === "text") return ; + return ; +} diff --git a/src/components/notes/FolderTreeView.tsx b/src/components/notes/FolderTreeView.tsx index 3f7998a3..191d8469 100644 --- a/src/components/notes/FolderTreeView.tsx +++ b/src/components/notes/FolderTreeView.tsx @@ -33,9 +33,16 @@ import { PinIcon, CopyIcon, ArrowUpIcon, + ExternalLinkIcon, } from "../icons"; import * as notesService from "../../services/notes"; -import type { FolderNode, NoteMetadata, Settings } from "../../types/note"; +import { FileTypeIcon } from "./FileTypeIcon"; +import type { + AttachmentMetadata, + FolderNode, + NoteMetadata, + Settings, +} from "../../types/note"; const STORAGE_KEY = "scratch:collapsedFolders"; @@ -247,15 +254,91 @@ const FileItem = memo(function FileItem({ ); }); +interface AttachmentFileItemProps { + attachment: AttachmentMetadata; + depth: number; + isSelected: boolean; + onAttachmentClick: (attachment: AttachmentMetadata) => void; + focusedItemKey?: string | null; +} + +const AttachmentFileItem = memo(function AttachmentFileItem({ + attachment, + depth, + isSelected, + onAttachmentClick, + focusedItemKey, +}: AttachmentFileItemProps) { + const itemRef = useRef(null); + + useEffect(() => { + if (isSelected) { + itemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [isSelected]); + + const handleCopyFilepath = useCallback(async () => { + try { + await invoke("copy_to_clipboard", { text: attachment.path }); + } catch (error) { + console.error("Failed to copy filepath:", error); + } + }, [attachment.path]); + + const handleReveal = useCallback(async () => { + try { + await invoke("open_in_file_manager", { path: attachment.path }); + } catch (error) { + console.error("Failed to reveal file:", error); + } + }, [attachment.path]); + + return ( + + +
onAttachmentClick(attachment)} + role="button" + tabIndex={-1} + > + + {attachment.name} +
+
+ + + + + Copy Filepath + + + + Reveal in File Manager + + + +
+ ); +}); + interface FolderItemProps { folder: FolderNode; depth: number; collapsedFolders: Set; onToggleCollapse: (path: string) => void; selectedNoteId: string | null; + selectedAttachmentId: string | null; pinnedIds: Set; multiSelectedNoteIds: Set; onNoteClick: (id: string, event: React.MouseEvent) => void; + onAttachmentClick: (attachment: AttachmentMetadata) => void; focusedItemKey: string | null; onCreateNoteHere: (path: string) => void; onNewSubfolder: (parentPath: string) => void; @@ -275,9 +358,11 @@ const FolderItemComponent = memo(function FolderItem({ collapsedFolders, onToggleCollapse, selectedNoteId, + selectedAttachmentId, pinnedIds, multiSelectedNoteIds, onNoteClick, + onAttachmentClick, focusedItemKey, onCreateNoteHere, onNewSubfolder, @@ -357,10 +442,12 @@ const FolderItemComponent = memo(function FolderItem({ collapsedFolders={collapsedFolders} onToggleCollapse={onToggleCollapse} selectedNoteId={selectedNoteId} + selectedAttachmentId={selectedAttachmentId} focusedItemKey={focusedItemKey} pinnedIds={pinnedIds} multiSelectedNoteIds={multiSelectedNoteIds} onNoteClick={onNoteClick} + onAttachmentClick={onAttachmentClick} onCreateNoteHere={onCreateNoteHere} onNewSubfolder={onNewSubfolder} onRenameFolder={onRenameFolder} @@ -390,6 +477,16 @@ const FolderItemComponent = memo(function FolderItem({ focusedItemKey={focusedItemKey} /> ))} + {folder.attachments.map((attachment) => ( + + ))} {isEmpty && (
buildFolderTree(notes, pinnedIds, knownFolders), - [notes, pinnedIds, knownFolders], + () => buildFolderTree(notes, attachments, pinnedIds, knownFolders), + [notes, attachments, pinnedIds, knownFolders], ); const handleToggleCollapse = useCallback((path: string) => { @@ -737,6 +837,15 @@ export function FolderTreeView({ ], ); + const handleAttachmentClick = useCallback( + (attachment: AttachmentMetadata) => { + setMultiSelectedNoteIds(new Set()); + setLastClickedNoteId(null); + selectAttachment(attachment); + }, + [selectAttachment, setMultiSelectedNoteIds, setLastClickedNoteId], + ); + // Track which item is focused for keyboard nav (separate from note selection) const [focusedItemKey, setFocusedItemKey] = useState(null); @@ -744,11 +853,17 @@ export function FolderTreeView({ useEffect(() => { if (selectedNoteId) { setFocusedItemKey(`note:${selectedNoteId}`); + } else if (selectedAttachment) { + setFocusedItemKey(`attachment:${selectedAttachment.id}`); } - }, [selectedNoteId]); + }, [selectedNoteId, selectedAttachment]); const itemKey = (item: TreeItem) => - item.type === "note" ? `note:${item.id}` : `folder:${item.path}`; + item.type === "note" + ? `note:${item.id}` + : item.type === "attachment" + ? `attachment:${item.id}` + : `folder:${item.path}`; const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -794,13 +909,16 @@ export function FolderTreeView({ setFocusedItemKey(itemKey(item)); if (item.type === "note") { selectNote(item.id); + } else if (item.type === "attachment") { + const attachment = attachments.find((file) => file.id === item.id); + if (attachment) selectAttachment(attachment); } } else if (e.key === "Enter") { if (currentIndex < 0) return; const item = visibleItems[currentIndex]; if (item.type === "folder") { handleToggleCollapse(item.path); - } else { + } else if (item.type === "note") { // Focus the editor const editor = document.querySelector(".ProseMirror") as HTMLElement; if (editor) editor.focus(); @@ -811,6 +929,8 @@ export function FolderTreeView({ visibleItems, focusedItemKey, selectNote, + selectAttachment, + attachments, handleToggleCollapse, multiSelectedNoteIds, setMultiSelectedNoteIds, @@ -845,6 +965,7 @@ export function FolderTreeView({ () => tree.rootNotes.filter((n) => !pinnedIds.has(n.id)), [tree.rootNotes, pinnedIds], ); + const rootAttachments = tree.rootAttachments; return ( <> @@ -883,10 +1004,12 @@ export function FolderTreeView({ collapsedFolders={collapsedFolders} onToggleCollapse={handleToggleCollapse} selectedNoteId={selectedNoteId} + selectedAttachmentId={selectedAttachment?.id ?? null} focusedItemKey={focusedItemKey} pinnedIds={pinnedIds} multiSelectedNoteIds={multiSelectedNoteIds} onNoteClick={handleNoteClick} + onAttachmentClick={handleAttachmentClick} onCreateNoteHere={createNoteInFolder} onNewSubfolder={handleNewSubfolder} onRenameFolder={handleRenameFolder} @@ -917,6 +1040,17 @@ export function FolderTreeView({ focusedItemKey={focusedItemKey} /> ))} + + {rootAttachments.map((attachment) => ( + + ))}
{/* Delete folder confirmation dialog */} diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index 3e761f39..73941600 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -20,8 +20,10 @@ import { PinIcon, CopyIcon, TrashIcon, + ExternalLinkIcon, } from "../icons"; -import type { Settings } from "../../types/note"; +import type { AttachmentMetadata, Settings } from "../../types/note"; +import { FileTypeIcon } from "./FileTypeIcon"; const menuItemClass = "px-3 py-1.5 text-sm text-text cursor-pointer outline-none hover:bg-bg-muted focus:bg-bg-muted flex items-center gap-2 rounded-sm"; @@ -226,6 +228,80 @@ const NoteItemWithMenu = memo(function NoteItemWithMenu({ ); }); +interface AttachmentItemProps { + attachment: AttachmentMetadata; + isSelected: boolean; + onSelect: (attachment: AttachmentMetadata) => void; + showFolderPrefix?: boolean; +} + +const AttachmentItem = memo(function AttachmentItem({ + attachment, + isSelected, + onSelect, + showFolderPrefix = true, +}: AttachmentItemProps) { + const ref = useRef(null); + const folder = + showFolderPrefix && attachment.id.includes("/") + ? attachment.id.substring(0, attachment.id.lastIndexOf("/")) + : null; + const subtitle = folder + ? `${folder}/ · ${attachment.extension.toUpperCase()}` + : attachment.extension.toUpperCase(); + + useEffect(() => { + if (isSelected) { + ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [isSelected]); + + const handleCopyFilepath = useCallback(async () => { + try { + await invoke("copy_to_clipboard", { text: attachment.path }); + } catch (error) { + console.error("Failed to copy filepath:", error); + } + }, [attachment.path]); + + const handleReveal = useCallback(async () => { + try { + await invoke("open_in_file_manager", { path: attachment.path }); + } catch (error) { + console.error("Failed to reveal file:", error); + } + }, [attachment.path]); + + return ( + + +
+ } + isSelected={isSelected} + onClick={() => onSelect(attachment)} + /> +
+
+ + + + + Copy Filepath + + + + Reveal in File Manager + + + +
+ ); +}); + interface NoteListProps { multiSelectedNoteIds: Set; setMultiSelectedNoteIds: React.Dispatch>>; @@ -241,8 +317,11 @@ export function NoteList({ }: NoteListProps) { const { notes, + attachments, selectedNoteId, + selectedAttachment, selectNote, + selectAttachment, deleteNote, duplicateNote, pinNote, @@ -307,6 +386,15 @@ export function NoteList({ return notes; }, [searchQuery, searchResults, notes]); + const displayAttachments = useMemo(() => { + if (!searchQuery.trim()) return attachments; + const query = searchQuery.trim().toLowerCase(); + return attachments.filter((attachment) => + attachment.name.toLowerCase().includes(query) || + attachment.id.toLowerCase().includes(query), + ); + }, [attachments, searchQuery]); + // Listen for focus request from editor (when Escape is pressed) useEffect(() => { const handleFocusNoteList = () => { @@ -341,7 +429,7 @@ export function NoteList({ ); } - if (isSearching && displayItems.length === 0) { + if (isSearching && displayItems.length === 0 && displayAttachments.length === 0) { return (
No results found @@ -349,7 +437,7 @@ export function NoteList({ ); } - if (displayItems.length === 0) { + if (displayItems.length === 0 && displayAttachments.length === 0) { return (
No notes yet @@ -420,6 +508,14 @@ export function NoteList({ onRefreshSettings={refreshSettings} /> ))} + {displayAttachments.map((attachment) => ( + + ))}
{/* Delete confirmation dialog */} diff --git a/src/components/preview/FilePreview.tsx b/src/components/preview/FilePreview.tsx new file mode 100644 index 00000000..61fa883e --- /dev/null +++ b/src/components/preview/FilePreview.tsx @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import type { AttachmentMetadata } from "../../types/note"; +import * as filesService from "../../services/files"; +import { Button, IconButton } from "../ui"; +import { + CopyIcon, + ExternalLinkIcon, + ImageIcon, + NoteIcon, + PanelLeftIcon, + RefreshCwIcon, + SpinnerIcon, +} from "../icons"; + +interface FilePreviewProps { + attachment: AttachmentMetadata; + onToggleSidebar?: () => void; + sidebarVisible?: boolean; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatDateTime(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function FilePreview({ + attachment, + onToggleSidebar, + sidebarVisible, +}: FilePreviewProps) { + const [textContent, setTextContent] = useState(null); + const [isLoadingText, setIsLoadingText] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + const assetUrl = useMemo(() => convertFileSrc(attachment.path), [attachment.path]); + + const loadText = useCallback(async () => { + if (attachment.kind !== "text") return; + setIsLoadingText(true); + try { + setTextContent(await filesService.readTextFile(attachment.path)); + } catch (error) { + console.error("Failed to read text file:", error); + toast.error(`Failed to read file: ${error}`); + setTextContent(""); + } finally { + setIsLoadingText(false); + } + }, [attachment.kind, attachment.path]); + + useEffect(() => { + setTextContent(null); + loadText(); + }, [loadText, attachment.modified]); + + const handleReload = useCallback(() => { + setReloadKey((key) => key + 1); + loadText(); + }, [loadText]); + + const handleCopyPath = useCallback(async () => { + try { + await invoke("copy_to_clipboard", { text: attachment.path }); + toast.success("Copied filepath"); + } catch { + toast.error("Failed to copy filepath"); + } + }, [attachment.path]); + + const handleReveal = useCallback(async () => { + try { + await invoke("open_in_file_manager", { path: attachment.path }); + } catch { + toast.error("Failed to reveal file"); + } + }, [attachment.path]); + + return ( +
+
+
+ {onToggleSidebar && ( + + + + )} +
+
+ {formatBytes(attachment.size)} · {formatDateTime(attachment.modified)} +
+
+
+
+ + + + + + + + + +
+
+ +
+ {attachment.kind === "image" && ( +
+ {attachment.name} +
+ )} + + {attachment.kind === "pdf" && ( + +
+ +

PDF preview is not available in this WebView.

+ +
+
+ )} + + {attachment.kind === "text" && ( +
+ {isLoadingText ? ( +
+ +
+ ) : ( +
+                {textContent}
+              
+ )} +
+ )} + + {attachment.kind === "file" && ( +
+ +

No preview is available for this file type.

+ +
+ )} +
+
+ ); +} diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index 8de365e7..1f6c1285 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -133,6 +133,7 @@ interface ListItemProps { title: string; subtitle?: string; meta?: string; + icon?: ReactNode; isSelected?: boolean; isPinned?: boolean; onClick?: () => void; @@ -143,6 +144,7 @@ export function ListItem({ title, subtitle, meta, + icon, isSelected = false, isPinned = false, onClick, @@ -174,6 +176,7 @@ export function ListItem({ {isPinned && ( )} + {icon} {title} diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 7ad2ea11..1ca5c62b 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -9,7 +9,7 @@ import { type ReactNode, } from "react"; import { listen } from "@tauri-apps/api/event"; -import type { Note, NoteMetadata } from "../types/note"; +import type { AttachmentMetadata, Note, NoteMetadata } from "../types/note"; import * as notesService from "../services/notes"; import type { SearchResult } from "../services/notes"; @@ -17,7 +17,9 @@ import type { SearchResult } from "../services/notes"; // Data context: changes frequently, only subscribed by components that need the data interface NotesDataContextValue { notes: NoteMetadata[]; + attachments: AttachmentMetadata[]; selectedNoteId: string | null; + selectedAttachment: AttachmentMetadata | null; currentNote: Note | null; notesFolder: string | null; isLoading: boolean; @@ -32,6 +34,7 @@ interface NotesDataContextValue { // Actions context: stable references, rarely causes re-renders interface NotesActionsContextValue { selectNote: (id: string) => Promise; + selectAttachment: (attachment: AttachmentMetadata) => void; createNote: () => Promise; consumePendingNewNote: (id: string) => boolean; saveNote: (content: string, noteId?: string) => Promise; @@ -58,7 +61,10 @@ const NotesActionsContext = createContext(null) export function NotesProvider({ children }: { children: ReactNode }) { const [notes, setNotes] = useState([]); + const [attachments, setAttachments] = useState([]); const [selectedNoteId, setSelectedNoteId] = useState(null); + const [selectedAttachment, setSelectedAttachment] = + useState(null); const [currentNote, setCurrentNote] = useState(null); const [notesFolder, setNotesFolderState] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -77,6 +83,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { // Ref to access selectedNoteId in file watcher without re-registering listener const selectedNoteIdRef = useRef(null); selectedNoteIdRef.current = selectedNoteId; + const selectedAttachmentRef = useRef(null); + selectedAttachmentRef.current = selectedAttachment; // Ref to access notes in search callback without re-creating it on every notes change const notesRef = useRef([]); notesRef.current = notes; @@ -91,7 +99,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { if (!notesFolder) return; try { const notesList = await notesService.listNotes(); + const attachmentsList = await notesService.listAttachments(); setNotes(notesList); + setAttachments(attachmentsList); + setSelectedAttachment((prev) => { + if (!prev) return prev; + return attachmentsList.find((attachment) => attachment.id === prev.id) ?? null; + }); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load notes"); } @@ -116,6 +130,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { } // Set selected ID immediately for responsive UI setSelectedNoteId(id); + setSelectedAttachment(null); setHasExternalChanges(false); // Expand parent folders so the note is visible in the tree const lastSlash = id.lastIndexOf("/"); @@ -135,6 +150,24 @@ export function NotesProvider({ children }: { children: ReactNode }) { } }, []); + const selectAttachment = useCallback((attachment: AttachmentMetadata) => { + selectRequestIdRef.current += 1; + pendingNewNoteIdRef.current = null; + setSelectedAttachment(attachment); + setSelectedNoteId(null); + setCurrentNote(null); + setHasExternalChanges(false); + + const lastSlash = attachment.id.lastIndexOf("/"); + if (lastSlash > 0) { + window.dispatchEvent( + new CustomEvent("expand-folder", { + detail: attachment.id.substring(0, lastSlash), + }), + ); + } + }, []); + const reloadCurrentNote = useCallback(async () => { if (!selectedNoteIdRef.current) return; try { @@ -165,6 +198,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(note); setSelectedNoteId(note.id); + setSelectedAttachment(null); // Clear search when creating a new note setSearchQuery(""); setSearchResults([]); @@ -353,6 +387,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refreshNotes(); setCurrentNote(note); setSelectedNoteId(note.id); + setSelectedAttachment(null); setSearchQuery(""); setSearchResults([]); setTimeout(() => { @@ -394,6 +429,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { } return prevId; }); + setSelectedAttachment((prev) => + prev && prev.id.startsWith(path + "/") ? null : prev, + ); await refreshNotes(); } catch (err) { setError( @@ -507,6 +545,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { try { await notesService.setNotesFolder(path); setNotesFolderState(path); + setSelectedAttachment(null); // Start file watcher after setting folder await notesService.startFileWatcher(); } catch (err) { @@ -522,9 +561,12 @@ export function NotesProvider({ children }: { children: ReactNode }) { try { setNotesFolderState(path); setSelectedNoteId(null); + setSelectedAttachment(null); setCurrentNote(null); const notesList = await notesService.listNotes(); + const attachmentsList = await notesService.listAttachments(); setNotes(notesList); + setAttachments(attachmentsList); await notesService.startFileWatcher(); } catch (err) { setError( @@ -605,7 +647,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { setNotesFolderState(folder); if (folder) { const notesList = await notesService.listNotes(); + const attachmentsList = await notesService.listAttachments(); setNotes(notesList); + setAttachments(attachmentsList); // Start file watcher await notesService.startFileWatcher(); } @@ -628,6 +672,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { if (isCancelled) return; const changedIds = event.payload.changed_ids || []; + const changedPaths = (event.payload as { changed_paths?: string[] }).changed_paths || []; // Filter out notes we recently saved ourselves const externalChanges = changedIds.filter( @@ -635,7 +680,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { ); // Only refresh if there are external changes - if (externalChanges.length > 0) { + if (externalChanges.length > 0 || changedPaths.length > 0) { refreshNotes(); // If the currently selected note was changed externally, set flag (don't auto-reload) @@ -643,6 +688,10 @@ export function NotesProvider({ children }: { children: ReactNode }) { if (currentId && externalChanges.includes(currentId)) { setHasExternalChanges(true); } + const currentAttachment = selectedAttachmentRef.current; + if (currentAttachment && changedPaths.includes(currentAttachment.path)) { + setHasExternalChanges(true); + } } }).then((fn) => { if (isCancelled) { @@ -684,7 +733,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { const dataValue = useMemo( () => ({ notes, + attachments, selectedNoteId, + selectedAttachment, currentNote, notesFolder, isLoading, @@ -697,7 +748,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { }), [ notes, + attachments, selectedNoteId, + selectedAttachment, currentNote, notesFolder, isLoading, @@ -714,6 +767,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { const actionsValue = useMemo( () => ({ selectNote, + selectAttachment, createNote, consumePendingNewNote, saveNote, @@ -736,6 +790,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { }), [ selectNote, + selectAttachment, createNote, consumePendingNewNote, saveNote, diff --git a/src/lib/folderTree.ts b/src/lib/folderTree.ts index a4a2d48d..6a8f92da 100644 --- a/src/lib/folderTree.ts +++ b/src/lib/folderTree.ts @@ -1,16 +1,19 @@ -import type { NoteMetadata, FolderNode } from "../types/note"; +import type { AttachmentMetadata, NoteMetadata, FolderNode } from "../types/note"; export interface FolderTreeData { rootNotes: NoteMetadata[]; + rootAttachments: AttachmentMetadata[]; folders: FolderNode[]; } export function buildFolderTree( notes: NoteMetadata[], + attachments: AttachmentMetadata[], pinnedIds: Set, knownFolders?: string[], ): FolderTreeData { const rootNotes: NoteMetadata[] = []; + const rootAttachments: AttachmentMetadata[] = []; const folderMap = new Map(); function ensureFolder(path: string): FolderNode { @@ -19,7 +22,7 @@ export function buildFolderTree( const parts = path.split("/"); const name = parts[parts.length - 1]; - const node: FolderNode = { name, path, children: [], notes: [] }; + const node: FolderNode = { name, path, children: [], notes: [], attachments: [] }; folderMap.set(path, node); if (parts.length > 1) { @@ -51,6 +54,17 @@ export function buildFolderTree( } } + for (const attachment of attachments) { + const lastSlash = attachment.id.lastIndexOf("/"); + if (lastSlash === -1) { + rootAttachments.push(attachment); + } else { + const folderPath = attachment.id.substring(0, lastSlash); + const folder = ensureFolder(folderPath); + folder.attachments.push(attachment); + } + } + function sortNode(node: FolderNode) { node.children.sort((a, b) => a.name.localeCompare(b.name)); node.notes.sort((a, b) => { @@ -59,6 +73,7 @@ export function buildFolderTree( if (ap !== bp) return ap ? -1 : 1; return b.modified - a.modified; }); + node.attachments.sort((a, b) => a.name.localeCompare(b.name)); node.children.forEach(sortNode); } @@ -75,12 +90,14 @@ export function buildFolderTree( if (ap !== bp) return ap ? -1 : 1; return b.modified - a.modified; }); + rootAttachments.sort((a, b) => a.name.localeCompare(b.name)); - return { rootNotes, folders: topLevelFolders }; + return { rootNotes, rootAttachments, folders: topLevelFolders }; } export type TreeItem = | { type: "note"; id: string } + | { type: "attachment"; id: string } | { type: "folder"; path: string }; /** Build a flat list of visible tree items in DFS order (for keyboard navigation). */ @@ -108,6 +125,9 @@ export function getVisibleItems( for (const note of folder.notes) { items.push({ type: "note", id: note.id }); } + for (const attachment of folder.attachments) { + items.push({ type: "attachment", id: attachment.id }); + } } } for (const folder of tree.folders) { @@ -120,12 +140,15 @@ export function getVisibleItems( items.push({ type: "note", id: note.id }); } } + for (const attachment of tree.rootAttachments) { + items.push({ type: "attachment", id: attachment.id }); + } return items; } export function countNotesInFolder(folder: FolderNode): number { - let count = folder.notes.length; + let count = folder.notes.length + folder.attachments.length; for (const child of folder.children) { count += countNotesInFolder(child); } diff --git a/src/services/files.ts b/src/services/files.ts index 6e536ef6..c3242949 100644 --- a/src/services/files.ts +++ b/src/services/files.ts @@ -18,6 +18,10 @@ export async function saveFileDirect( return invoke("save_file_direct", { path, content }); } +export async function readTextFile(path: string): Promise { + return invoke("read_text_attachment", { path }); +} + export async function openFilePreview(path: string): Promise { return invoke("open_file_preview", { path }); } diff --git a/src/services/notes.ts b/src/services/notes.ts index c4632627..2d577c9a 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Note, NoteMetadata, Settings } from "../types/note"; +import type { AttachmentMetadata, Note, NoteMetadata, Settings } from "../types/note"; export async function getNotesFolder(): Promise { return invoke("get_notes_folder"); @@ -13,6 +13,10 @@ export async function listNotes(): Promise { return invoke("list_notes"); } +export async function listAttachments(): Promise { + return invoke("list_attachments"); +} + export async function readNote(id: string): Promise { return invoke("read_note", { id }); } diff --git a/src/types/note.ts b/src/types/note.ts index 37addc1b..4e111789 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -5,6 +5,18 @@ export interface NoteMetadata { modified: number; } +export type AttachmentKind = "image" | "pdf" | "text" | "file"; + +export interface AttachmentMetadata { + id: string; + name: string; + path: string; + extension: string; + kind: AttachmentKind; + modified: number; + size: number; +} + export interface Note { id: string; title: string; @@ -66,4 +78,5 @@ export interface FolderNode { path: string; children: FolderNode[]; notes: NoteMetadata[]; + attachments: AttachmentMetadata[]; } From 38734a67dd29b3e76a75402757fd9d48adf2b946 Mon Sep 17 00:00:00 2001 From: Barbara Leth Date: Tue, 5 May 2026 18:25:26 +0200 Subject: [PATCH 2/4] fix: harden attachment preview with error handling and size limits Add image onError fallback UI, 10MB size limit for text file preview, and wire up AttachmentKind::File for unknown extensions. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/lib.rs | 9 +++++- src/components/preview/FilePreview.tsx | 42 ++++++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2201bcd..1e41ff7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -699,7 +699,7 @@ fn attachment_kind_from_extension(ext: &str) -> Option { "txt" | "text" | "log" | "csv" | "tsv" | "json" | "xml" | "yaml" | "yml" => { Some(AttachmentKind::Text) } - _ => None, + _ => Some(AttachmentKind::File), } } @@ -2059,6 +2059,13 @@ async fn read_text_attachment(path: String, state: State<'_, AppState>) -> Resul return Err("Only text files can be read as text".to_string()); } + let meta = fs::metadata(&file_path) + .await + .map_err(|_| "Failed to read file metadata".to_string())?; + if meta.len() > 10 * 1024 * 1024 { + return Err("File too large to preview (max 10 MB)".to_string()); + } + fs::read_to_string(&file_path) .await .map_err(|_| "Failed to read text file".to_string()) diff --git a/src/components/preview/FilePreview.tsx b/src/components/preview/FilePreview.tsx index 61fa883e..95dd7dd1 100644 --- a/src/components/preview/FilePreview.tsx +++ b/src/components/preview/FilePreview.tsx @@ -45,7 +45,11 @@ export function FilePreview({ const [textContent, setTextContent] = useState(null); const [isLoadingText, setIsLoadingText] = useState(false); const [reloadKey, setReloadKey] = useState(0); - const assetUrl = useMemo(() => convertFileSrc(attachment.path), [attachment.path]); + const [imageError, setImageError] = useState(false); + const assetUrl = useMemo( + () => convertFileSrc(attachment.path), + [attachment.path], + ); const loadText = useCallback(async () => { if (attachment.kind !== "text") return; @@ -63,11 +67,13 @@ export function FilePreview({ useEffect(() => { setTextContent(null); + setImageError(false); loadText(); }, [loadText, attachment.modified]); const handleReload = useCallback(() => { setReloadKey((key) => key + 1); + setImageError(false); loadText(); }, [loadText]); @@ -105,7 +111,8 @@ export function FilePreview({ )}
- {formatBytes(attachment.size)} · {formatDateTime(attachment.modified)} + {formatBytes(attachment.size)} ·{" "} + {formatDateTime(attachment.modified)}
@@ -125,12 +132,23 @@ export function FilePreview({
{attachment.kind === "image" && (
- {attachment.name} + {imageError ? ( +
+ +

Failed to load image.

+ +
+ ) : ( + {attachment.name} setImageError(true)} + /> + )}
)} @@ -143,7 +161,9 @@ export function FilePreview({ >
-

PDF preview is not available in this WebView.

+

+ PDF preview is not available in this WebView. +

@@ -168,7 +188,9 @@ export function FilePreview({ {attachment.kind === "file" && (
-

No preview is available for this file type.

+

+ No preview is available for this file type. +

From 2c467f9a5dd85df6cca6849022c652034f743936 Mon Sep 17 00:00:00 2001 From: Barbara Leth Date: Tue, 5 May 2026 19:45:30 +0200 Subject: [PATCH 3/4] fix: use FileIcon instead of ImageIcon for unknown file type fallback Co-Authored-By: Claude Opus 4.6 --- src/components/preview/FilePreview.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/preview/FilePreview.tsx b/src/components/preview/FilePreview.tsx index 95dd7dd1..7ac929e5 100644 --- a/src/components/preview/FilePreview.tsx +++ b/src/components/preview/FilePreview.tsx @@ -7,6 +7,7 @@ import { Button, IconButton } from "../ui"; import { CopyIcon, ExternalLinkIcon, + FileIcon, ImageIcon, NoteIcon, PanelLeftIcon, @@ -187,7 +188,7 @@ export function FilePreview({ {attachment.kind === "file" && (
- +

No preview is available for this file type.

From 7f2b5e1947c05fd63daac9938b79d2b88cbf03d2 Mon Sep 17 00:00:00 2001 From: Barbara Leth Date: Tue, 5 May 2026 20:08:00 +0200 Subject: [PATCH 4/4] fix: harden FilePreview text loading with stale-result guard and safe error toast Prevent out-of-order async loads from overwriting the active preview by tagging each load with a generation counter. Also replace the raw error interpolation in the toast with a generic user-safe message. Co-Authored-By: Claude Opus 4.6 --- src/components/preview/FilePreview.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/preview/FilePreview.tsx b/src/components/preview/FilePreview.tsx index 7ac929e5..9bc6ec85 100644 --- a/src/components/preview/FilePreview.tsx +++ b/src/components/preview/FilePreview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import type { AttachmentMetadata } from "../../types/note"; @@ -47,6 +47,7 @@ export function FilePreview({ const [isLoadingText, setIsLoadingText] = useState(false); const [reloadKey, setReloadKey] = useState(0); const [imageError, setImageError] = useState(false); + const loadIdRef = useRef(0); const assetUrl = useMemo( () => convertFileSrc(attachment.path), [attachment.path], @@ -54,15 +55,19 @@ export function FilePreview({ const loadText = useCallback(async () => { if (attachment.kind !== "text") return; + const id = ++loadIdRef.current; setIsLoadingText(true); try { - setTextContent(await filesService.readTextFile(attachment.path)); + const content = await filesService.readTextFile(attachment.path); + if (id !== loadIdRef.current) return; + setTextContent(content); } catch (error) { + if (id !== loadIdRef.current) return; console.error("Failed to read text file:", error); - toast.error(`Failed to read file: ${error}`); + toast.error("Failed to read file. Please try again."); setTextContent(""); } finally { - setIsLoadingText(false); + if (id === loadIdRef.current) setIsLoadingText(false); } }, [attachment.kind, attachment.path]);