diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9d585ef..a49be32 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,6 +1,7 @@ use crate::cli::InitialTarget; use crate::error::{AppError, AppResult}; use crate::folder::TreeNode; +use crate::highlights::{Highlight, HighlightsFile}; use crate::registry::SharedRegistry; use crate::search::{search, SearchResult}; use crate::settings::{data_dir, Folder}; @@ -172,3 +173,26 @@ pub fn load_preferences(registry: State<'_, SharedRegistry>) -> PreferencesPaylo sidebar_group_by_repo: s.sidebar_group_by_repo, } } + +/// Load all highlights for all files. Frontend filters by file path. +#[tauri::command] +pub fn load_highlights() -> AppResult { + HighlightsFile::load(&data_dir()) +} + +/// Replace the highlights for a single file path. Other files are untouched. +/// Pass an empty Vec to clear highlights for the path. +#[tauri::command] +pub fn save_highlights_for_file( + file_path: String, + highlights: Vec, +) -> AppResult<()> { + let dir = data_dir(); + let mut store = HighlightsFile::load(&dir).unwrap_or_default(); + if highlights.is_empty() { + store.files.remove(&file_path); + } else { + store.files.insert(file_path, highlights); + } + store.save(&dir) +} diff --git a/src-tauri/src/highlights.rs b/src-tauri/src/highlights.rs new file mode 100644 index 0000000..6bc5d36 --- /dev/null +++ b/src-tauri/src/highlights.rs @@ -0,0 +1,172 @@ +//! Highlights persistence. +//! +//! Highlights are keyed by the absolute file path of the markdown document +//! they belong to. We store all highlights in a single JSON file at +//! `app_data_dir/highlights.json` so they survive across sessions without +//! requiring any filesystem permissions outside Marky's own data directory. + +use crate::error::AppResult; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +const FILE_NAME: &str = "highlights.json"; +const CURRENT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Highlight { + pub id: String, + #[serde(rename = "filePath")] + pub file_path: String, + pub colour: String, + #[serde(rename = "sourceStartLine")] + pub source_start_line: u32, + #[serde(rename = "sourceEndLine")] + pub source_end_line: u32, + pub passage: String, + pub occurrence: u32, + pub section: String, + #[serde(rename = "createdAt")] + pub created_at: String, + /// Optional per-item annotation. Older `highlights.json` files written + /// before annotations existed deserialize cleanly via `default`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HighlightsFile { + pub version: u32, + pub files: HashMap>, +} + +impl Default for HighlightsFile { + fn default() -> Self { + Self { + version: CURRENT_VERSION, + files: HashMap::new(), + } + } +} + +impl HighlightsFile { + pub fn load(dir: &Path) -> AppResult { + let p = dir.join(FILE_NAME); + if !p.exists() { + return Ok(Self::default()); + } + let raw = std::fs::read_to_string(&p)?; + Ok(serde_json::from_str(&raw).unwrap_or_default()) + } + + pub fn save(&self, dir: &Path) -> AppResult<()> { + std::fs::create_dir_all(dir)?; + let p = dir.join(FILE_NAME); + let tmp = dir.join(format!("{FILE_NAME}.tmp")); + let raw = serde_json::to_string_pretty(self)?; + std::fs::write(&tmp, raw)?; + std::fs::rename(&tmp, &p)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn h(id: &str, path: &str, colour: &str) -> Highlight { + Highlight { + id: id.into(), + file_path: path.into(), + colour: colour.into(), + source_start_line: 0, + source_end_line: 1, + passage: "p".into(), + occurrence: 0, + section: "".into(), + created_at: "2026-04-29T00:00:00.000Z".into(), + note: None, + } + } + + #[test] + fn round_trip_default() { + let dir = tempdir().unwrap(); + let f = HighlightsFile::default(); + f.save(dir.path()).unwrap(); + let loaded = HighlightsFile::load(dir.path()).unwrap(); + assert_eq!(loaded.version, CURRENT_VERSION); + assert!(loaded.files.is_empty()); + } + + #[test] + fn round_trip_with_data() { + let dir = tempdir().unwrap(); + let mut f = HighlightsFile::default(); + f.files + .insert("/a.md".into(), vec![h("1", "/a.md", "yellow")]); + f.save(dir.path()).unwrap(); + + let loaded = HighlightsFile::load(dir.path()).unwrap(); + assert_eq!(loaded.files.len(), 1); + let items = loaded.files.get("/a.md").unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "1"); + assert_eq!(items[0].colour, "yellow"); + } + + #[test] + fn load_missing_file_is_default() { + let dir = tempdir().unwrap(); + let loaded = HighlightsFile::load(dir.path()).unwrap(); + assert!(loaded.files.is_empty()); + } + + #[test] + fn load_corrupt_file_falls_back_to_default() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join(FILE_NAME), "{not json").unwrap(); + let loaded = HighlightsFile::load(dir.path()).unwrap(); + assert!(loaded.files.is_empty()); + } + + #[test] + fn camelcase_field_names_in_json() { + let dir = tempdir().unwrap(); + let mut f = HighlightsFile::default(); + f.files + .insert("/a.md".into(), vec![h("1", "/a.md", "yellow")]); + f.save(dir.path()).unwrap(); + let raw = std::fs::read_to_string(dir.path().join(FILE_NAME)).unwrap(); + // Frontend expects camelCase keys. + assert!(raw.contains("\"filePath\"")); + assert!(raw.contains("\"sourceStartLine\"")); + assert!(raw.contains("\"sourceEndLine\"")); + assert!(raw.contains("\"createdAt\"")); + } + + #[test] + fn loads_legacy_file_without_note_field() { + // A highlights.json written before `note` existed must still load. + let dir = tempdir().unwrap(); + let raw = r#"{"version":1,"files":{"/a.md":[{"id":"1","filePath":"/a.md","colour":"yellow","sourceStartLine":0,"sourceEndLine":1,"passage":"p","occurrence":0,"section":"","createdAt":"2026-04-29T00:00:00.000Z"}]}}"#; + std::fs::write(dir.path().join(FILE_NAME), raw).unwrap(); + let loaded = HighlightsFile::load(dir.path()).unwrap(); + let items = loaded.files.get("/a.md").unwrap(); + assert_eq!(items.len(), 1); + assert!(items[0].note.is_none()); + } + + #[test] + fn omits_note_field_in_json_when_absent() { + let dir = tempdir().unwrap(); + let mut f = HighlightsFile::default(); + f.files + .insert("/a.md".into(), vec![h("1", "/a.md", "yellow")]); + f.save(dir.path()).unwrap(); + let raw = std::fs::read_to_string(dir.path().join(FILE_NAME)).unwrap(); + // skip_serializing_if keeps the JSON small for un-annotated highlights. + assert!(!raw.contains("\"note\"")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 244ce1b..0357d98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod commands; mod error; mod folder; mod fs; +mod highlights; mod registry; mod search; mod settings; @@ -126,6 +127,8 @@ pub fn run() { commands::save_theme, commands::save_preferences, commands::load_preferences, + commands::load_highlights, + commands::save_highlights_for_file, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 6f4160c..0191d47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { ThemeProvider } from "@/lib/theme"; import { PreferencesProvider, usePreferences } from "@/lib/preferences"; +import { HighlightsProvider, useHighlights } from "@/lib/highlightsStore"; +import { NotificationsProvider, useNotify } from "@/lib/notifications"; import { ResizeHandle } from "@/components/ResizeHandle"; import { TooltipProvider } from "@/components/ui/tooltip"; import { FolderSidebar } from "@/components/FolderSidebar"; @@ -10,6 +12,7 @@ import { Pane } from "@/components/Pane"; import { TableOfContents } from "@/components/TableOfContents"; import { Toolbar } from "@/components/Toolbar"; import { CommandPalette } from "@/components/CommandPalette"; +import { HighlightsPanel } from "@/components/HighlightsPanel"; import { tauri, onCliTarget, onFolderChanged, onFileChanged, type AnnotatedFolder } from "@/lib/tauri"; import { createInitialState, @@ -50,6 +53,17 @@ function AppShell() { sidebarLeftWidth, sidebarRightWidth, setSidebarWidth, resetSidebarWidth, } = usePreferences(); + const { + panelOpen: highlightsPanelOpen, + setPanelOpen: setHighlightsPanelOpen, + activeColour, + setActiveColour, + byFile: highlightsByFile, + addHighlight, + removeHighlight, + updateHighlight, + } = useHighlights(); + const { notify } = useNotify(); const activeTab = getActiveTab(state); const activePane = getActivePane(state); @@ -246,6 +260,9 @@ function AppShell() { onCloseSplit={handleCloseSplit} isSplit={isSplit} onFind={() => setSearchPaneId(state.activePaneId)} + onToggleHighlights={() => setHighlightsPanelOpen(!highlightsPanelOpen)} + highlightsOpen={highlightsPanelOpen} + activeHighlightColour={activeColour} />
@@ -270,7 +287,7 @@ function AppShell() { ))}
- {!isSplit && ( + {!isSplit && !highlightsPanelOpen && ( <> )} + {highlightsPanelOpen && (() => { + const panelKey = activeTab ? activeTab.filePath ?? ":welcome" : undefined; + return ( + setHighlightsPanelOpen(false)} + onJump={(id) => { + document + .querySelector("article.markdown-body") + ?.dispatchEvent(new CustomEvent("marky:scroll-to-highlight", { detail: id })); + }} + onRemove={(id) => { + if (!panelKey) return; + const items = highlightsByFile[panelKey] ?? []; + const removed = items.find((h) => h.id === id); + removeHighlight(panelKey, id); + if (removed) { + notify("Highlight deleted", { + duration: 5000, + action: { label: "Undo", onClick: () => addHighlight(removed) }, + }); + } + }} + onUpdateNote={(id, note) => { + if (panelKey) updateHighlight(panelKey, id, { note }); + }} + /> + ); + })()} - - - + + + + + + + ); diff --git a/src/components/FolderSidebar.tsx b/src/components/FolderSidebar.tsx index ee2a500..8d217d2 100644 --- a/src/components/FolderSidebar.tsx +++ b/src/components/FolderSidebar.tsx @@ -106,13 +106,16 @@ export function FolderSidebar({ activePath, onOpenFile, onOpenPalette, refreshNo const renderFolder = (f: AnnotatedFolder) => { const tree = trees[f.id]; - const isCollapsed = collapsed[f.id]; + // Default to collapsed when no explicit state has been set, so the + // sidebar opens quietly each session and the user expands what they + // want to scan. + const isCollapsed = collapsed[f.id] ?? true; return (
+ +
+ {editingNote ? ( +
+