From 24ed6c4d1ac3ca3c4e290e4c26beb68d20a3ee4f Mon Sep 17 00:00:00 2001 From: Johnny Bonk Date: Wed, 29 Apr 2026 10:11:27 -0500 Subject: [PATCH 1/3] feat: highlight passages and copy as agent-ready changelist (#5 v1) Adds the v1 cutline of issue #5: select text in the rendered markdown, pick a colour, then copy a single item or whole list to the clipboard in a format coding agents can act on directly. What's in v1 - Selection in the article surfaces a small popover with five colour swatches (yellow / orange / pink / blue / purple) plus a "Copy" shortcut. Keyboard 1-5 applies the matching colour, Esc dismisses. - A right-side review panel (toolbar toggle) lists the current file's highlights grouped by colour. Each list and each item has its own copy button. Clicking an item scrolls the source highlight into view. - Highlights are anchored by source-line range + passage text + occurrence index, leveraging the data-source-map attributes that markdown-it already emits. Re-applied as wrapping spans on every render, so they survive Shiki / Mermaid replacement passes. - Persistence is a single highlights.json under app_data_dir, keyed by absolute file path. New Tauri commands: load_highlights and save_highlights_for_file. - Code blocks are not highlightable in v1 (selection inside
  hides the popover).
- IBM colorblind-safe palette, distinguishable to protanopes.

Tests
- 24 new Vitest cases for the pure helpers (anchoring, occurrence
  counting, agent export formatting).
- 5 new Rust tests for the persistence module (round trip, missing
  file, corrupt file, camelCase JSON keys for the frontend).
- Pre-existing failures in src/lib/markdown.test.ts and
  src/lib/theme.test.tsx also fail on main and are unrelated to this
  change.

Deferred to v2 (kept the diff focused)
- Annotations on highlights and named/described lists.
- Edit history with archive/restore.
- Multi-select copy.
- Customisable palette and JSON / markdown-blockquote export formats.
- Sidecar JSON next to source files (current store is global, which
  follows the project convention of not writing user data outside
  app_data_dir).

Closes the highlighting half of GRVYDEV/marky#5.
The folder-disambiguation half is owned by the maintainer.
---
 src-tauri/src/commands.rs           |  24 +++
 src-tauri/src/highlights.rs         | 143 ++++++++++++++
 src-tauri/src/lib.rs                |   3 +
 src/App.tsx                         |  40 +++-
 src/components/HighlightPopover.tsx | 101 ++++++++++
 src/components/HighlightsPanel.tsx  | 174 +++++++++++++++++
 src/components/Toolbar.tsx          |  43 ++++-
 src/components/Viewer.tsx           | 193 +++++++++++++++++++
 src/lib/highlights.test.ts          | 282 ++++++++++++++++++++++++++++
 src/lib/highlights.ts               | 254 +++++++++++++++++++++++++
 src/lib/highlightsApply.ts          | 187 ++++++++++++++++++
 src/lib/highlightsStore.tsx         | 150 +++++++++++++++
 src/lib/tauri.ts                    |  20 ++
 src/styles/markdown.css             |  34 ++++
 14 files changed, 1642 insertions(+), 6 deletions(-)
 create mode 100644 src-tauri/src/highlights.rs
 create mode 100644 src/components/HighlightPopover.tsx
 create mode 100644 src/components/HighlightsPanel.tsx
 create mode 100644 src/lib/highlights.test.ts
 create mode 100644 src/lib/highlights.ts
 create mode 100644 src/lib/highlightsApply.ts
 create mode 100644 src/lib/highlightsStore.tsx

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..fb541c8
--- /dev/null
+++ b/src-tauri/src/highlights.rs
@@ -0,0 +1,143 @@
+//! 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,
+}
+
+#[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(),
+        }
+    }
+
+    #[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\""));
+    }
+}
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..cfc9593 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,7 @@ 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 { ResizeHandle } from "@/components/ResizeHandle";
 import { TooltipProvider } from "@/components/ui/tooltip";
 import { FolderSidebar } from "@/components/FolderSidebar";
@@ -10,6 +11,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 +52,14 @@ function AppShell() {
     sidebarLeftWidth, sidebarRightWidth,
     setSidebarWidth, resetSidebarWidth,
   } = usePreferences();
+  const {
+    panelOpen: highlightsPanelOpen,
+    setPanelOpen: setHighlightsPanelOpen,
+    activeColour,
+    setActiveColour,
+    byFile: highlightsByFile,
+    removeHighlight,
+  } = useHighlights();
 
   const activeTab = getActiveTab(state);
   const activePane = getActivePane(state);
