Integrate per-file source drafts and exact accepted document history
This commit is contained in:
+814
-154
File diff suppressed because it is too large
Load Diff
@@ -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(¤t);
|
||||
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,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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user