Integrate per-file source drafts and exact accepted document history
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
//! 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;
|
||||
|
||||
pub struct Buffer {
|
||||
pub text: String,
|
||||
baseline: String,
|
||||
pub cursor: Cursor,
|
||||
past: Vec<String>,
|
||||
future: Vec<String>,
|
||||
}
|
||||
|
||||
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<String, Buffer>,
|
||||
pub reachable: BTreeSet<String>,
|
||||
pub new_path: String,
|
||||
/// An invalid Open retains the accepted model but has no applied source yet.
|
||||
pub applied: bool,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
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::<usize>(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if histories.iter().map(|(_, _, bytes)| bytes).sum::<usize>() <= 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<String>) {
|
||||
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<SourceBundle, Vec<SourceDiagnostic>> {
|
||||
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<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
source_project::compile_bundle(&reachable(bundle)?, LIMITS)
|
||||
}
|
||||
|
||||
pub fn lint(bundle: &SourceBundle) -> Vec<SourceDiagnostic> {
|
||||
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::<usize>())
|
||||
.sum::<usize>()
|
||||
<= 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user