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; }} />