use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct OutlineRow {
pub id: Uuid,
pub depth: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ClipMode {
Copy,
Move,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct ParaClipboard {
pub id: Uuid,
pub mode: ClipMode,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(super) struct OutlineState {
#[serde(default)]
pub expanded: HashMap<Uuid, bool>,
#[serde(default)]
pub cursor_uuid: Option<Uuid>,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub filter_str: String,
}
impl OutlineState {
pub(super) fn sidecar_path(project_root: &Path) -> PathBuf {
project_root.join(".inkhaven").join("outline-state.json")
}
pub(super) fn load(project_root: &Path) -> OutlineState {
match std::fs::read_to_string(Self::sidecar_path(project_root)) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => OutlineState::default(),
}
}
pub(super) fn save(&self, project_root: &Path) -> std::io::Result<()> {
let path = Self::sidecar_path(project_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_vec_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
crate::io_atomic::write(&path, &body)
}
pub(super) fn is_expanded(&self, id: &Uuid) -> bool {
self.expanded.get(id).copied().unwrap_or(false)
}
pub(super) fn set_expanded(&mut self, id: Uuid, expanded: bool) {
self.expanded.insert(id, expanded);
}
pub(super) fn toggle_expanded(&mut self, id: Uuid) {
let now = self.is_expanded(&id);
self.expanded.insert(id, !now);
}
pub(super) fn seed_default_expansion(&mut self, h: &Hierarchy) {
if !self.expanded.is_empty() {
return;
}
for n in h.iter() {
if h.has_children(n.id) && matches!(n.kind, NodeKind::Book | NodeKind::Chapter) {
self.expanded.insert(n.id, true);
}
}
}
pub(super) fn collapsed_set(&self, h: &Hierarchy) -> HashSet<Uuid> {
h.iter()
.filter(|n| h.has_children(n.id) && !self.is_expanded(&n.id))
.map(|n| n.id)
.collect()
}
pub(super) fn visible_rows(&self, h: &Hierarchy) -> Vec<OutlineRow> {
let needle = self.filter_str.trim().to_lowercase();
if needle.is_empty() {
let collapsed = self.collapsed_set(h);
return h
.flatten_with_collapsed(&collapsed)
.into_iter()
.map(|(n, depth)| OutlineRow { id: n.id, depth })
.collect();
}
let mut out = Vec::new();
for root in h.children_of(None) {
Self::collect_filtered(h, root, 0, &needle, &mut out);
}
out
}
fn collect_filtered(
h: &Hierarchy,
node: &Node,
depth: usize,
needle: &str,
out: &mut Vec<OutlineRow>,
) -> bool {
let start = out.len();
let mut any_child = false;
for child in h.children_of(Some(node.id)) {
if Self::collect_filtered(h, child, depth + 1, needle, out) {
any_child = true;
}
}
let self_match = node.title.to_lowercase().contains(needle)
|| node.slug.to_lowercase().contains(needle);
if self_match || any_child {
out.insert(start, OutlineRow { id: node.id, depth });
true
} else {
out.truncate(start);
false
}
}
pub(super) fn cursor_index(&self, rows: &[OutlineRow]) -> usize {
self.cursor_uuid
.and_then(|c| rows.iter().position(|r| r.id == c))
.unwrap_or(0)
}
pub(super) fn move_cursor(&mut self, rows: &[OutlineRow], delta: isize) -> usize {
if rows.is_empty() {
self.cursor_uuid = None;
return 0;
}
let cur = self.cursor_index(rows) as isize;
let next = (cur + delta).clamp(0, rows.len() as isize - 1) as usize;
self.cursor_uuid = Some(rows[next].id);
next
}
pub(super) fn reanchor_cursor(&mut self, rows: &[OutlineRow], fallback_idx: usize) {
if rows.is_empty() {
self.cursor_uuid = None;
return;
}
let still_visible = self
.cursor_uuid
.is_some_and(|c| rows.iter().any(|r| r.id == c));
if !still_visible {
let idx = fallback_idx.min(rows.len() - 1);
self.cursor_uuid = Some(rows[idx].id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::node::Node;
fn node(id: Uuid, kind: &str, slug: &str, parent: Option<Uuid>, order: u32) -> Node {
serde_json::from_value(serde_json::json!({
"id": id, "kind": kind, "title": slug, "slug": slug,
"path": [], "parent_id": parent, "order": order, "file": null,
"modified_at": "2026-01-01T00:00:00Z",
}))
.expect("test node")
}
fn sample() -> (Hierarchy, [Uuid; 5]) {
let book = Uuid::now_v7();
let ch = Uuid::now_v7();
let sub = Uuid::now_v7();
let p1 = Uuid::now_v7();
let p2 = Uuid::now_v7();
let h = Hierarchy::from_nodes_for_test(vec![
node(book, "book", "b", None, 1),
node(ch, "chapter", "ch1", Some(book), 1),
node(sub, "subchapter", "sub1", Some(ch), 1),
node(p1, "paragraph", "p1", Some(sub), 1),
node(p2, "paragraph", "p2", Some(sub), 2),
]);
(h, [book, ch, sub, p1, p2])
}
#[test]
fn default_view_expands_book_and_chapter_only() {
let (h, [book, ch, sub, _p1, _p2]) = sample();
let mut s = OutlineState::default();
s.seed_default_expansion(&h);
let rows = s.visible_rows(&h);
let ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
assert_eq!(ids, vec![book, ch, sub]);
assert_eq!(rows[0].depth, 0);
assert_eq!(rows[2].depth, 2);
}
#[test]
fn expanding_subchapter_reveals_paragraphs() {
let (h, [book, ch, sub, p1, p2]) = sample();
let mut s = OutlineState::default();
s.seed_default_expansion(&h);
s.set_expanded(sub, true);
let ids: Vec<Uuid> = s.visible_rows(&h).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![book, ch, sub, p1, p2]);
}
#[test]
fn filter_keeps_matches_and_their_ancestors() {
let (h, [book, ch, sub, p1, p2]) = sample();
let mut s = OutlineState::default();
s.filter_str = "p1".into();
let ids: Vec<Uuid> = s.visible_rows(&h).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![book, ch, sub, p1]);
assert!(!ids.contains(&p2));
s.filter_str = "ch1".into();
let ids: Vec<Uuid> = s.visible_rows(&h).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![book, ch]);
s.set_expanded(ch, false);
s.filter_str = "p2".into();
let ids: Vec<Uuid> = s.visible_rows(&h).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![book, ch, sub, p2]);
s.filter_str = "zzz-nope".into();
assert!(s.visible_rows(&h).is_empty());
}
#[test]
fn seed_is_noop_once_user_has_toggled() {
let (h, [book, ch, _sub, _p1, _p2]) = sample();
let mut s = OutlineState::default();
s.set_expanded(book, true); s.set_expanded(ch, false); s.seed_default_expansion(&h); let ids: Vec<Uuid> = s.visible_rows(&h).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![book, ch]); }
#[test]
fn cursor_navigation_clamps_and_tracks_uuid() {
let (h, [book, ch, sub, _p1, _p2]) = sample();
let mut s = OutlineState::default();
s.seed_default_expansion(&h);
let rows = s.visible_rows(&h); assert_eq!(s.cursor_index(&rows), 0); assert_eq!(s.move_cursor(&rows, 1), 1);
assert_eq!(s.cursor_uuid, Some(ch));
assert_eq!(s.move_cursor(&rows, 5), 2); assert_eq!(s.cursor_uuid, Some(sub));
assert_eq!(s.move_cursor(&rows, -9), 0); assert_eq!(s.cursor_uuid, Some(book));
}
#[test]
fn reanchor_keeps_visible_cursor_else_falls_back() {
let (h, [book, ch, sub, _p1, _p2]) = sample();
let mut s = OutlineState::default();
s.seed_default_expansion(&h);
let rows = s.visible_rows(&h);
s.cursor_uuid = Some(ch);
s.reanchor_cursor(&rows, 0);
assert_eq!(s.cursor_uuid, Some(ch)); s.cursor_uuid = Some(Uuid::now_v7());
s.reanchor_cursor(&rows, 99);
assert_eq!(s.cursor_uuid, Some(sub)); s.reanchor_cursor(&[], 0);
assert_eq!(s.cursor_uuid, None);
let _ = book;
}
#[test]
fn round_trips_through_sidecar() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let mut s = OutlineState::default();
s.set_expanded(a, true);
s.set_expanded(b, false);
s.cursor_uuid = Some(a);
s.scroll_offset = 12;
s.filter_str = "harbour".into();
s.save(root).unwrap();
let back = OutlineState::load(root);
assert_eq!(back, s);
assert!(back.is_expanded(&a));
assert!(!back.is_expanded(&b));
assert_eq!(back.cursor_uuid, Some(a));
assert_eq!(back.scroll_offset, 12);
assert_eq!(back.filter_str, "harbour");
}
#[test]
fn absent_and_malformed_default() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert_eq!(OutlineState::load(root), OutlineState::default());
std::fs::create_dir_all(root.join(".inkhaven")).unwrap();
std::fs::write(OutlineState::sidecar_path(root), b"{not json").unwrap();
assert_eq!(OutlineState::load(root), OutlineState::default());
}
#[test]
fn expand_helpers() {
let mut s = OutlineState::default();
let id = Uuid::new_v4();
assert!(!s.is_expanded(&id)); s.toggle_expanded(id);
assert!(s.is_expanded(&id));
s.toggle_expanded(id);
assert!(!s.is_expanded(&id));
}
}