//! Per-file drafts and navigation. Accepted model/source history lives in Document. use iced::widget::text_editor::Cursor; use oa_core::{ Id, 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, generation: u64, } 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(), generation: 0, } } pub fn dirty(&self) -> bool { self.text != self.baseline } pub fn generation(&self) -> u64 { self.generation } } pub struct Workspace { session: Id, revision: u64, 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 { /// Ephemeral editor identity, never a model identity or recovery payload. pub fn session(&self) -> Id { self.session } pub fn revision(&self) -> u64 { self.revision } fn advance(&mut self) -> u64 { if let Some(revision) = self.revision.checked_add(1) { self.revision = revision; } else { self.reset_navigation_session(); } self.revision } /// A changed filesystem root must not reuse queued navigation from Save As. pub fn reset_navigation_session(&mut self) { self.session = Id::new_v4(); self.revision = 0; for buffer in self.files.values_mut() { buffer.generation = 0; } } pub fn set_reachable(&mut self, reachable: BTreeSet) { if self.reachable != reachable { self.advance(); self.reachable = reachable; } } 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 { session: Id::new_v4(), revision: 0, 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 { session: Id::new_v4(), revision: 0, 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 changed = self.files.get(&self.active).is_none_or(|b| b.text != text); let generation = if changed { self.advance() } else { self.revision }; let buffer = self .files .entry(self.active.clone()) .or_insert_with(|| Buffer::new(String::new())); buffer.cursor = cursor; if changed { buffer.generation = generation; } 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); } let generation = self.advance(); self.files.get_mut(&self.active).unwrap().generation = generation; 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) { let generation = self.advance(); 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 previous_generation = self .files .get(path) .filter(|b| b.text == *text) .map(|b| b.generation); let mut buffer = Buffer::new(text.clone()); buffer.generation = previous_generation.unwrap_or(generation); 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) { let changed = self.reachable != reachable || bundle .files .keys() .any(|path| !self.files.contains_key(path)); let generation = if changed { self.advance() } else { self.revision }; for (path, text) in &bundle.files { self.files.entry(path.clone()).or_insert_with(|| { let mut buffer = Buffer::new(text.clone()); buffer.generation = generation; buffer }); } 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.advance(); 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 fresh_session_cannot_reuse_a_retained_buffer_generation() { let bundle = SourceBundle { entry: "main.arcadia".into(), files: BTreeMap::from([ ("main.arcadia".into(), "first".into()), ("other.arcadia".into(), "other".into()), ]), }; let mut workspace = Workspace::new(&bundle, true); let cursor = workspace.files["main.arcadia"].cursor; workspace.edit("second".into(), cursor); workspace.reset_navigation_session(); let before = workspace.files["main.arcadia"].generation(); workspace.edit("third".into(), cursor); assert_ne!(workspace.files["main.arcadia"].generation(), before); workspace.switch("other.arcadia", cursor); workspace.revision = u64::MAX; workspace.edit("changed other".into(), cursor); let after_wrap = workspace.files["main.arcadia"].generation(); workspace.switch("main.arcadia", cursor); workspace.edit("fourth".into(), cursor); assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap); assert!(workspace.undo(false)); assert_eq!(workspace.text(), "third"); } #[test] fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() { let bundle = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ ("project.arcadia".into(), "entry\r\n".into()), ("part.arcadia".into(), "雨 draft\r\n".into()), ]), }; let mut workspace = Workspace::new(&bundle, true); let reopened = Workspace::new(&bundle, true); let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap(); assert_ne!(workspace.session(), reopened.session()); assert_ne!(workspace.session(), recovered.session()); assert_eq!(workspace.bundle(), recovered.bundle()); let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap(); assert!(saved.get("session").is_none() && saved.get("revision").is_none()); let cursor = Cursor { position: iced::widget::text_editor::Position { line: 0, column: 3 }, selection: None, }; let revision = workspace.revision(); workspace.edit(workspace.text().into(), cursor); assert_eq!( workspace.revision(), revision, "selection is not a text change" ); workspace.switch("part.arcadia", cursor); assert_eq!( workspace.revision(), revision, "switching retains the snapshot" ); let untouched = workspace.files["project.arcadia"].generation(); workspace.edit("雨 unfinished !\r\n".into(), cursor); let edited = workspace.files["part.arcadia"].generation(); assert!(workspace.revision() > revision); assert_eq!(workspace.files["project.arcadia"].generation(), untouched); assert!(workspace.undo(false)); assert_eq!(workspace.bundle(), bundle); assert!(workspace.files["part.arcadia"].generation() > edited); assert!(workspace.undo(true)); assert_eq!(workspace.text(), "雨 unfinished !\r\n"); let previous_session = workspace.session(); let previous_text = workspace.bundle(); workspace.reset_navigation_session(); assert_ne!(workspace.session(), previous_session); assert_eq!(workspace.bundle(), previous_text); assert!( workspace.undo(false), "session reset preserves text history" ); } #[test] fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() { let bundle = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ ("project.arcadia".into(), "entry".into()), ("part.arcadia".into(), "part".into()), ]), }; let mut workspace = Workspace::new(&bundle, true); let entry_generation = workspace.files[&bundle.entry].generation(); let initial_revision = workspace.revision(); workspace.set_reachable(BTreeSet::from([bundle.entry.clone()])); assert!(workspace.revision() > initial_revision); let membership_revision = workspace.revision(); workspace.set_reachable(workspace.reachable.clone()); assert_eq!(workspace.revision(), membership_revision); assert!(workspace.discard_detached("part.arcadia")); assert!(workspace.revision() > membership_revision); let closed_revision = workspace.revision(); workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()])); assert!(workspace.revision() > closed_revision); assert_eq!( workspace.files[&bundle.entry].generation(), entry_generation ); assert!(workspace.files["part.arcadia"].generation() > closed_revision); let discovered_revision = workspace.revision(); workspace.discovered(&bundle, workspace.reachable.clone()); assert_eq!(workspace.revision(), discovered_revision); workspace.accept(&bundle); assert!( workspace.revision() > discovered_revision, "validation evidence changed" ); assert_eq!( workspace.files[&bundle.entry].generation(), entry_generation ); assert_eq!(workspace.bundle(), bundle); } #[test] fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() { let bundle = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([("project.arcadia".into(), "original".into())]), }; let mut workspace = Workspace::new(&bundle, true); workspace.revision = u64::MAX; let before = workspace.session(); let cursor = workspace.files[&bundle.entry].cursor; workspace.edit("changed".into(), cursor); assert_ne!(workspace.session(), before); assert_eq!(workspace.revision(), 0); assert!(workspace.undo(false)); assert_eq!(workspace.bundle(), bundle); } #[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")); } }