diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index c5b71d5..5306c44 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -6,9 +6,9 @@ // vs manifest) and resolve each file as upload/download/delete, with keep-both // conflict copies so nothing is ever lost. // -// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`. -// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the -// manifest itself) is local-only and never synced. +// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and +// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata, +// and the manifest itself remain local-only. use crate::state::AppState; use crate::vault::operations::helixnotes_dir; @@ -119,12 +119,12 @@ struct LocalFile { path: PathBuf, } -/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under -/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a -/// folder with unrelated files never drags them into the vault. +/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under +/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local +/// and remote so pointing at a folder with unrelated files never imports them. fn is_synced_relpath(rel: &str) -> bool { if rel.starts_with(".helixnotes/") { - rel.starts_with(".helixnotes/attachments/") + rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json" } else { rel.ends_with(".md") } @@ -724,3 +724,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu ); Ok(summary) } + +#[cfg(test)] +mod tests { + use super::is_synced_relpath; + + #[test] + fn syncs_notebook_icon_mapping_and_assets_only() { + for path in [ + "Notes/plan.md", + ".helixnotes/attachments/notebook-icon.png", + ".helixnotes/notebook_icons.json", + ] { + assert!(is_synced_relpath(path), "expected {path} to be synced"); + } + + for path in [ + "Notes/image.png", + ".helixnotes/sync_state.json", + ".helixnotes/notebook_icons.json.bak", + ".helixnotes/attachments-old/icon.png", + ] { + assert!(!is_synced_relpath(path), "expected {path} to stay local"); + } + } +} diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index 295974a..66604d7 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -1237,3 +1237,30 @@ pub fn sanitize_filename(name: &str) -> String { .trim() .to_string() } + +#[cfg(test)] +mod tests { + use super::{helixnotes_dir, load_notebook_icons, set_notebook_icon}; + use std::fs; + use uuid::Uuid; + + #[test] + fn persists_and_removes_builtin_notebook_icons() { + let vault = + std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4())); + let vault_path = vault.to_string_lossy(); + fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap(); + + set_notebook_icon(&vault_path, "Projects", Some("builtin:briefcase")).unwrap(); + let icons = load_notebook_icons(&vault_path).unwrap(); + assert_eq!( + icons.get("Projects").map(String::as_str), + Some("builtin:briefcase") + ); + + set_notebook_icon(&vault_path, "Projects", None).unwrap(); + assert!(load_notebook_icons(&vault_path).unwrap().is_empty()); + + fs::remove_dir_all(vault).unwrap(); + } +} \ No newline at end of file diff --git a/src/lib/components/NotebookGlyph.svelte b/src/lib/components/NotebookGlyph.svelte new file mode 100644 index 0000000..d0d24fb --- /dev/null +++ b/src/lib/components/NotebookGlyph.svelte @@ -0,0 +1,61 @@ + + + diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 1bff3e4..ad59a66 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -28,6 +28,13 @@ import type { NotebookEntry } from '$lib/types'; import { isMobile } from '$lib/platform'; import { decodeNoteDragPaths } from '$lib/utils/note-drag'; + import NotebookGlyph from './NotebookGlyph.svelte'; + import { + NOTEBOOK_ICON_OPTIONS, + decodeBuiltinNotebookIcon, + encodeBuiltinNotebookIcon, + type NotebookIconId + } from '$lib/utils/notebook-icons'; let { onViewChanged = () => {} }: { onViewChanged?: () => void; @@ -79,6 +86,8 @@ } let sortedNotebooks = $derived(sortNotebooksTree($notebooks)); let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null); + let iconPickerNotebook = $state(null); + let iconPickerElement = $state(null); let trashContextMenu = $state<{ x: number; y: number } | null>(null); let tagsCollapsed = $state(true); let deleteConfirm = $state(null); @@ -569,21 +578,38 @@ renameInput?.select(); } - async function handleSetIcon(nb: NotebookEntry) { + async function openIconPicker(nb: NotebookEntry) { contextMenu = null; + iconPickerNotebook = nb; + await tick(); + iconPickerElement?.focus(); + } + + async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) { + try { + const value = encodeBuiltinNotebookIcon(icon); + await setNotebookIcon(nb.relative_path, value); + $notebookIcons = { ...$notebookIcons, [nb.relative_path]: value }; + iconPickerNotebook = null; + } catch (e) { + console.error('Failed to set notebook icon:', e); + } + } + + async function handleCustomIcon(nb: NotebookEntry) { + iconPickerNotebook = null; try { const selected = await openDialog({ multiple: false, filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }] }); if (!selected) return; - const filePath = typeof selected === 'string' ? selected : selected; - // Read the file and save as attachment - const data = await readFile(filePath as string); - const fileName = (filePath as string).split('/').pop() || 'icon.png'; + const filePath = selected as string; + const data = await readFile(filePath); + const fileName = baseOf(filePath) || 'icon.png'; const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data)); await setNotebookIcon(nb.relative_path, iconRelative); - $notebookIcons = await getNotebookIcons(); + $notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative }; } catch (e) { console.error('Failed to set notebook icon:', e); } @@ -591,9 +617,12 @@ async function handleRemoveIcon(nb: NotebookEntry) { contextMenu = null; + iconPickerNotebook = null; try { await setNotebookIcon(nb.relative_path, null); - $notebookIcons = await getNotebookIcons(); + const icons = { ...$notebookIcons }; + delete icons[nb.relative_path]; + $notebookIcons = icons; } catch (e) { console.error('Failed to remove notebook icon:', e); } @@ -601,7 +630,7 @@ function getNotebookIconSrc(nb: NotebookEntry): string | null { const iconPath = $notebookIcons[nb.relative_path]; - if (!iconPath) return null; + if (!iconPath || iconPath.startsWith('builtin:')) return null; const vaultRoot = $appConfig?.active_vault; if (!vaultRoot) return null; return convertFileSrc(`${vaultRoot}/${iconPath}`); @@ -661,7 +690,7 @@ } - + { if (e.key === 'Escape') iconPickerNotebook = null; }} />