Files
ARCADIA-Studio/crates/oa-desktop/src/source_workspace.rs
T

661 lines
24 KiB
Rust

//! 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<String, (String, String)>,
reachable: BTreeSet<String>,
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<String>,
future: Vec<String>,
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<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 {
/// 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<String>) {
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<Self, String> {
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::<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);
}
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<String>) {
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<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 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::<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"));
}
}