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 app;
|
||||||
mod behavior;
|
mod behavior;
|
||||||
mod diagram;
|
mod diagram;
|
||||||
|
mod document;
|
||||||
mod forms;
|
mod forms;
|
||||||
mod guide;
|
mod guide;
|
||||||
mod highlight;
|
mod highlight;
|
||||||
@@ -10,6 +11,7 @@ mod port_preview;
|
|||||||
mod port_workflow;
|
mod port_workflow;
|
||||||
mod route_edit;
|
mod route_edit;
|
||||||
mod source_navigation;
|
mod source_navigation;
|
||||||
|
mod source_workspace;
|
||||||
mod style;
|
mod style;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
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::{
|
use crate::{
|
||||||
app::{App, Message, Page, Pending},
|
app::{App, LoadedSnapshot, Message, Page, Pending},
|
||||||
forms::Draft,
|
forms::Draft,
|
||||||
workflow::{Event, Workflow},
|
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::WindowResized(size));
|
||||||
send(
|
send(
|
||||||
&mut app,
|
&mut app,
|
||||||
Message::Loaded(Ok(Some((
|
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
|
||||||
std::path::PathBuf::from("resized-model.arcadia"),
|
std::path::PathBuf::from("resized-model.arcadia"),
|
||||||
persistence::DecodeResult {
|
Box::new(persistence::DecodeResult {
|
||||||
project: project.clone(),
|
project: project.clone(),
|
||||||
migration: None,
|
migration: None,
|
||||||
},
|
}),
|
||||||
std::sync::Arc::from(language::format(&project).into_bytes()),
|
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());
|
let mut app = App::with_project(Project::empty());
|
||||||
send(
|
send(
|
||||||
&mut app,
|
&mut app,
|
||||||
Message::Loaded(Ok(Some((
|
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
|
||||||
std::path::PathBuf::from("legacy.json"),
|
std::path::PathBuf::from("legacy.json"),
|
||||||
decoded,
|
Box::new(decoded),
|
||||||
bytes.clone(),
|
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::CanvasResized(canvas));
|
||||||
send(
|
send(
|
||||||
&mut app,
|
&mut app,
|
||||||
Message::Loaded(Ok(Some((
|
Message::Loaded(Ok(Some(LoadedSnapshot::Legacy(
|
||||||
std::path::PathBuf::from("measured.arcadia"),
|
std::path::PathBuf::from("measured.arcadia"),
|
||||||
persistence::DecodeResult {
|
Box::new(persistence::DecodeResult {
|
||||||
project: p.clone(),
|
project: p.clone(),
|
||||||
migration: None,
|
migration: None,
|
||||||
},
|
}),
|
||||||
std::sync::Arc::from(language::format(&p).into_bytes()),
|
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
|
before.elements[&id].alias
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
language::parse(&app.source_editor.text()).unwrap(),
|
crate::source_workspace::compile(&app.source_workspace.bundle())
|
||||||
|
.unwrap()
|
||||||
|
.decoded
|
||||||
|
.project,
|
||||||
*app.project()
|
*app.project()
|
||||||
);
|
);
|
||||||
send(&mut app, Message::Undo);
|
send(&mut app, Message::Undo);
|
||||||
@@ -374,7 +377,13 @@ fn malformed_source_preserves_model_and_is_recoverable() {
|
|||||||
send(&mut app, Message::Cancel);
|
send(&mut app, Message::Cancel);
|
||||||
assert_eq!(app.source_editor.text(), "arcadia 2; ?");
|
assert_eq!(app.source_editor.text(), "arcadia 2; ?");
|
||||||
send(&mut app, Message::RevertSource);
|
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]
|
#[test]
|
||||||
fn escaped_string_mode_preserves_exact_control_characters() {
|
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));
|
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!(app.project().relations.values().any(|r| matches!(&r.body, RelationBody::CapabilityExtend {source,target,condition: value} if *source == extension && *target == base && value == condition)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
language::parse(&app.source_editor.text()).unwrap(),
|
crate::source_workspace::compile(&app.source_workspace.bundle())
|
||||||
|
.unwrap()
|
||||||
|
.decoded
|
||||||
|
.project,
|
||||||
*app.project()
|
*app.project()
|
||||||
);
|
);
|
||||||
send(&mut app, Message::Undo);
|
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("New", Message::Request(Pending::New)),
|
||||||
action("Open", Message::Request(Pending::Open)),
|
action("Open", Message::Request(Pending::Open)),
|
||||||
action("Save as", Message::Save(true)),
|
action("Save as", Message::Save(true)),
|
||||||
button(text("Save project").size(12))
|
button(text("Save all").size(12))
|
||||||
.padding([10, 16])
|
.padding([10, 16])
|
||||||
.on_press(Message::Save(false))
|
.on_press(Message::Save(false))
|
||||||
]
|
]
|
||||||
@@ -252,11 +252,15 @@ pub fn view(app: &App) -> View<'_, Message> {
|
|||||||
)
|
)
|
||||||
.push(creation)
|
.push(creation)
|
||||||
.push(action("Project properties", Message::SelectProject).width(Fill));
|
.push(action("Project properties", Message::SelectProject).width(Fill));
|
||||||
let sidebar = container(nav)
|
let sidebar = container(if app.page == Page::Text {
|
||||||
.padding(14)
|
source_files(app)
|
||||||
.width(230)
|
} else {
|
||||||
.height(Fill)
|
nav.into()
|
||||||
.style(style::panel);
|
})
|
||||||
|
.padding(14)
|
||||||
|
.width(230)
|
||||||
|
.height(Fill)
|
||||||
|
.style(style::panel);
|
||||||
let main = match app.page {
|
let main = match app.page {
|
||||||
Page::Diagram => diagram(app),
|
Page::Diagram => diagram(app),
|
||||||
Page::Catalog | Page::Data => catalog(app),
|
Page::Catalog | Page::Data => catalog(app),
|
||||||
@@ -306,7 +310,8 @@ pub fn view(app: &App) -> View<'_, Message> {
|
|||||||
text(title).size(21),
|
text(title).size(21),
|
||||||
label(match p.operation {
|
label(match p.operation {
|
||||||
FileOperation::Export => "Save this diagram as .svg.",
|
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)
|
text_input("File path", &p.path)
|
||||||
.on_input(Message::FilePath)
|
.on_input(Message::FilePath)
|
||||||
@@ -318,8 +323,8 @@ pub fn view(app: &App) -> View<'_, Message> {
|
|||||||
"This file exists. Replace it with the current diagram?",
|
"This file exists. Replace it with the current diagram?",
|
||||||
(FileOperation::Export, false) =>
|
(FileOperation::Export, false) =>
|
||||||
"Includes visible elements and their authored connection routes.",
|
"Includes visible elements and their authored connection routes.",
|
||||||
(_, true) => "This file exists. Replace it with the current model?",
|
(FileOperation::Save, _) => "Source Save As retains included file paths. Existing destination files are protected.",
|
||||||
(_, false) => "Your model includes both layers, diagrams and traceability.",
|
(FileOperation::Open, _) => "Included source files are loaded together. Invalid drafts remain editable.",
|
||||||
})
|
})
|
||||||
.size(12),
|
.size(12),
|
||||||
row![
|
row![
|
||||||
@@ -1071,6 +1076,80 @@ fn traces(app: &App) -> View<'_, Message> {
|
|||||||
scrollable(out.padding(24)).into()
|
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> {
|
fn source(app: &App) -> View<'_, Message> {
|
||||||
use crate::source_navigation::Event as SourceEvent;
|
use crate::source_navigation::Event as SourceEvent;
|
||||||
let navigation: View<'_, Message> = if app.source_navigation.open {
|
let navigation: View<'_, Message> = if app.source_navigation.open {
|
||||||
@@ -1117,11 +1196,14 @@ fn source(app: &App) -> View<'_, Message> {
|
|||||||
diagnostics = diagnostics.push(
|
diagnostics = diagnostics.push(
|
||||||
button(
|
button(
|
||||||
text(format!(
|
text(format!(
|
||||||
"{:?} · {}:{} {}",
|
"{}:{}:{} {}",
|
||||||
issue.severity, issue.line, issue.column, issue.message
|
issue.path,
|
||||||
|
issue.diagnostic.line,
|
||||||
|
issue.diagnostic.column,
|
||||||
|
issue.diagnostic.message
|
||||||
))
|
))
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(match issue.severity {
|
.color(match issue.diagnostic.severity {
|
||||||
validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64),
|
validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64),
|
||||||
validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23),
|
validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23),
|
||||||
}),
|
}),
|
||||||
@@ -1131,6 +1213,12 @@ fn source(app: &App) -> View<'_, Message> {
|
|||||||
.style(style::nav)
|
.style(style::nav)
|
||||||
.on_press(Message::SourceDiagnostic(index)),
|
.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)
|
let editor = widget::text_editor(&app.source_editor)
|
||||||
.id("source-editor")
|
.id("source-editor")
|
||||||
@@ -1149,30 +1237,40 @@ fn source(app: &App) -> View<'_, Message> {
|
|||||||
row![
|
row![
|
||||||
container(heading(
|
container(heading(
|
||||||
"Textual model",
|
"Textual model",
|
||||||
"One typed model · stable @aliases · canonical source for Git"
|
"One model across files · comments and identities preserved"
|
||||||
))
|
))
|
||||||
.width(Fill),
|
.width(Fill),
|
||||||
button(text("Apply to model").size(12))
|
button(text("Apply all").size(12))
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.on_press(Message::ApplySource),
|
.on_press(Message::ApplySource),
|
||||||
action("Revert draft", Message::RevertSource)
|
action("Revert file", Message::RevertSource)
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::Center),
|
.align_y(iced::Center),
|
||||||
row![
|
row![
|
||||||
action("Undo text", Message::SourceUndo),
|
action("Undo text", Message::SourceUndo),
|
||||||
action("Redo text", Message::SourceRedo),
|
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))),
|
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
|
||||||
label(if app.source_dirty() {
|
label(if app.source_dirty() {
|
||||||
"Draft · changes are not applied yet"
|
"Drafts pending"
|
||||||
} else {
|
} else {
|
||||||
"In sync with graphical model"
|
"Model in sync"
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
.spacing(7)
|
.spacing(7)
|
||||||
.align_y(iced::Center),
|
.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,
|
navigation,
|
||||||
scrollable(diagnostics)
|
scrollable(diagnostics)
|
||||||
.height((app.source_diagnostics.len() as f32 * 32.).min(132.))
|
.height((app.source_diagnostics.len() as f32 * 32.).min(132.))
|
||||||
@@ -1180,7 +1278,7 @@ fn source(app: &App) -> View<'_, Message> {
|
|||||||
.spacing(12)
|
.spacing(12)
|
||||||
)
|
)
|
||||||
.padding(18),
|
.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)
|
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
|
||||||
]
|
]
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
+2
-1
@@ -46,7 +46,8 @@ Earlier Studio windows disappeared during concurrent work. Fresh inventory confi
|
|||||||
|
|
||||||
## Exact next actions
|
## 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.
|
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.
|
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.
|
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.
|
- 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.
|
- 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.
|
- 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
|
## Assumptions to test
|
||||||
|
|
||||||
@@ -46,6 +47,6 @@ System engineers can develop a coherent OA→SA system model, including iterativ
|
|||||||
|
|
||||||
## Current strategy and evaluators
|
## 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.
|
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.
|
visibility or compare-and-swap with other editors.
|
||||||
- Source Save As retains relative file layout and reviews destination collisions.
|
- Source Save As retains relative file layout and reviews destination collisions.
|
||||||
JSON remains whole-model interchange and cannot erase source layout/history.
|
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
|
## Production syntax direction
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user