Files
ARCADIA-Studio/crates/oa-desktop/src/source_workspace.rs
T
sorokin.oleg.edu 36db9356cc Add bounded definition and reference navigation for current source drafts
Preserve loaded draft bytes and model history, reject stale workspace and input events, and keep captured Escape under modal priority. Verified 181 focused Windows tests and strict desktop Clippy on exact inputs4; earlier full737 scope and component/native preparation evidence are archived separately. Native navigation acceptance remains pending.
2026-09-11 11:23:20 -07:00

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"));
}
}