//! Per-file drafts and navigation. Accepted model/source history lives in Document. use iced::widget::text_editor::Cursor; use oa_core::{ language, source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic}, }; use std::collections::{BTreeMap, BTreeSet}; pub const LIMITS: ProjectLimits = ProjectLimits { max_files: 256, max_include_depth: 32, max_include_edges: 1024, model: language::ParseLimits { max_source_bytes: 64 * 1024 * 1024, max_graph_objects: 100_000, max_records: 250_000, max_step_references: 500_000, }, }; const DRAFT_HISTORY_BYTES: usize = 32 * 1024 * 1024; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct RecoveryWorkspace { entry: String, active: String, files: BTreeMap, reachable: BTreeSet, applied: bool, new_path: String, } impl RecoveryWorkspace { /// Refresh only accepted clean buffers. Dirty baselines and invalid/unapplied /// workspaces are authored recovery data, not migration input. pub(crate) fn upgrade_clean_buffers(&mut self, old: &SourceBundle, new: &SourceBundle) { if !self.applied || self.entry != old.entry { return; } for (path, (text, baseline)) in &mut self.files { if text == baseline && old.files.get(path) == Some(text) && let Some(upgraded) = new.files.get(path) { *text = upgraded.clone(); *baseline = upgraded.clone(); } } } /// Check the same bounds before replacing a checkpoint and before restoring it. pub(crate) fn validate(&self) -> Result<(), String> { if self.files.len() > LIMITS.max_files || !self.files.contains_key(&self.entry) || !self.files.contains_key(&self.active) || !self.reachable.contains(&self.entry) || self.reachable.len() > LIMITS.max_include_edges + 1 { return Err("Recovery has an invalid file count, entry or active file.".into()); } if self.files.values().fold(0usize, |bytes, (text, base)| { bytes.saturating_add(text.len()).saturating_add(base.len()) }) > LIMITS.model.max_source_bytes.saturating_mul(2) { return Err("Recovery source buffers exceed the size limit.".into()); } for path in self.files.keys().chain(self.reachable.iter()) { // A recovery buffer is never a filesystem path to open implicitly. if path.starts_with('/') || path.contains('\\') || path.contains(':') || path .split('/') .any(|part| part == ".." || part == "." || part.is_empty()) { return Err("Recovery contains an unsafe source path.".into()); } } Ok(()) } } pub struct Buffer { pub text: String, baseline: String, pub cursor: Cursor, past: Vec, future: Vec, } impl Buffer { fn new(text: String) -> Self { Self { baseline: text.clone(), text, cursor: Cursor { position: iced::widget::text_editor::Position { line: 0, column: 0 }, selection: None, }, past: Vec::new(), future: Vec::new(), } } pub fn dirty(&self) -> bool { self.text != self.baseline } } pub struct Workspace { pub entry: String, pub active: String, pub files: BTreeMap, pub reachable: BTreeSet, pub new_path: String, /// An invalid Open retains the accepted model but has no applied source yet. pub applied: bool, } impl Workspace { pub fn recovery_snapshot(&self) -> RecoveryWorkspace { RecoveryWorkspace { entry: self.entry.clone(), active: self.active.clone(), files: self .files .iter() .map(|(path, buffer)| { (path.clone(), (buffer.text.clone(), buffer.baseline.clone())) }) .collect(), reachable: self.reachable.clone(), applied: self.applied, new_path: self.new_path.clone(), } } pub fn from_recovery(saved: RecoveryWorkspace) -> Result { saved.validate()?; Ok(Self { entry: saved.entry, active: saved.active, files: saved .files .into_iter() .map(|(path, (text, baseline))| { let mut buffer = Buffer::new(text); buffer.baseline = baseline; (path, buffer) }) .collect(), reachable: saved.reachable, applied: saved.applied, new_path: saved.new_path, }) } pub fn new(bundle: &SourceBundle, applied: bool) -> Self { Self { entry: bundle.entry.clone(), active: bundle.entry.clone(), files: bundle .files .iter() .map(|(p, t)| (p.clone(), Buffer::new(t.clone()))) .collect(), reachable: bundle.files.keys().cloned().collect(), new_path: String::new(), applied, } } pub fn text(&self) -> &str { self.files.get(&self.active).map_or("", |b| b.text.as_str()) } pub fn dirty(&self) -> bool { !self.applied || self.has_edits() } pub fn has_edits(&self) -> bool { self.files.values().any(Buffer::dirty) } pub fn detached_dirty(&self) -> bool { self.files .iter() .any(|(p, b)| !self.reachable.contains(p) && b.dirty()) } pub fn bundle(&self) -> SourceBundle { SourceBundle { entry: self.entry.clone(), files: self .files .iter() .map(|(p, b)| (p.clone(), b.text.clone())) .collect(), } } pub fn edit(&mut self, text: String, cursor: Cursor) { let buffer = self .files .entry(self.active.clone()) .or_insert_with(|| Buffer::new(String::new())); buffer.cursor = cursor; if text != buffer.text { buffer.past.push(std::mem::replace(&mut buffer.text, text)); if buffer.past.len() > 100 { buffer.past.remove(0); } buffer.future.clear(); self.trim_history(DRAFT_HISTORY_BYTES); } } // Bound all file histories together. Evict the oldest/furthest snapshot // from the largest history first; current text and baselines are retained. fn trim_history(&mut self, budget: usize) { loop { let histories: Vec<_> = self .files .iter() .flat_map(|(path, buffer)| { [(&buffer.past, false), (&buffer.future, true)] .into_iter() .filter(|(history, _)| !history.is_empty()) .map(move |(history, future)| { ( path.clone(), future, history.iter().map(String::len).sum::(), ) }) }) .collect(); if histories.iter().map(|(_, _, bytes)| bytes).sum::() <= budget { break; } let Some((path, future, _)) = histories.into_iter().max_by_key(|(_, _, bytes)| *bytes) else { break; }; let buffer = self.files.get_mut(&path).unwrap(); if future { buffer.future.remove(0); } else { buffer.past.remove(0); } } } pub fn switch(&mut self, path: &str, cursor: Cursor) -> bool { if path == self.active || !self.files.contains_key(path) { return false; } if let Some(buffer) = self.files.get_mut(&self.active) { buffer.cursor = cursor; } self.active = path.into(); true } pub fn undo(&mut self, redo: bool) -> bool { let Some(buffer) = self.files.get_mut(&self.active) else { return false; }; let value = if redo { buffer.future.pop() } else { buffer.past.pop() }; let Some(value) = value else { return false; }; let previous = std::mem::replace(&mut buffer.text, value); if redo { buffer.past.push(previous); } else { buffer.future.push(previous); } self.trim_history(DRAFT_HISTORY_BYTES); true } pub fn revert_active(&mut self) { if let Some((baseline, cursor)) = self .files .get(&self.active) .map(|b| (b.baseline.clone(), b.cursor)) { self.edit(baseline, cursor); } } pub fn accept(&mut self, bundle: &SourceBundle) { if self.entry != bundle.entry && !bundle.files.contains_key(&self.entry) && self.files.get(&self.entry).is_some_and(|b| !b.dirty()) { self.files.remove(&self.entry); } self.entry = bundle.entry.clone(); self.reachable = bundle.files.keys().cloned().collect(); for (path, text) in &bundle.files { let cursor = self.files.get(path).map(|b| b.cursor); let mut buffer = Buffer::new(text.clone()); if let Some(cursor) = cursor { buffer.cursor = cursor; } self.files.insert(path.clone(), buffer); } if !self.files.contains_key(&self.active) { self.active = self.entry.clone(); } self.applied = true; } pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet) { for (path, text) in &bundle.files { self.files .entry(path.clone()) .or_insert_with(|| Buffer::new(text.clone())); } self.reachable = reachable; } pub fn discard_detached(&mut self, path: &str) -> bool { if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) { return false; } self.files.remove(path); if self.active == path { self.active = self.entry.clone(); } true } } /// Follow only explicitly reachable documents. Disconnected buffers remain in /// Workspace, while the compiler deliberately rejects extras in its input. pub fn reachable(bundle: &SourceBundle) -> Result> { let mut included = BTreeSet::new(); let mut todo = vec![(bundle.entry.clone(), 0usize)]; let mut edges = 0usize; while let Some((path, depth)) = todo.pop() { if !included.insert(path.clone()) { continue; } if depth > LIMITS.max_include_depth || included.len() > LIMITS.max_files { // The compiler supplies its normative admission diagnostic. return source_project::compile_bundle(bundle, LIMITS).map(|c| c.bundle); } let Some(text) = bundle.files.get(&path) else { return source_project::compile_bundle(bundle, LIMITS).map(|c| c.bundle); }; let includes = source_project::inspect_includes(&path, text, path == bundle.entry, LIMITS)?; edges = edges.saturating_add(includes.len()); if edges > LIMITS.max_include_edges { return source_project::compile_bundle(bundle, LIMITS).map(|c| c.bundle); } todo.extend( includes .into_iter() .map(|include| (include.target, depth + 1)), ); } Ok(SourceBundle { entry: bundle.entry.clone(), files: bundle .files .iter() .filter(|(p, _)| included.contains(*p)) .map(|(p, t)| (p.clone(), t.clone())) .collect(), }) } pub fn compile(bundle: &SourceBundle) -> Result> { source_project::compile_bundle(&reachable(bundle)?, LIMITS) } pub fn lint(bundle: &SourceBundle) -> Vec { match reachable(bundle) { Ok(bundle) => source_project::lint_bundle(&bundle, LIMITS), Err(diagnostics) => diagnostics, } } #[cfg(test)] mod tests { use super::*; use oa_core::Project; #[test] fn draft_history_budget_covers_inactive_files_without_discarding_drafts() { let bundle = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ ("project.arcadia".into(), "entry baseline".into()), ("part.arcadia".into(), "part baseline".into()), ]), }; let mut workspace = Workspace::new(&bundle, true); for buffer in workspace.files.values_mut() { buffer.past = vec!["old".repeat(20), "recent".repeat(10)]; buffer.future = vec!["future".repeat(10)]; buffer.text = "unfinished draft".into(); } workspace.trim_history(130); assert!( workspace .files .values() .map(|b| b .past .iter() .chain(&b.future) .map(String::len) .sum::()) .sum::() <= 130 ); for (path, buffer) in &workspace.files { assert_eq!(buffer.text, "unfinished draft"); assert_eq!(buffer.baseline, bundle.files[path]); assert!(buffer.dirty()); } } #[test] fn switching_retains_invalid_drafts_and_independent_history() { let mut bundle = source_project::format_bundle(&Project::demo()); let fragment = bundle .files .keys() .find(|p| **p != bundle.entry) .unwrap() .clone(); let original = bundle.files[&fragment].clone(); let mut workspace = Workspace::new(&bundle, true); let cursor = Cursor { position: iced::widget::text_editor::Position { line: 0, column: 0 }, selection: None, }; workspace.switch(&fragment, cursor); workspace.edit("? invalid".into(), cursor); workspace.switch(&bundle.entry, cursor); assert!(workspace.dirty()); assert!(compile(&workspace.bundle()).is_err()); workspace.switch(&fragment, cursor); assert_eq!(workspace.text(), "? invalid"); assert!(workspace.undo(false)); assert_eq!(workspace.text(), original); assert!(!workspace.dirty()); bundle .files .insert("detached.arcadia".into(), "? parked".into()); workspace.discovered(&bundle, workspace.reachable.clone()); assert!(compile(&workspace.bundle()).is_ok()); assert!(workspace.discard_detached("detached.arcadia")); } }