Integrate per-file source drafts and exact accepted document history

This commit is contained in:
2026-09-05 22:30:57 +03:00
parent 271f06225f
commit 3c87576ed8
9 changed files with 1840 additions and 189 deletions
File diff suppressed because it is too large Load Diff
+517
View File
@@ -0,0 +1,517 @@
//! Accepted graphical and source state, committed and restored together.
//! Editor drafts and filesystem receipts belong to their respective adapters.
use oa_core::{
Command, Id, ModelError, Project, language, prepare_command,
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
validation::Severity,
};
use std::{fmt, sync::Arc};
const HISTORY_LIMIT: usize = 100;
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
struct HistoryEntry {
snapshot: Arc<CompiledSourceProject>,
bytes: usize,
}
fn estimated_bytes(compiled: &CompiledSourceProject) -> usize {
compiled
.bundle
.files
.values()
.fold(0usize, |sum, text| sum.saturating_add(text.len()))
.saturating_add(
serde_json::to_vec(&compiled.decoded.project).map_or(usize::MAX, |bytes| bytes.len()),
)
}
#[derive(Debug)]
pub enum DocumentError {
Model(ModelError),
Source(Vec<SourceDiagnostic>),
}
impl DocumentError {
pub fn diagnostics(&self) -> &[SourceDiagnostic] {
match self {
Self::Model(_) => &[],
Self::Source(diagnostics) => diagnostics,
}
}
}
impl fmt::Display for DocumentError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Model(error) => error.fmt(formatter),
Self::Source(diagnostics) => {
if let Some(first) = diagnostics.first() {
write!(
formatter,
"{}:{}:{}: {}",
first.path,
first.diagnostic.line,
first.diagnostic.column,
first.diagnostic.message
)
} else {
formatter.write_str("Source validation failed.")
}
}
}
}
}
impl std::error::Error for DocumentError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Model(error) => Some(error),
Self::Source(_) => None,
}
}
}
impl From<ModelError> for DocumentError {
fn from(error: ModelError) -> Self {
Self::Model(error)
}
}
impl From<Vec<SourceDiagnostic>> for DocumentError {
fn from(diagnostics: Vec<SourceDiagnostic>) -> Self {
Self::Source(diagnostics)
}
}
pub struct Document {
current: Arc<CompiledSourceProject>,
past: Vec<HistoryEntry>,
future: Vec<HistoryEntry>,
current_bytes: usize,
saved: Arc<SourceBundle>,
revision: u64,
limits: ProjectLimits,
}
impl Document {
/// A valid generated workspace starts clean, like a new semantic Session.
pub fn from_project(project: Project, limits: ProjectLimits) -> Result<Self, DocumentError> {
oa_core::validate_candidate(&project)?;
let document = Self::from_bundle(source_project::format_bundle(&project), limits)?;
if document.project() != &project {
return Err(DocumentError::Model(ModelError(
"Generated source does not reproduce the supplied project.".into(),
)));
}
Ok(document)
}
/// Retain the exact input as the saved baseline. If compilation inserts
/// missing IDs, those accepted source changes still need saving.
pub fn from_bundle(bundle: SourceBundle, limits: ProjectLimits) -> Result<Self, DocumentError> {
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
let current_bytes = estimated_bytes(&current);
Ok(Self {
current,
current_bytes,
past: Vec::new(),
future: Vec::new(),
saved: Arc::new(bundle),
revision: 0,
limits,
})
}
pub fn project(&self) -> &Project {
&self.current.decoded.project
}
pub fn bundle(&self) -> &SourceBundle {
&self.current.bundle
}
pub fn compiled(&self) -> &CompiledSourceProject {
&self.current
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn dirty(&self) -> bool {
self.bundle() != self.saved.as_ref()
}
pub fn can_undo(&self) -> bool {
!self.past.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.future.is_empty()
}
/// Capture this before asynchronous saving. Completion must return the same
/// snapshot, rather than marking whatever is current at completion as saved.
pub fn snapshot(&self) -> Arc<CompiledSourceProject> {
self.current.clone()
}
/// Records the bytes actually saved, even when later edits remain current.
/// The I/O adapter owns document identity and save completion ordering.
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
self.saved = Arc::new(snapshot.bundle.clone());
}
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, DocumentError> {
let candidate = prepare_command(self.project(), &command)?;
if &candidate.project != self.project() {
let compiled = source_project::update_bundle_with_identity_map(
&self.current,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
self.commit(compiled);
}
Ok(candidate.selected)
}
pub fn apply_bundle(&mut self, bundle: SourceBundle) -> Result<bool, DocumentError> {
let compiled = source_project::compile_bundle(&bundle, self.limits)?;
Ok(self.commit(compiled))
}
/// Public compiled fields are convenient data, not a validation capability.
/// Rebuild diagnostics/index and reject a stale or altered model/index pair.
pub fn apply_compiled(
&mut self,
compiled: CompiledSourceProject,
) -> Result<bool, DocumentError> {
let checked = source_project::compile_bundle(&compiled.bundle, self.limits)?;
if checked.bundle != compiled.bundle
|| checked.decoded.project != compiled.decoded.project
|| checked.index != compiled.index
{
return Err(DocumentError::Source(vec![SourceDiagnostic {
path: compiled.bundle.entry,
diagnostic: language::Diagnostic {
severity: Severity::Error,
code: "stale_source_index".into(),
object_id: None,
field: None,
line: 1,
column: 1,
message: "Compile the current documents before applying this source snapshot."
.into(),
span: language::Span::default(),
},
related: Vec::new(),
}]));
}
Ok(self.commit(checked))
}
pub fn undo(&mut self) -> bool {
let Some(previous) = self.past.pop() else {
return false;
};
self.future.push(HistoryEntry {
snapshot: std::mem::replace(&mut self.current, previous.snapshot),
bytes: std::mem::replace(&mut self.current_bytes, previous.bytes),
});
self.revision = self.revision.saturating_add(1);
true
}
pub fn redo(&mut self) -> bool {
let Some(next) = self.future.pop() else {
return false;
};
self.past.push(HistoryEntry {
snapshot: std::mem::replace(&mut self.current, next.snapshot),
bytes: std::mem::replace(&mut self.current_bytes, next.bytes),
});
self.revision = self.revision.saturating_add(1);
true
}
fn commit(&mut self, compiled: CompiledSourceProject) -> bool {
if compiled.bundle == self.current.bundle {
return false;
}
let bytes = estimated_bytes(&compiled);
self.past.push(HistoryEntry {
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
bytes: std::mem::replace(&mut self.current_bytes, bytes),
});
self.future.clear();
let mut retained = self.past.iter().fold(self.current_bytes, |sum, entry| {
sum.saturating_add(entry.bytes)
});
while !self.past.is_empty() && (self.past.len() > HISTORY_LIMIT || retained > HISTORY_BYTES)
{
retained = retained.saturating_sub(self.past.remove(0).bytes);
}
self.revision = self.revision.saturating_add(1);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use oa_core::{Kind, Layer};
fn document() -> Document {
Document::from_project(Project::empty(), ProjectLimits::default()).unwrap()
}
fn comment(bundle: &SourceBundle, text: &str) -> SourceBundle {
let mut changed = bundle.clone();
changed
.files
.get_mut(&changed.entry)
.unwrap()
.push_str(text);
changed
}
#[test]
fn creation_selects_the_single_uuid_persisted_in_source() {
let mut document = document();
let id = document
.execute(Command::AddElement {
kind: Kind::Activity,
layer: Layer::Oa,
name: "Assess incident".into(),
parent: None,
})
.unwrap()
.unwrap();
assert_eq!(document.project().elements.len(), 1);
assert_eq!(document.project().elements[&id].name, "Assess incident");
let location = &document.compiled().index.declarations[&id];
let declaration =
&document.bundle().files[&location.path][location.span.start..location.span.end];
assert!(declaration.contains(&id.to_string()));
let reopened =
source_project::compile_bundle(document.bundle(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.decoded.project, *document.project());
assert_eq!(document.revision(), 1);
}
#[test]
fn rejected_compile_command_and_writeback_preserve_state_and_redo() {
let bundle = source_project::format_bundle(&Project::empty());
let bytes = bundle.files.values().map(String::len).sum::<usize>();
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes = bytes + 100;
let mut document = Document::from_bundle(bundle, limits).unwrap();
document
.apply_bundle(comment(document.bundle(), "// preserved future\n"))
.unwrap();
assert!(document.undo());
let before = document.snapshot();
let revision = document.revision();
let history = (document.past.len(), document.future.len());
let malformed = comment(document.bundle(), "?");
assert!(document.apply_bundle(malformed).is_err());
assert!(document.execute(Command::Delete(Id::new_v4())).is_err());
let error = document
.execute(Command::Rename(document.project().id, "x".repeat(200)))
.unwrap_err();
assert!(!error.diagnostics().is_empty());
assert!(error.to_string().contains(&document.bundle().entry));
assert!(Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.revision(), revision);
assert_eq!((document.past.len(), document.future.len()), history);
assert!(!document.dirty());
assert!(document.redo());
}
#[test]
fn comment_and_semantic_history_restore_exact_bytes_and_branch_together() {
let mut document = document();
let initial = document.snapshot();
let annotated = comment(
document.bundle(),
"\r\n// Project rationale: preserve me.\r\n",
);
assert!(document.apply_bundle(annotated.clone()).unwrap());
assert_eq!(document.project(), &initial.decoded.project);
assert_eq!(document.revision(), 1);
document
.execute(Command::Rename(
document.project().id,
"Rescue workspace".into(),
))
.unwrap();
let renamed = document.snapshot();
assert!(document.undo());
assert_eq!(document.bundle(), &annotated);
assert!(document.undo());
assert_eq!(document.bundle(), &initial.bundle);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &annotated);
assert!(document.redo());
assert_eq!(document.bundle(), &renamed.bundle);
assert!(document.undo());
let branch = comment(document.bundle(), "// A different source-only decision.\n");
assert!(document.apply_bundle(branch.clone()).unwrap());
assert!(!document.can_redo());
assert_eq!(document.project(), &initial.decoded.project);
assert!(document.undo());
assert_eq!(document.bundle(), &annotated);
assert!(document.redo());
assert_eq!(document.bundle(), &branch);
}
#[test]
fn asynchronous_save_marks_its_snapshot_without_cleaning_later_edits() {
let mut document = document();
document
.apply_bundle(comment(document.bundle(), "// Saved rationale.\n"))
.unwrap();
let saving = document.snapshot();
document
.apply_bundle(comment(document.bundle(), "// Later unsaved rationale.\n"))
.unwrap();
document.mark_saved(saving);
assert!(document.dirty());
assert!(document.undo());
assert!(!document.dirty());
assert!(document.redo());
assert!(document.dirty());
}
#[test]
fn no_op_command_or_source_apply_preserves_history_revision_and_redo() {
let mut document = document();
assert!(!document.undo());
assert!(!document.redo());
document
.apply_bundle(comment(document.bundle(), "// Future edit.\n"))
.unwrap();
assert!(document.undo());
let before = document.snapshot();
let revision = document.revision();
document
.execute(Command::Rename(
document.project().id,
document.project().name.clone(),
))
.unwrap();
assert!(!document.apply_bundle(document.bundle().clone()).unwrap());
assert!(
!document
.apply_compiled(document.compiled().clone())
.unwrap()
);
assert!(Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.revision(), revision);
assert!(document.can_redo());
assert!(!document.can_undo());
}
#[test]
fn altered_compiled_model_or_index_is_rejected_without_mutation() {
let mut document = document();
let before = document.snapshot();
let mut forged = document.compiled().clone();
forged.decoded.project.name = "Not in the source".into();
assert!(document.apply_compiled(forged).is_err());
let mut forged = document.compiled().clone();
forged.index.declarations.clear();
let error = document.apply_compiled(forged).unwrap_err();
assert_eq!(error.diagnostics()[0].diagnostic.code, "stale_source_index");
assert!(Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.revision(), 0);
assert!(!document.can_undo());
}
#[test]
fn omitted_id_stabilization_remains_dirty_until_exact_source_is_saved() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" {}\n".into(),
)]
.into(),
};
let mut document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
assert!(document.dirty());
assert_ne!(document.bundle(), &bundle);
let id = document.project().id;
assert!(!document.apply_bundle(document.bundle().clone()).unwrap());
assert_eq!(document.project().id, id);
document.mark_saved(document.snapshot());
assert!(!document.dirty());
}
#[test]
fn accepted_history_keeps_the_latest_hundred_complete_snapshots() {
let mut document = document();
for change in 0..105 {
document
.apply_bundle(comment(
document.bundle(),
&format!("// Change {change}.\n"),
))
.unwrap();
}
assert_eq!(document.past.len(), HISTORY_LIMIT);
let mut undone = 0;
while document.undo() {
undone += 1;
}
assert_eq!(undone, HISTORY_LIMIT);
assert_eq!(
document.bundle().files[&document.bundle().entry]
.matches("// Change")
.count(),
5
);
while document.redo() {}
assert_eq!(document.past.len(), HISTORY_LIMIT);
assert_eq!(document.revision(), 305);
}
#[test]
fn invalid_project_is_rejected_before_format_can_normalize_it() {
let mut project = Project::empty();
project.schema_version = 17;
assert!(Document::from_project(project, ProjectLimits::default()).is_err());
}
#[test]
fn byte_budget_trims_oldest_history_without_discarding_a_large_current() {
let mut document = document();
let rationale = format!("// {}\n", "x".repeat(11 * 1024 * 1024));
document
.apply_bundle(comment(document.bundle(), &rationale))
.unwrap();
document
.apply_bundle(comment(document.bundle(), "// Second version\n"))
.unwrap();
document
.apply_bundle(comment(document.bundle(), "// Third version\n"))
.unwrap();
assert_eq!(document.past.len(), 1);
let before = document.snapshot();
assert!(document.undo());
assert!(!document.can_undo());
assert!(document.redo());
assert!(Arc::ptr_eq(&before, &document.snapshot()));
let large = format!("// {}\n", "y".repeat(22 * 1024 * 1024));
document
.apply_bundle(comment(document.bundle(), &large))
.unwrap();
assert!(document.current_bytes > HISTORY_BYTES);
assert!(!document.can_undo());
assert!(document.bundle().files[&document.bundle().entry].ends_with(&large));
}
}
+2
View File
@@ -2,6 +2,7 @@
mod app;
mod behavior;
mod diagram;
mod document;
mod forms;
mod guide;
mod highlight;
@@ -10,6 +11,7 @@ mod port_preview;
mod port_workflow;
mod route_edit;
mod source_navigation;
mod source_workspace;
mod style;
#[cfg(test)]
mod tests;
+352
View File
@@ -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"));
}
}
+24 -12
View File
@@ -1,5 +1,5 @@
use crate::{
app::{App, Message, Page, Pending},
app::{App, LoadedSnapshot, Message, Page, Pending},
forms::Draft,
workflow::{Event, Workflow},
};
@@ -53,12 +53,12 @@ fn opening_and_creating_models_preserve_the_actual_window_size_for_fit() {
send(&mut app, Message::WindowResized(size));
send(
&mut app,
Message::Loaded(Ok(Some((
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
std::path::PathBuf::from("resized-model.arcadia"),
persistence::DecodeResult {
Box::new(persistence::DecodeResult {
project: project.clone(),
migration: None,
},
}),
std::sync::Arc::from(language::format(&project).into_bytes()),
)))),
);
@@ -102,9 +102,9 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
let mut app = App::with_project(Project::empty());
send(
&mut app,
Message::Loaded(Ok(Some((
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
std::path::PathBuf::from("legacy.json"),
decoded,
Box::new(decoded),
bytes.clone(),
)))),
);
@@ -124,12 +124,12 @@ fn fit_uses_measured_canvas_bounds_and_preserves_them_on_open() {
send(&mut app, Message::CanvasResized(canvas));
send(
&mut app,
Message::Loaded(Ok(Some((
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
std::path::PathBuf::from("measured.arcadia"),
persistence::DecodeResult {
Box::new(persistence::DecodeResult {
project: p.clone(),
migration: None,
},
}),
std::sync::Arc::from(language::format(&p).into_bytes()),
)))),
);
@@ -300,7 +300,10 @@ fn graphical_rename_keeps_aliases_and_references_and_undo() {
before.elements[&id].alias
);
assert_eq!(
language::parse(&app.source_editor.text()).unwrap(),
crate::source_workspace::compile(&app.source_workspace.bundle())
.unwrap()
.decoded
.project,
*app.project()
);
send(&mut app, Message::Undo);
@@ -374,7 +377,13 @@ fn malformed_source_preserves_model_and_is_recoverable() {
send(&mut app, Message::Cancel);
assert_eq!(app.source_editor.text(), "arcadia 2; ?");
send(&mut app, Message::RevertSource);
assert_eq!(language::parse(&app.source_editor.text()).unwrap(), p);
assert_eq!(
crate::source_workspace::compile(&app.source_workspace.bundle())
.unwrap()
.decoded
.project,
p
);
}
#[test]
fn escaped_string_mode_preserves_exact_control_characters() {
@@ -581,7 +590,10 @@ fn mission_capability_authoring_is_contextual_atomic_and_text_equivalent() {
send(&mut app, Message::Workflow(Event::Apply));
assert!(app.project().relations.values().any(|r| matches!(&r.body, RelationBody::CapabilityExtend {source,target,condition: value} if *source == extension && *target == base && value == condition)));
assert_eq!(
language::parse(&app.source_editor.text()).unwrap(),
crate::source_workspace::compile(&app.source_workspace.bundle())
.unwrap()
.decoded
.project,
*app.project()
);
send(&mut app, Message::Undo);
+118 -20
View File
@@ -69,7 +69,7 @@ pub fn view(app: &App) -> View<'_, Message> {
action("New", Message::Request(Pending::New)),
action("Open", Message::Request(Pending::Open)),
action("Save as", Message::Save(true)),
button(text("Save project").size(12))
button(text("Save all").size(12))
.padding([10, 16])
.on_press(Message::Save(false))
]
@@ -252,11 +252,15 @@ pub fn view(app: &App) -> View<'_, Message> {
)
.push(creation)
.push(action("Project properties", Message::SelectProject).width(Fill));
let sidebar = container(nav)
.padding(14)
.width(230)
.height(Fill)
.style(style::panel);
let sidebar = container(if app.page == Page::Text {
source_files(app)
} else {
nav.into()
})
.padding(14)
.width(230)
.height(Fill)
.style(style::panel);
let main = match app.page {
Page::Diagram => diagram(app),
Page::Catalog | Page::Data => catalog(app),
@@ -306,7 +310,8 @@ pub fn view(app: &App) -> View<'_, Message> {
text(title).size(21),
label(match p.operation {
FileOperation::Export => "Save this diagram as .svg.",
_ => "Use .arcadia for canonical source or .json for the model.",
FileOperation::Save => "Choose a .arcadia entry in a new destination, or .json to export the model.",
FileOperation::Open => "Open an .arcadia project entry or import a .json model.",
}),
text_input("File path", &p.path)
.on_input(Message::FilePath)
@@ -318,8 +323,8 @@ pub fn view(app: &App) -> View<'_, Message> {
"This file exists. Replace it with the current diagram?",
(FileOperation::Export, false) =>
"Includes visible elements and their authored connection routes.",
(_, true) => "This file exists. Replace it with the current model?",
(_, false) => "Your model includes both layers, diagrams and traceability.",
(FileOperation::Save, _) => "Source Save As retains included file paths. Existing destination files are protected.",
(FileOperation::Open, _) => "Included source files are loaded together. Invalid drafts remain editable.",
})
.size(12),
row![
@@ -1071,6 +1076,80 @@ fn traces(app: &App) -> View<'_, Message> {
scrollable(out.padding(24)).into()
}
fn source_files(app: &App) -> View<'_, Message> {
let workspace = &app.source_workspace;
let mut files = column![].spacing(3);
for (path, buffer) in &workspace.files {
let detached = !workspace.reachable.contains(path);
let errors = app
.source_diagnostics
.iter()
.filter(|issue| issue.path == *path)
.count();
let filename = path.rsplit('/').next().unwrap_or(path);
let detail: String = if detached {
"Detached · retained on disk".into()
} else if path == &workspace.entry {
"Project entry".into()
} else {
path.rsplit_once('/')
.map_or("Included source", |(parent, _)| parent)
.into()
};
let caption = format!(
"{}{}{}",
if buffer.dirty() { "* " } else { "" },
short(filename, 27),
if errors > 0 { " !" } else { "" }
);
files = files.push(
button(column![text(caption).size(12), label(detail)].spacing(3))
.padding(8)
.width(Fill)
.on_press(Message::SourceFile(path.clone()))
.style(if &workspace.active == path {
style::selected
} else {
style::nav
}),
);
if detached && !buffer.dirty() {
files = files.push(
action(
"Close detached buffer",
Message::SourceCloseFile(path.clone()),
)
.width(Fill),
);
}
}
let mut panel = column![
text("SOURCE FILES").size(13),
label(format!(
"{} files · one system model",
workspace.files.len()
)),
scrollable(files).height(Fill),
action("Load includes", Message::SourceLoadIncludes).width(Fill),
text_input("oa/new-file.arcadia", &workspace.new_path)
.size(12)
.padding(8)
.on_input(Message::SourceNewPath)
.on_submit(Message::SourceCreateFile),
action("+ Create source file", Message::SourceCreateFile).width(Fill),
label("Includes define file membership. Closing a detached buffer never deletes its file."),
]
.spacing(9);
if app
.source_diagnostics
.iter()
.any(|issue| issue.diagnostic.code == "pending_save")
{
panel = panel.push(action("Recover interrupted save", Message::SourceRecover));
}
panel.into()
}
fn source(app: &App) -> View<'_, Message> {
use crate::source_navigation::Event as SourceEvent;
let navigation: View<'_, Message> = if app.source_navigation.open {
@@ -1117,11 +1196,14 @@ fn source(app: &App) -> View<'_, Message> {
diagnostics = diagnostics.push(
button(
text(format!(
"{:?} · {}:{} {}",
issue.severity, issue.line, issue.column, issue.message
"{}:{}:{} {}",
issue.path,
issue.diagnostic.line,
issue.diagnostic.column,
issue.diagnostic.message
))
.size(12)
.color(match issue.severity {
.color(match issue.diagnostic.severity {
validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64),
validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23),
}),
@@ -1131,6 +1213,12 @@ fn source(app: &App) -> View<'_, Message> {
.style(style::nav)
.on_press(Message::SourceDiagnostic(index)),
);
for related in &issue.related {
diagnostics = diagnostics.push(action(
&format!("Related declaration · {}", related.path),
Message::SourceRelated(related.clone()),
));
}
}
let editor = widget::text_editor(&app.source_editor)
.id("source-editor")
@@ -1149,30 +1237,40 @@ fn source(app: &App) -> View<'_, Message> {
row![
container(heading(
"Textual model",
"One typed model · stable @aliases · canonical source for Git"
"One model across files · comments and identities preserved"
))
.width(Fill),
button(text("Apply to model").size(12))
button(text("Apply all").size(12))
.padding(10)
.on_press(Message::ApplySource),
action("Revert draft", Message::RevertSource)
action("Revert file", Message::RevertSource)
]
.spacing(8)
.align_y(iced::Center),
row![
action("Undo text", Message::SourceUndo),
action("Redo text", Message::SourceRedo),
action("Undo model", Message::Undo),
button(text("Undo model").size(12)).padding([8,10]).style(style::nav)
.on_press_maybe((app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo)),
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
label(if app.source_dirty() {
"Draft · changes are not applied yet"
"Drafts pending"
} else {
"In sync with graphical model"
"Model in sync"
})
]
.spacing(7)
.align_y(iced::Center),
label("Apply normalizes formatting and removes comments. Model names, descriptions and identifiers are preserved."),
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
label(if !app.source_workspace.applied {
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
} else if app.source_workspace.detached_dirty() {
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
} else {
"Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)."
}),
navigation,
scrollable(diagnostics)
.height((app.source_diagnostics.len() as f32 * 32.).min(132.))
@@ -1180,7 +1278,7 @@ fn source(app: &App) -> View<'_, Message> {
.spacing(12)
)
.padding(18),
widget::sensor(editor).key(app.source_jump)
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
]
.into()
+2 -1
View File
@@ -46,7 +46,8 @@ Earlier Studio windows disappeared during concurrent work. Fresh inventory confi
## Exact next actions
1. Implement release-source-integration.md. Core `prepare_command` is committed in `41d7963`; 198 core tests passed after extraction. The new desktop Document module passed eight external-harness tests and strict Clippy for exact accepted history, single identity creation, failed writeback, delayed saved baselines and missing-ID dirtiness. It is not yet integrated or committed. Agent source_integration_map owns desktop Document/workspace/app/views/tests integration; source_filesystem owns core disk IO/tests and the narrow same-file dependency/export change; source_document_review independently reviews the Document boundary. Root owns integration, documentation and native QA. Then extend the production parser/updater for the selected syntax direction and REST bundles; do not promote the bounded experiment unchanged.
1. Native acceptance of release-source-integration.md is next. `86bf36b` preserves file/comment ownership through actual command identity provenance, including implicit ID+alias edits and sequential UUID swaps; it also exposes raw-draft lint locations. Independent Document probes originally reproduced all three ownership failures and now pass all seven probes (`source-document-independent-rerun.log`). `271f062` adds bounded source-project disk discovery, dependency conflict checks, staged recoverable Save All and explicit recovery. Fresh independent Linux probes found blocking FIFO reads in both discovery and physical-alias checks plus mode0664→0600 replacement; root fixed pre-open regular-file admission and permission preservation, then reran both probes (exit0, mode0664) and all11 Linux disk integration tests. This is still a non-CAS filesystem contract.
Desktop integration is in the working tree: one accepted Document history, per-file drafts/navigation, whole-bundle lint/Apply, source Open/Save All and repair/recovery. Root fixed warning-only projects wrongly entering repair mode. All366 Windows workspace tests passed (223core,121desktop,22server), plus strict workspace Clippy. A follow-up adds an aggregate32MiB draft-history cap; its final desktop tests/build are running. The accepted model/source history separately has an estimated32MiB cap. The detached-file review finding is fixed at the UI lifecycle: closed buffers retain original dependency expectations until detach is saved, then receipts retire so later reattachment reads fresh disk bytes; its real disk integration test passes. Agents stopped with usage-limit errors, so root owns all remaining integration and native input. Do not claim the source UI accepted until native workflows and a fresh UI review run. Then extend production syntax and REST bundles; do not promote the experiment unchanged.
2. Linux routing acceptance is complete. Independent verification passed all 324 workspace tests, strict Clippy, formatting and optimized desktop/server builds at `23535b4`. Root's actual Linux/WSLg bend, label, Undo/Redo, Save and reopen passed. That run exposed a dialog-induced viewport recenter, fixed in `366ce44`: all 105 Windows desktop tests and strict desktop Clippy passed, Windows/Linux rebuilds passed, and root's rebuilt Linux native trial preserved the viewport throughout Save. `release-routing-linux.md` records exact scopes/hashes; final saved fixture is `ui-routing-viewport/linux-routing.arcadia` (33,546 bytes). The Linux test window was closed normally after saving. No full 325-test Linux invocation is claimed.
3. Implement direct state/scenario/process gestures, R3 semantic drift and reviewed reconciliation, R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag workflows, navigable offline guidance and normative reST/API completion. These are required release work, not non-blocking limitations of a finished release.
4. Run fresh requirements, correctness, native UI/UX and architecture reviews for the complete release; address material findings. Verify current Windows/Linux/MSRV builds, runtime workflows, documentation, packages and checksums before completion.
+3 -2
View File
@@ -23,7 +23,8 @@ System engineers can develop a coherent OA→SA system model, including iterativ
- Independent core review reproduced alias reservation and same-transaction allocation replacement defects; fixes have focused regression evidence. Typed desktop integration `fa096ce` passed full checks and native creation/generation, undo/redo, save/reopen and no-op synchronization trials. Long-reference previews were inspected at 1440×900 and 1100×700; detailed evidence is in STATE.md.
- Per-diagram pins and labels, source/API parity and shared orthogonal routing are implemented in `4e5a88d`, `a7c6f96`, `68b1eff` and `30c7db1`. `65fa9ab` resolves all six reproduced default SA label-overlap pairs. Native OA/SA editing, movement/undo, fold restoration, save/reopen, SVG inspection and short gestures at 1100×700 passed. Routing code/native acceptance and independent native follow-up are complete at `23535b4`; exact scope is recorded in release-routing-verification.md.
- The source experiment passed 23 tests, but an independent probe reproduced positional item-comment misattachment, shorthand description rejection and unsupported deletion. Whole-model equality does not establish source-comment fidelity. The production updater retained comment attachment on the same reorder probe; extend its parser/updater instead of copying the experimental parallel implementation.
- Current behavior diagrams derive geometry and only handle selection. Text editing currently flattens the accepted model. These remain product gaps regardless of underlying semantic coverage.
- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The integrated source editor now retains modular accepted source instead of flattening it, with223core/121desktop/22server Windows tests passing; native acceptance and production nesting/shorthand remain incomplete.
- Independent source review exposed declaration/comment reassignment during UUID changes and swaps, Linux FIFO hangs during disk admission, file-mode changes on save, and overlong detached-file receipt lifetimes. Command provenance, pre-open regular-file admission, permission retention and saved-detach receipt retirement resolve the reproduced cases. Exact probes and test scopes are in STATE.md.
## Assumptions to test
@@ -46,6 +47,6 @@ System engineers can develop a coherent OA→SA system model, including iterativ
## Current strategy and evaluators
Extend production source parsing/updating using the independent probe findings as regressions; integrate per-file editing, safe project saves, exact source Undo and REST bundles. Independent current WSL tests, strict Clippy and release builds are in progress; Linux success is not yet claimed. Prototype persisted direct behavior gestures next. Continue R3 reconciliation, R6 data contracts, lifecycle/recovery, hosted editing, offline guidance and fresh Windows/Linux release acceptance; no subsystem checkpoint constitutes release completion.
Accept native per-file editing, safe project saves and exact source Undo, then extend production source parsing/updating using the independent probe findings as regressions and integrate REST bundles. Linux routing is accepted with actual WSLg Save/reopen; all11 current Linux disk integration tests also pass. Full release Linux acceptance remains open. Prototype persisted direct behavior gestures next. Continue R3 reconciliation, R6 data contracts, lifecycle/recovery, hosted editing, offline guidance and fresh Windows/Linux release acceptance; no subsystem checkpoint constitutes release completion.
Meaningful progress now includes verified drag and typed-port authoring, accepted editable routing with native save/reopen, group movement and minimum-size evidence, and tested source-bundle/nested-writeback foundations with independent limitations exposed. Production source and direct behavior usability remain open engineering tasks; feature counts alone do not establish completion.
+8
View File
@@ -50,6 +50,14 @@ failure handling; reopen reproduces the accepted model and source.
visibility or compare-and-swap with other editors.
- Source Save As retains relative file layout and reviews destination collisions.
JSON remains whole-model interchange and cannot erase source layout/history.
- Recovery/staging directories use the reserved `.arcadia-save-*` prefix. Ignore
`.arcadia-save-*/` when tracking a source project in Git; these contain local
save recovery data, not model declarations.
- Source writeback consumes the command engine's complete original-to-candidate
identity correspondence. Changing UUID/alias or swapping UUIDs preserves the
authored declaration file and associated reference-list comments. Inferring
correspondence from surviving UUIDs or aliases is insufficient. Full model
replacement uses common UUIDs as its explicit correspondence boundary.
## Production syntax direction