@@ -246,6 +256,9 @@ function AppShell() {
           onCloseSplit={handleCloseSplit}
           isSplit={isSplit}
           onFind={() => setSearchPaneId(state.activePaneId)}
+          onToggleHighlights={() => setHighlightsPanelOpen(!highlightsPanelOpen)}
+          highlightsOpen={highlightsPanelOpen}
+          activeHighlightColour={activeColour}
         />
         
@@ -270,7 +283,7 @@ function AppShell() { ))}
- {!isSplit && ( + {!isSplit && !highlightsPanelOpen && ( <> )} + {highlightsPanelOpen && ( + setHighlightsPanelOpen(false)} + onJump={(id) => { + document + .querySelector("article.markdown-body") + ?.dispatchEvent(new CustomEvent("marky:scroll-to-highlight", { detail: id })); + }} + onRemove={(id) => { + if (activeTab?.filePath) removeHighlight(activeTab.filePath, id); + }} + /> + )} - - - + + + + + ); diff --git a/src/components/HighlightPopover.tsx b/src/components/HighlightPopover.tsx new file mode 100644 index 0000000..3c7e247 --- /dev/null +++ b/src/components/HighlightPopover.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import { HIGHLIGHT_COLOURS, type HighlightColour } from "@/lib/highlights"; +import { cn } from "@/lib/utils"; + +export interface HighlightPopoverState { + /** Viewport position of the bottom-centre of the selection. */ + x: number; + y: number; +} + +interface Props { + state: HighlightPopoverState | null; + activeColour: HighlightColour; + onApply: (colour: HighlightColour) => void; + onCopyForAgent: () => void; + onDismiss: () => void; +} + +/** + * Floating popover anchored to the user's selection. Shows the five colour + * swatches plus a "Copy for agent" shortcut. Click a swatch → apply highlight. + * Dismisses on outside click or Escape. + */ +export function HighlightPopover({ + state, + activeColour, + onApply, + onCopyForAgent, + onDismiss, +}: Props) { + const ref = React.useRef(null); + + React.useEffect(() => { + if (!state) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onDismiss(); + } else if (e.key >= "1" && e.key <= "5") { + const idx = parseInt(e.key, 10) - 1; + const colour = HIGHLIGHT_COLOURS[idx]; + if (colour) { + e.preventDefault(); + onApply(colour); + } + } + }; + const onMouseDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onDismiss(); + } + }; + window.addEventListener("keydown", onKey); + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("mousedown", onMouseDown); + }; + }, [state, onApply, onDismiss]); + + if (!state) return null; + + return ( +
e.stopPropagation()} + > + {HIGHLIGHT_COLOURS.map((c, i) => ( + +
+ ); +} diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx new file mode 100644 index 0000000..586f094 --- /dev/null +++ b/src/components/HighlightsPanel.tsx @@ -0,0 +1,174 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + HIGHLIGHT_COLOURS, + formatList, + formatItem, + type Highlight, + type HighlightColour, +} from "@/lib/highlights"; +import { Copy, Trash2, X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface Props { + filePath: string | undefined; + highlights: Highlight[]; + activeColour: HighlightColour; + onSetActive: (c: HighlightColour) => void; + onClose: () => void; + onJump: (id: string) => void; + onRemove: (id: string) => void; +} + +async function copyToClipboard(text: string): Promise { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + } catch { + // Best-effort; older Tauri webviews may need a fallback. The HTML5 + // execCommand path is deprecated but is the only synchronous fallback. + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + } finally { + document.body.removeChild(ta); + } + } +} + +/** + * Right-side review panel. Lists the current file's highlights grouped by + * colour, with per-list and per-item copy actions. Clicking an item scrolls + * the source highlight into view. + */ +export function HighlightsPanel({ + filePath, + highlights, + activeColour, + onSetActive, + onClose, + onJump, + onRemove, +}: Props) { + const grouped = React.useMemo(() => { + const out = new Map(); + for (const c of HIGHLIGHT_COLOURS) out.set(c, []); + for (const h of highlights) out.get(h.colour)?.push(h); + return out; + }, [highlights]); + + return ( + + ); +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 75806ff..0bd86d5 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -13,10 +13,12 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, } from "@/components/ui/dropdown-menu"; -import { Search, Sun, Moon, Monitor, FileText, SplitSquareHorizontal, SplitSquareVertical, X, Settings, Minus, Plus } from "lucide-react"; +import { Search, Sun, Moon, Monitor, FileText, SplitSquareHorizontal, SplitSquareVertical, X, Settings, Minus, Plus, Highlighter } from "lucide-react"; import { useTheme, type Theme } from "@/lib/theme"; import { usePreferences } from "@/lib/preferences"; import type { SplitDirection } from "@/lib/workspace"; +import type { HighlightColour } from "@/lib/highlights"; +import { cn } from "@/lib/utils"; interface Props { filePath?: string; @@ -25,9 +27,22 @@ interface Props { onCloseSplit: () => void; isSplit: boolean; onFind: () => void; + onToggleHighlights?: () => void; + highlightsOpen?: boolean; + activeHighlightColour?: HighlightColour; } -export function Toolbar({ filePath, onOpenFile, onSplit, onCloseSplit, isSplit, onFind }: Props) { +export function Toolbar({ + filePath, + onOpenFile, + onSplit, + onCloseSplit, + isSplit, + onFind, + onToggleHighlights, + highlightsOpen, + activeHighlightColour, +}: Props) { const { theme, setTheme } = useTheme(); const { copyAsMarkdown, setCopyAsMarkdown, zoom, zoomIn, zoomOut, zoomReset } = usePreferences(); const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; @@ -44,6 +59,30 @@ export function Toolbar({ filePath, onOpenFile, onSplit, onCloseSplit, isSplit, Find in document (⌘F) + {onToggleHighlights && ( + + + + + + {highlightsOpen ? "Hide highlights" : "Show highlights"} + + + )} + + + {editingNote ? ( +
+