Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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> {
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<Highlight>,
) -> 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)
}
172 changes: 172 additions & 0 deletions src-tauri/src/highlights.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HighlightsFile {
pub version: u32,
pub files: HashMap<String, Vec<Highlight>>,
}

impl Default for HighlightsFile {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
files: HashMap::new(),
}
}
}

impl HighlightsFile {
pub fn load(dir: &Path) -> AppResult<Self> {
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\""));
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod commands;
mod error;
mod folder;
mod fs;
mod highlights;
mod registry;
mod search;
mod settings;
Expand Down Expand Up @@ -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");
Expand Down
61 changes: 57 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ 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";
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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -246,6 +260,9 @@ function AppShell() {
onCloseSplit={handleCloseSplit}
isSplit={isSplit}
onFind={() => setSearchPaneId(state.activePaneId)}
onToggleHighlights={() => setHighlightsPanelOpen(!highlightsPanelOpen)}
highlightsOpen={highlightsPanelOpen}
activeHighlightColour={activeColour}
/>
<div className="flex min-h-0 flex-1">
<main className="min-w-0 flex-1">
Expand All @@ -270,7 +287,7 @@ function AppShell() {
))}
</div>
</main>
{!isSplit && (
{!isSplit && !highlightsPanelOpen && (
<>
<ResizeHandle
side="right"
Expand All @@ -283,6 +300,38 @@ function AppShell() {
</aside>
</>
)}
{highlightsPanelOpen && (() => {
const panelKey = activeTab ? activeTab.filePath ?? ":welcome" : undefined;
return (
<HighlightsPanel
filePath={activeTab?.filePath ?? (activeTab ? "(welcome)" : undefined)}
highlights={panelKey ? highlightsByFile[panelKey] ?? [] : []}
activeColour={activeColour}
onSetActive={setActiveColour}
onClose={() => 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 });
}}
/>
);
})()}
</div>
</div>
<CommandPalette
Expand All @@ -308,9 +357,13 @@ export default function App() {
return (
<ThemeProvider>
<PreferencesProvider>
<TooltipProvider delayDuration={300}>
<AppShell />
</TooltipProvider>
<NotificationsProvider>
<HighlightsProvider>
<TooltipProvider delayDuration={300}>
<AppShell />
</TooltipProvider>
</HighlightsProvider>
</NotificationsProvider>
</PreferencesProvider>
</ThemeProvider>
);
Expand Down
7 changes: 5 additions & 2 deletions src/components/FolderSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div key={f.id} id={`folder-${f.id}`} className="group/folder mb-1">
<div className="flex items-center justify-between gap-1 rounded px-1 py-0.5 hover:bg-accent">
<button
type="button"
onClick={() => setCollapsed((c) => ({ ...c, [f.id]: !c[f.id] }))}
onClick={() => setCollapsed((c) => ({ ...c, [f.id]: !isCollapsed }))}
className="flex flex-1 items-center gap-1 truncate text-left"
>
{isCollapsed ? (
Expand Down
Loading