diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index fa71a16e..cee7ca79 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1,11 +1,30 @@ use crate::forms::Draft; +use crate::{ + document::Document, + source_workspace::{self, LIMITS, Workspace}, +}; use iced::{Element, Subscription, Task, keyboard, widget::text_editor, window}; use oa_core::{projection::project_view, validation::validate, *}; +use oa_core::{ + source_files::{self, DiskProject}, + source_project::{CompiledSourceProject, SourceDiagnostic, SourceLocation}, +}; use serde_json::{Value, json}; use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; -type FileSnapshot = (PathBuf, Project, Arc<[u8]>); -type LoadedSnapshot = (PathBuf, persistence::DecodeResult, Arc<[u8]>); +#[derive(Debug, Clone)] +pub enum LoadedSnapshot { + Source(DiskProject), + Legacy(PathBuf, Box, Arc<[u8]>), +} +#[derive(Debug, Clone)] +pub struct FileSnapshot { + pub document: Id, + pub revision: u64, + pub snapshot: Arc, + pub disk: Option, + pub path: PathBuf, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { @@ -102,13 +121,23 @@ pub enum Message { RevertSource, SourceUndo, SourceRedo, + SourceFile(String), + SourceNewPath(String), + SourceCreateFile, + SourceCloseFile(String), + SourceLoadIncludes, + SourceRecover, + SourceRecovered(Result), + IncludesLoaded(u64, Result), Lint(u64), - Linted(u64, Result, String>), + Linted(u64, Result, String>), SourceDiagnostic(usize), + SourceRelated(SourceLocation), SourceViewport, SourceForSelection, SourceNavigation(crate::source_navigation::Event), Save(bool), + SavePath(Option), Saved(Result, String>), Loaded(Result, String>), Request(Pending), @@ -131,7 +160,7 @@ pub struct App { pub canvas_epoch: u64, pub selected_bend: Option<(Id, usize)>, pub exact_strings: bool, - pub session: Session, + pub document: Document, pub page: Page, pub guide: crate::guide::State, pub behavior: crate::behavior::State, @@ -148,7 +177,7 @@ pub struct App { pub pending: Option, pub delete_pending: bool, pub path: Option, - file_snapshot: Option>, + disk_project: Option, pub file_prompt: Option, continue_after_save: bool, pub zoom: f32, @@ -157,15 +186,14 @@ pub struct App { canvas_size: Option, fit_to_view: bool, pub source_editor: text_editor::Content, - source_baseline: String, + pub source_workspace: Workspace, pub source_snapshot: Arc, - pub source_diagnostics: Vec, + pub source_diagnostics: Vec, pub source_jump: Option, + source_select_line: bool, pub source_navigation: crate::source_navigation::State, pub model_issues: Vec, pub migration: Option, - source_past: Vec, - source_future: Vec, source_version: u64, lint_running: Option, lint_requested: Option, @@ -180,7 +208,10 @@ impl App { app } pub fn with_project(project: Project) -> Self { - let source = language::format(&project); + let document = Document::from_project(project.clone(), LIMITS) + .expect("Application models are valid source projects"); + let source_workspace = Workspace::new(document.bundle(), true); + let source = source_workspace.text().to_owned(); let model_issues = validate(&project); let selected = project.id; let populated = |layer| { @@ -204,7 +235,7 @@ impl App { canvas_epoch: 0, selected_bend: None, exact_strings: false, - session: Session::new(project), + document, page: Page::Diagram, guide: crate::guide::State::default(), behavior: Default::default(), @@ -225,7 +256,7 @@ impl App { pending: None, delete_pending: false, path: None, - file_snapshot: None, + disk_project: None, file_prompt: None, continue_after_save: false, zoom: 0.65, @@ -234,15 +265,14 @@ impl App { canvas_size: None, fit_to_view: true, source_editor: text_editor::Content::with_text(&source), - source_baseline: source.clone(), + source_workspace, source_snapshot: Arc::from(source), source_diagnostics: vec![], source_jump: None, + source_select_line: false, source_navigation: crate::source_navigation::State::default(), model_issues, migration: None, - source_past: vec![], - source_future: vec![], source_version: 0, lint_running: None, lint_requested: None, @@ -251,7 +281,7 @@ impl App { } } pub fn project(&self) -> &Project { - self.session.project() + self.document.project() } pub fn title(&self) -> String { format!( @@ -265,27 +295,36 @@ impl App { ) } pub fn source_dirty(&self) -> bool { - self.source_editor.text() != self.source_baseline + self.source_workspace.dirty() } pub fn draft_dirty(&self) -> bool { self.draft.as_ref().is_some_and(Draft::dirty) } pub fn dirty(&self) -> bool { - self.session.dirty() || self.draft_dirty() || self.source_dirty() + self.document.dirty() || self.draft_dirty() || self.source_dirty() } fn sync_source(&mut self) { - self.source_jump = None; - let source = language::format(self.project()); - self.source_navigation.refresh(&source); - self.source_editor = text_editor::Content::with_text(&source); - self.source_baseline = source.clone(); - self.source_snapshot = Arc::from(source); - self.source_diagnostics.clear(); - self.source_past.clear(); - self.source_future.clear(); + self.source_workspace.accept(self.document.bundle()); + self.show_source_file(false); + self.source_diagnostics = self.document.compiled().diagnostics.clone(); self.source_version += 1; self.lint_requested = None; } + fn show_source_file(&mut self, restore_cursor: bool) { + self.source_select_line = false; + let source = self.source_workspace.text().to_owned(); + self.source_navigation.refresh(&source); + self.source_editor = text_editor::Content::with_text(&source); + self.source_snapshot = Arc::from(source); + self.source_jump = restore_cursor + .then(|| { + self.source_workspace + .files + .get(&self.source_workspace.active) + .map(|b| b.cursor) + }) + .flatten(); + } fn refresh_draft(&mut self) { self.selected_node = self .active_diagram @@ -340,21 +379,20 @@ impl App { if !self.source_dirty() { return true; } - match language::parse_with_report(&self.source_editor.text()) { - Ok(decoded) => match self.session.execute(Command::Replace(decoded.project)) { - Ok(_) => { - if decoded.migration.is_some() { - self.migration = decoded.migration; + match source_workspace::compile(&self.source_workspace.bundle()) { + Ok(compiled) => { + match self.document.apply_compiled(compiled) { + Ok(_) => { + self.sync(); + self.status = if self.source_workspace.detached_dirty() { "Reachable files applied. Reattach or revert detached drafts before saving." } else { "All source files applied as one model change" }.into(); + true + } + Err(e) => { + self.status = e.to_string(); + false } - self.sync(); - self.status = "Text applied as one model change".into(); - true } - Err(e) => { - self.status = e.to_string(); - false - } - }, + } Err(issues) => { self.source_diagnostics = issues; self.status = "Source contains errors. Your draft is preserved.".into(); @@ -364,7 +402,9 @@ impl App { } } pub fn apply(&mut self) -> bool { - if !self.apply_source() { + if self.source_dirty() { + self.status = + "Apply all source drafts or revert them before changing the model.".into(); return false; } if !self.draft_dirty() { @@ -377,7 +417,7 @@ impl App { .ok_or("No selection.".to_string()) .and_then(|d| Ok((d.command()?, d.resulting_id()?))); match result.and_then(|(command, id)| { - self.session + self.document .execute(command) .map(|_| id) .map_err(|e| e.to_string()) @@ -418,7 +458,7 @@ impl App { } } fn execute(&mut self, command: Command) -> bool { - match self.session.execute(command) { + match self.document.execute(command) { Ok(selected) => { if let Some(id) = selected { self.selected = id; @@ -526,6 +566,9 @@ impl App { message, Message::Saved(_) | Message::Loaded(_) + | Message::IncludesLoaded(_, _) + | Message::SavePath(_) + | Message::SourceRecovered(_) | Message::Linted(_, _) | Message::Exported(_) | Message::WindowResized(_) @@ -566,6 +609,9 @@ impl App { | Message::Linted(_, _) | Message::Saved(_) | Message::Loaded(_) + | Message::IncludesLoaded(_, _) + | Message::SavePath(_) + | Message::SourceRecovered(_) | Message::Exported(_) ); if !modal_event @@ -704,13 +750,7 @@ impl App { } Message::Search(value) => self.search = value, Message::Page(page) => { - if page == Page::Guide - || (if page == Page::Text { - self.apply() - } else { - self.apply_source() - }) - { + if page != Page::Text || !self.draft_dirty() || self.apply() { self.page = page; if page == Page::Text { return self.request_lint(); @@ -1044,9 +1084,9 @@ impl App { let redo = matches!(message, Message::Redo); if self.apply() { if redo { - self.session.redo(); + self.document.redo(); } else { - self.session.undo(); + self.document.undo(); } self.sync(); self.status = if redo { @@ -1191,11 +1231,8 @@ impl App { let after = self.source_editor.text(); if after != before { self.source_navigation.refresh(&after); - self.source_past.push(before); - if self.source_past.len() > 100 { - self.source_past.remove(0); - } - self.source_future.clear(); + self.source_workspace + .edit(after.clone(), self.source_editor.cursor()); self.source_snapshot = Arc::from(after); self.source_version += 1; self.source_diagnostics.clear(); @@ -1209,6 +1246,83 @@ impl App { ); } } + Message::SourceFile(path) => { + if self + .source_workspace + .switch(&path, self.source_editor.cursor()) + { + self.show_source_file(true); + } + } + Message::SourceNewPath(path) => self.source_workspace.new_path = path, + Message::SourceCreateFile => { + if let Err(error) = self.create_source_file() { + self.status = error; + } else { + return self.request_lint(); + } + } + Message::SourceCloseFile(path) => { + if self.source_workspace.discard_detached(&path) { + self.source_version += 1; + self.prune_closed_files(!self.document.dirty()); + self.show_source_file(true); + } + } + Message::SourceLoadIncludes => return self.load_includes(), + Message::SourceRecover => { + if !self.source_workspace.applied && self.source_workspace.has_edits() { + self.status = + "Revert or preserve your source drafts before reloading recovered files." + .into(); + return Task::none(); + } + if let Some(path) = self.path.clone() { + self.busy = true; + return Task::perform( + async move { + tokio::task::spawn_blocking(move || { + source_files::recover_project(&path, LIMITS) + .map_err(|e| e.to_string())?; + source_files::load_project(&path, LIMITS).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? + }, + Message::SourceRecovered, + ); + } + } + Message::SourceRecovered(result) => { + self.busy = false; + match result { + Ok(disk) if !self.source_workspace.applied => self.open_source_project(disk), + Ok(disk) => { + self.disk_project = Some(disk); + self.source_diagnostics + .retain(|issue| issue.diagnostic.code != "pending_save"); + self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into(); + } + Err(error) => self.status = error, + } + } + Message::IncludesLoaded(version, result) => { + self.busy = false; + if version == self.source_version { + match result { + Ok(disk) => { + self.source_workspace + .discovered(&disk.bundle, disk.reachable.clone()); + self.source_version += 1; + self.source_diagnostics = disk.diagnostics.clone(); + self.disk_project = Some(disk); + self.status = "Included files loaded; existing drafts retained.".into(); + return self.request_lint(); + } + Err(error) => self.status = error, + } + } + } Message::Lint(version) => { if version == self.source_version { return self.request_lint(); @@ -1221,31 +1335,33 @@ impl App { self.lint_running = None; if version == self.source_version { match result { - Ok(diagnostics) => self.source_diagnostics = diagnostics, + Ok(mut diagnostics) => { + diagnostics.extend( + self.source_diagnostics + .iter() + .filter(|issue| issue.diagnostic.code == "pending_save") + .cloned(), + ); + self.source_diagnostics = diagnostics; + } Err(error) => self.status = format!("Source checks failed: {error}"), } } return self.start_pending_lint(); } Message::SourceDiagnostic(index) => { - if let Some(diagnostic) = self.source_diagnostics.get(index) { - let line = diagnostic.line.saturating_sub(1); - let column = self.source_editor.line(line).map_or(0, |text| { - text.text - .char_indices() - .nth(diagnostic.column.saturating_sub(1)) - .map_or(text.text.len(), |(offset, _)| offset) - }); - self.source_jump = Some(text_editor::Cursor { - position: text_editor::Position { line, column }, - selection: None, + if let Some(issue) = self.source_diagnostics.get(index) { + self.jump_to_source(SourceLocation { + path: issue.path.clone(), + span: issue.diagnostic.span, }); } } + Message::SourceRelated(location) => self.jump_to_source(location), Message::SourceViewport => { if let Some(cursor) = self.source_jump.take() { self.source_editor.move_to(cursor); - if cursor.selection.is_none() { + if self.source_select_line && cursor.selection.is_none() { self.source_editor.perform(text_editor::Action::SelectLine); } return iced::widget::operation::focus("source-editor"); @@ -1255,27 +1371,16 @@ impl App { self.apply_source(); } Message::RevertSource => { - self.sync_source(); - self.status = "Source draft reverted".into(); + self.source_workspace.revert_active(); + self.show_source_file(true); + self.source_version += 1; + self.status = "This file's draft reverted; other files retained.".into(); + return self.request_lint(); } Message::SourceUndo | Message::SourceRedo => { let redo = matches!(message, Message::SourceRedo); - let value = if redo { - self.source_future.pop() - } else { - self.source_past.pop() - }; - if let Some(value) = value { - let current = self.source_editor.text(); - if redo { - self.source_past.push(current); - } else { - self.source_future.push(current); - } - self.source_editor = text_editor::Content::with_text(&value); - self.source_navigation.refresh(&value); - self.source_jump = None; - self.source_snapshot = Arc::from(value); + if self.source_workspace.undo(redo) { + self.show_source_file(true); self.source_version += 1; self.source_diagnostics.clear(); return self.request_lint(); @@ -1284,27 +1389,16 @@ impl App { Message::SourceForSelection => { if self.apply() { self.page = Page::Text; - let alias = self - .project() - .identities() - .into_iter() - .find(|(id, _)| *id == self.selected) - .map(|(_, a)| a.to_string()); - if let Some(alias) = alias { - let source = self.source_editor.text(); - if let Some(declaration) = language::analyze(&source) - .declarations - .iter() - .find(|d| d.alias == alias) - { - let prefix = &source[..declaration.span.start.min(source.len())]; - let line = prefix.bytes().filter(|b| *b == b'\n').count(); - self.source_jump = Some(text_editor::Cursor { - position: text_editor::Position { line, column: 0 }, - selection: None, - }); - } - self.status = format!("Source declaration: @{alias}"); + if let Some(location) = self + .document + .compiled() + .index + .declarations + .get(&self.selected) + .cloned() + { + self.jump_to_source(location); + self.status = "Source declaration selected.".into(); } return self.request_lint(); } @@ -1341,7 +1435,10 @@ impl App { .source_navigation .destination(self.source_editor.line_count()) { - Ok(cursor) => self.source_jump = Some(cursor), + Ok(cursor) => { + self.source_jump = Some(cursor); + self.source_select_line = true; + } Err(error) => self.status = error, }, _ => {} @@ -1368,7 +1465,7 @@ impl App { return self.handle(Message::Save(false)); } Message::Save(save_as) => { - if !self.apply() { + if !self.apply_source() || !self.apply() { self.continue_after_save = false; self.pending = None; return Task::none(); @@ -1378,16 +1475,52 @@ impl App { } return self.choose_file(FileOperation::Save); } + Message::SavePath(path) => { + self.busy = false; + if let Some(path) = path { + return self.save_to(path); + } + self.continue_after_save = false; + self.status = "Save cancelled".into(); + } Message::Saved(result) => { self.busy = false; match result { - Ok(Some((path, project, bytes))) => { - self.file_snapshot = Some(bytes); - self.path = Some(path); - if self.project() == &project { - self.session.mark_saved(project); + Ok(Some(receipt)) => { + if receipt.document != self.canvas_document { + return Task::none(); + } + if let Some(disk) = receipt.disk { + if receipt.snapshot.bundle.entry != self.document.bundle().entry { + if receipt.revision != self.document.revision() { + self.status = format!( + "Snapshot saved to {}. Newer edits retain the current source location.", + receipt.path.display() + ); + self.continue_after_save = false; + return Task::none(); + } + if let Err(error) = + self.document.apply_bundle(receipt.snapshot.bundle.clone()) + { + self.status = error.to_string(); + self.continue_after_save = false; + return Task::none(); + } + self.sync_source(); + } + self.disk_project = Some(disk); + self.path = Some(receipt.path); + self.document.mark_saved(receipt.snapshot); + self.prune_closed_files(!self.document.dirty()); + self.status = "All source files saved".into(); + } else { + self.status = format!( + "JSON exported to {}. Source files and their save state are retained.", + receipt.path.display() + ); + self.continue_after_save = false; } - self.status = "Project saved".into(); if self.continue_after_save { self.continue_after_save = false; if let Some(p) = self.pending.take() { @@ -1408,15 +1541,20 @@ impl App { Message::Loaded(result) => { self.busy = false; match result { - Ok(Some((path, decoded, bytes))) => { - self.reset(decoded.project, Some(path)); + Ok(Some(LoadedSnapshot::Legacy(path, decoded, _bytes))) => { + self.reset(decoded.project, None); self.migration = decoded.migration; if self.migration.is_some() { self.status = "Imported earlier schema · review migration details in Model checks before saving".into(); self.page = Page::Validation; + } else { + self.status = format!( + "Imported {}. Save as an Arcadia source project to retain file structure and comments.", + path.display() + ); } - self.file_snapshot = Some(bytes); } + Ok(Some(LoadedSnapshot::Source(disk))) => self.open_source_project(disk), Ok(None) => self.status = "Open cancelled".into(), Err(e) => self.status = e, } @@ -1438,7 +1576,9 @@ impl App { return Task::none(); } let path = PathBuf::from(prompt.path.trim()); - if prompt.operation != FileOperation::Open && path.exists() && !prompt.overwrite + if prompt.operation == FileOperation::Export + && path.exists() + && !prompt.overwrite { prompt.overwrite = true; return Task::none(); @@ -1749,30 +1889,18 @@ impl App { Message::Loaded, ); } - let p = self.project().clone(); + let name = self.source_workspace.entry.clone(); Task::perform( async move { let file = rfd::AsyncFileDialog::new() .add_filter("Arcadia source", &["arcadia"]) .add_filter("Model JSON", &["json"]) - .set_file_name("system.arcadia") + .set_file_name(name) .save_file() .await; - match file { - None => Ok(None), - Some(file) => { - let path = file.path().to_path_buf(); - tokio::task::spawn_blocking(move || { - persistence::save_checked(&path, &p, None) - .map(|bytes| Some((path, p, Arc::from(bytes)))) - .map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? - } - } + file.map(|file| file.path().to_path_buf()) }, - Message::Saved, + Message::SavePath, ) } fn load_from(&mut self, path: PathBuf) -> Task { @@ -1787,19 +1915,15 @@ impl App { ) } fn save_to(&mut self, path: PathBuf) -> Task { - let expected = if self.path.as_ref() == Some(&path) { - self.file_snapshot.clone() - } else { - None - }; self.busy = true; - let p = self.project().clone(); + let snapshot = self.document.snapshot(); + let document = self.canvas_document; + let revision = self.document.revision(); + let previous = self.disk_project.clone(); Task::perform( async move { tokio::task::spawn_blocking(move || { - persistence::save_checked(&path, &p, expected.as_deref()) - .map(|bytes| Some((path, p, Arc::from(bytes)))) - .map_err(|e| e.to_string()) + write_snapshot(document, revision, snapshot, path, previous).map(Some) }) .await .map_err(|e| e.to_string())? @@ -1831,6 +1955,165 @@ impl App { self.model_issues.len() } + fn open_source_project(&mut self, disk: DiskProject) { + let path = disk.root.join(&disk.bundle.entry); + let candidate = if !disk + .diagnostics + .iter() + .any(|issue| issue.diagnostic.severity == oa_core::validation::Severity::Error) + { + Document::from_bundle(disk.reachable_bundle(), LIMITS) + } else { + Err(crate::document::DocumentError::Source( + disk.diagnostics.clone(), + )) + }; + match candidate { + Ok(document) => { + self.reset(document.project().clone(), Some(path)); + self.document = document; + self.source_workspace = Workspace::new(self.document.bundle(), true); + self.source_workspace + .discovered(&disk.bundle, disk.reachable.clone()); + self.show_source_file(false); + self.source_diagnostics = self.document.compiled().diagnostics.clone(); + self.status = + "Source project opened. Included files and comments are preserved.".into(); + } + Err(error) => { + self.canvas_document = Id::new_v4(); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.path = Some(path); + self.source_workspace = Workspace::new(&disk.bundle, false); + self.source_workspace.reachable = disk.reachable.clone(); + self.show_source_file(false); + self.source_diagnostics = error.diagnostics().to_vec(); + self.source_version += 1; + self.lint_requested = None; + self.page = Page::Text; + self.refresh_draft(); + self.status = "Source needs repair. Loaded drafts are retained; the graphical model is unchanged.".into(); + } + } + self.disk_project = Some(disk); + } + + fn prune_closed_files(&mut self, prune_expectations: bool) { + if let Some(disk) = &mut self.disk_project { + let retain = |path: &String| { + self.document.bundle().files.contains_key(path) + || self.source_workspace.files.contains_key(path) + }; + disk.bundle.files.retain(|path, _| retain(path)); + if prune_expectations { + disk.expectations.retain(|path, _| retain(path)); + disk.reachable.retain(retain); + } + } + } + + fn jump_to_source(&mut self, location: SourceLocation) { + self.source_workspace + .switch(&location.path, self.source_editor.cursor()); + if self.source_workspace.active != location.path { + self.status = format!( + "{} has not been loaded. Use Load includes or create the missing file.", + location.path + ); + return; + } + self.show_source_file(false); + let source = self.source_workspace.text(); + let mut offset = location.span.start.min(source.len()); + while !source.is_char_boundary(offset) { + offset -= 1; + } + let prefix = &source[..offset]; + let line = prefix.bytes().filter(|b| *b == b'\n').count(); + let column = prefix + .rfind('\n') + .map_or(offset, |start| offset - start - 1); + self.source_jump = Some(text_editor::Cursor { + position: text_editor::Position { line, column }, + selection: None, + }); + self.source_select_line = true; + } + + fn create_source_file(&mut self) -> Result<(), String> { + let path = self.source_workspace.new_path.trim().to_owned(); + if source_project::resolve_include("", &path).as_deref() != Ok(path.as_str()) { + return Err("Use a relative portable path such as sa/functions.arcadia.".into()); + } + if self.source_workspace.files.contains_key(&path) { + return Err("This file is already open.".into()); + } + if self.source_workspace.files.len() >= LIMITS.max_files { + return Err("The project file limit has been reached.".into()); + } + let bundle = self.source_workspace.bundle(); + let already_included = bundle.files.iter().any(|(p, t)| { + source_project::inspect_includes(p, t, p == &bundle.entry, LIMITS) + .is_ok_and(|includes| includes.iter().any(|include| include.target == path)) + }); + let mut changed = bundle.clone(); + if !already_included { + if self.source_dirty() { + return Err( + "Apply current drafts first, or add an include for the new file in the entry." + .into(), + ); + } + let at = self.document.compiled().index.project_body.span.end; + let entry = changed + .files + .get_mut(&changed.entry) + .ok_or("The entry file is missing.")?; + entry.insert_str( + at, + &format!( + "\n include {};\n", + serde_json::to_string(&path).map_err(|e| e.to_string())? + ), + ); + self.source_workspace + .switch(&bundle.entry, self.source_editor.cursor()); + self.source_workspace + .edit(entry.clone(), self.source_editor.cursor()); + } + changed.files.insert(path.clone(), String::new()); + self.source_workspace + .discovered(&changed, changed.files.keys().cloned().collect()); + self.source_workspace + .switch(&path, self.source_editor.cursor()); + self.source_workspace.new_path.clear(); + self.show_source_file(true); + self.source_version += 1; + self.status = "Source file created. Add declarations, then Apply all.".into(); + Ok(()) + } + + fn load_includes(&mut self) -> Task { + let Some(disk) = self.disk_project.clone() else { + self.status = "Save the source project first to establish its include directory. New files can be created here.".into(); + return Task::none(); + }; + let overlays = self.source_workspace.bundle().files; + let version = self.source_version; + self.busy = true; + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + source_files::discover_missing(&disk, &overlays, LIMITS) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? + }, + move |result| Message::IncludesLoaded(version, result), + ) + } + fn request_lint(&mut self) -> Task { self.lint_requested = Some(self.source_version); self.start_pending_lint() @@ -1847,10 +2130,10 @@ impl App { return Task::none(); } self.lint_running = Some(version); - let source = self.source_snapshot.clone(); + let source = self.source_workspace.bundle(); Task::perform( async move { - tokio::task::spawn_blocking(move || language::lint(&source)) + tokio::task::spawn_blocking(move || source_workspace::lint(&source)) .await .map_err(|error| error.to_string()) }, @@ -1861,7 +2144,7 @@ impl App { Some(crate::route_edit::Context { document: self.canvas_document, diagram: self.active_diagram?, - revision: self.session.revision(), + revision: self.document.revision(), epoch: self.canvas_epoch, }) } @@ -2083,10 +2366,384 @@ fn reveal_port_owner(p: &mut Project, diagram: Id, element: Id) { } } +fn write_snapshot( + document: Id, + revision: u64, + mut snapshot: Arc, + path: PathBuf, + previous: Option, +) -> Result { + let is_json = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")); + let disk = if is_json { + if path.exists() { + return Err( + "The JSON destination already exists. Choose a new file to preserve it.".into(), + ); + } + persistence::save_checked(&path, &snapshot.decoded.project, None) + .map_err(|e| e.to_string())?; + None + } else { + let entry = path + .file_name() + .and_then(|p| p.to_str()) + .ok_or("Choose a portable .arcadia entry file name.")?; + if source_project::resolve_include("", entry).as_deref() != Ok(entry) { + return Err("Choose a portable .arcadia entry file name.".into()); + } + if entry != snapshot.bundle.entry { + let mut bundle = snapshot.bundle.clone(); + if bundle.files.contains_key(entry) { + return Err("That entry name is already used by an included file.".into()); + } + let text = bundle + .files + .remove(&bundle.entry) + .ok_or("The accepted entry is missing.")?; + bundle.entry = entry.into(); + bundle.files.insert(entry.into(), text); + snapshot = Arc::new( + source_project::compile_bundle(&bundle, LIMITS) + .map_err(|errors| crate::document::DocumentError::Source(errors).to_string())?, + ); + } + let previous = previous.filter(|disk| { + path.parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(std::path::Path::new(".")) + .canonicalize() + .ok() + .as_ref() + == Some(&disk.root) + && entry == disk.bundle.entry + }); + let mut disk = match previous { + Some(disk) => disk, + None => source_files::new_project(&path, LIMITS).map_err(|e| e.to_string())?, + }; + for path in snapshot.bundle.files.keys() { + if !disk.expectations.contains_key(path) { + source_files::expect_new_file(&mut disk, path, LIMITS) + .map_err(|e| e.to_string())?; + } + } + Some(source_files::save_project(&disk, &snapshot, LIMITS).map_err(|e| e.to_string())?) + }; + Ok(FileSnapshot { + document, + revision, + snapshot, + disk, + path, + }) +} + fn read_snapshot(path: PathBuf) -> Result { - let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; - let decoded = persistence::decode_with_report(&bytes).map_err(|e| e.to_string())?; - Ok((path, decoded, Arc::from(bytes))) + use std::io::Read; + if !std::fs::symlink_metadata(&path) + .map_err(|e| e.to_string())? + .is_file() + { + return Err("Open a regular model file; links and special files are not supported.".into()); + } + let is_source = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")); + let mut bytes = Vec::new(); + std::fs::File::open(&path) + .map_err(|e| e.to_string())? + .take(LIMITS.model.max_source_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > LIMITS.model.max_source_bytes { + return Err("The model exceeds the source byte limit.".into()); + } + if !is_source { + let decoded = persistence::decode_with_report_limited(&bytes, LIMITS.model) + .map_err(|e| e.to_string())?; + return Ok(LoadedSnapshot::Legacy( + path, + Box::new(decoded), + Arc::from(bytes), + )); + } + if let Ok(decoded) = persistence::decode_with_report_limited(&bytes, LIMITS.model) + && decoded.migration.is_some() + { + return Ok(LoadedSnapshot::Legacy( + path, + Box::new(decoded), + Arc::from(bytes), + )); + } + source_files::load_project(&path, LIMITS) + .map(LoadedSnapshot::Source) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod source_project_tests { + use super::*; + + struct Directory(PathBuf); + impl Directory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("arcadia-source-ui-{}", Id::new_v4())); + std::fs::create_dir(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + } + impl Drop for Directory { + fn drop(&mut self) { + let temp = std::env::temp_dir().canonicalize().unwrap(); + assert_eq!(self.0.parent(), Some(temp.as_path())); + assert!( + self.0 + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("arcadia-source-ui-") + ); + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + fn send(app: &mut App, message: Message) { + let _ = app.update(message); + } + fn edit(app: &mut App, text: &str) { + send(app, Message::SourceEdit(text_editor::Action::SelectAll)); + send( + app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( + Arc::new(text.into()), + ))), + ); + } + fn save(app: &mut App, path: PathBuf) -> Result<(), String> { + let receipt = write_snapshot( + app.canvas_document, + app.document.revision(), + app.document.snapshot(), + path, + app.disk_project.clone(), + )?; + send(app, Message::Saved(Ok(Some(receipt)))); + Ok(()) + } + #[test] + fn two_file_apply_graphical_edit_exact_undo_save_as_and_reopen() { + let directory = Directory::new(); + let mut project = Project::empty(); + let activity = project + .add_element(Kind::Activity, Layer::Oa, "Assess incident", None) + .unwrap(); + let mut app = App::with_project(project); + let fragment = app.document.compiled().index.declarations[&activity] + .path + .clone(); + send(&mut app, Message::SourceFile(fragment.clone())); + let edited = format!( + "// Engineering rationale: preserve this comment.\n{}", + app.source_editor + .text() + .replace("Assess incident", "Review incident") + ); + edit(&mut app, &edited); + let entry = app.source_workspace.entry.clone(); + send(&mut app, Message::SourceFile(entry.clone())); + let entry_text = format!("{}\n// Project rationale.\n", app.source_editor.text()); + edit(&mut app, &entry_text); + assert!(app.apply_source(), "{}", app.status); + let accepted = app.document.snapshot(); + send( + &mut app, + Message::Command(Command::Rename(activity, "Evaluate incident".into())), + ); + assert_eq!(app.project().elements[&activity].name, "Evaluate incident"); + assert!(app.document.bundle().files[&fragment].starts_with("// Engineering rationale")); + send(&mut app, Message::Undo); + assert_eq!(app.document.bundle(), &accepted.bundle); + send(&mut app, Message::Redo); + let model = app.project().clone(); + let path = directory.0.join("study.arcadia"); + save(&mut app, path.clone()).unwrap(); + assert!(!app.dirty()); + assert_eq!(app.source_workspace.entry, "study.arcadia"); + assert!(!app.source_workspace.files.contains_key(&entry)); + let mut reopened = App::with_project(Project::empty()); + send( + &mut reopened, + Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))), + ); + assert_eq!( + reopened.project(), + &model, + "{}: {:?}", + reopened.status, + reopened.source_diagnostics + ); + assert_eq!(reopened.document.bundle(), app.document.bundle()); + assert!(!reopened.dirty()); + } + #[test] + fn inactive_invalid_draft_blocks_graphical_changes_and_diagnostic_switches_files() { + let mut project = Project::empty(); + let activity = project + .add_element(Kind::Activity, Layer::Oa, "Assess incident", None) + .unwrap(); + let mut app = App::with_project(project.clone()); + let fragment = app.document.compiled().index.declarations[&activity] + .path + .clone(); + send(&mut app, Message::SourceFile(fragment.clone())); + edit(&mut app, "? invalid source"); + let entry = app.source_workspace.entry.clone(); + send(&mut app, Message::SourceFile(entry)); + send(&mut app, Message::Page(Page::Diagram)); + send( + &mut app, + Message::Command(Command::Rename(activity, "Must not happen".into())), + ); + assert_eq!(app.project(), &project); + assert!(!app.apply_source()); + assert_eq!(app.source_diagnostics[0].path, fragment); + send(&mut app, Message::SourceDiagnostic(0)); + assert_eq!(app.source_workspace.active, fragment); + assert_eq!(app.source_editor.text(), "? invalid source"); + send(&mut app, Message::RevertSource); + assert!(!app.source_dirty()); + assert_eq!(app.project(), &project); + } + #[test] + fn missing_include_open_keeps_old_model_and_can_be_repaired_and_saved() { + let directory = Directory::new(); + let compiled = source_project::compile_bundle( + &source_project::format_bundle(&Project::empty()), + LIMITS, + ) + .unwrap(); + let mut entry = compiled.bundle.files[&compiled.bundle.entry].clone(); + entry.insert_str( + compiled.index.project_body.span.end, + "\n include \"missing.arcadia\";\n", + ); + let path = directory.0.join(&compiled.bundle.entry); + std::fs::write(&path, &entry).unwrap(); + let mut app = App::with_project(Project::demo()); + let original = app.project().clone(); + send( + &mut app, + Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))), + ); + assert_eq!(app.project(), &original); + assert!(!app.source_workspace.applied); + assert_eq!(app.source_editor.text(), entry); + send(&mut app, Message::SourceNewPath("missing.arcadia".into())); + send(&mut app, Message::SourceCreateFile); + assert!(app.apply_source(), "{}", app.status); + assert_eq!(app.project(), &compiled.decoded.project); + save(&mut app, path).unwrap(); + assert_eq!( + std::fs::read(directory.0.join("missing.arcadia")).unwrap(), + Vec::::new() + ); + assert!(!app.dirty()); + } + #[test] + fn closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh() { + let directory = Directory::new(); + let mut app = App::with_project(Project::empty()); + send(&mut app, Message::SourceNewPath("spare.arcadia".into())); + send(&mut app, Message::SourceCreateFile); + assert!(app.apply_source()); + let path = directory.0.join("project.arcadia"); + save(&mut app, path.clone()).unwrap(); + send(&mut app, Message::SourceFile("project.arcadia".into())); + let removed = app + .source_editor + .text() + .replace(" include \"spare.arcadia\";\n", ""); + edit(&mut app, &removed); + assert!(app.apply_source()); + send(&mut app, Message::SourceCloseFile("spare.arcadia".into())); + assert!(!app.source_workspace.files.contains_key("spare.arcadia")); + assert!( + app.disk_project + .as_ref() + .unwrap() + .expectations + .contains_key("spare.arcadia") + ); + std::fs::write(directory.0.join("spare.arcadia"), "// external edit\n").unwrap(); + assert!(save(&mut app, path.clone()).is_err()); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("include \"spare.arcadia\"") + ); + std::fs::write(directory.0.join("spare.arcadia"), "").unwrap(); + save(&mut app, path.clone()).unwrap(); + assert!( + !app.disk_project + .as_ref() + .unwrap() + .expectations + .contains_key("spare.arcadia") + ); + std::fs::write(directory.0.join("spare.arcadia"), "// new external text\n").unwrap(); + let id = app.project().id; + send( + &mut app, + Message::Command(Command::Rename(id, "New project name".into())), + ); + save(&mut app, path).unwrap(); + let mut entry = app.source_editor.text(); + entry.insert_str( + app.document.compiled().index.project_body.span.end, + "\n include \"spare.arcadia\";\n", + ); + edit(&mut app, &entry); + let disk = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(disk))); + assert_eq!( + app.source_workspace.files["spare.arcadia"].text, + "// new external text\n" + ); + assert!(app.apply_source()); + } + #[test] + fn json_export_and_rejected_save_as_preserve_source_location_history_and_bytes() { + let directory = Directory::new(); + let mut app = App::with_project(Project::empty()); + let id = app.project().id; + send( + &mut app, + Message::Command(Command::Rename(id, "Unsaved model".into())), + ); + let before = app.document.snapshot(); + let revision = app.document.revision(); + save(&mut app, directory.0.join("export.json")).unwrap(); + assert!(app.path.is_none()); + assert!(app.dirty()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + let path = directory.0.join("collision.arcadia"); + std::fs::write(&path, "Preserve existing file").unwrap(); + assert!(save(&mut app, path.clone()).is_err()); + assert_eq!( + std::fs::read_to_string(path).unwrap(), + "Preserve existing file" + ); + assert_eq!(app.document.revision(), revision); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + } } #[cfg(test)] @@ -2176,7 +2833,7 @@ mod lint_tests { assert_eq!(app.lint_running, Some(first)); assert_eq!(app.lint_requested, Some(latest)); - let errors = language::lint("?"); + let errors = source_workspace::lint(&app.source_workspace.bundle()); let _ = app.update(Message::Linted(first, Ok(errors.clone()))); assert!(app.source_diagnostics.is_empty()); assert_eq!(app.lint_running, Some(latest)); @@ -2196,7 +2853,10 @@ mod lint_tests { assert!(app.source_version > old_version); let _ = app.request_lint(); app.busy = true; - let _ = app.update(Message::Linted(old_version, Ok(language::lint("?")))); + let _ = app.update(Message::Linted( + old_version, + Ok(source_workspace::lint(&app.source_workspace.bundle())), + )); assert!(app.source_diagnostics.is_empty()); assert_eq!(app.lint_running, Some(app.source_version)); let _ = app.update(Message::Linted(app.source_version, Ok(vec![]))); diff --git a/crates/oa-desktop/src/document.rs b/crates/oa-desktop/src/document.rs new file mode 100644 index 00000000..79ba4719 --- /dev/null +++ b/crates/oa-desktop/src/document.rs @@ -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, + 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), +} + +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 for DocumentError { + fn from(error: ModelError) -> Self { + Self::Model(error) + } +} + +impl From> for DocumentError { + fn from(diagnostics: Vec) -> Self { + Self::Source(diagnostics) + } +} + +pub struct Document { + current: Arc, + past: Vec, + future: Vec, + current_bytes: usize, + saved: Arc, + 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 { + 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 { + 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 { + 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) { + self.saved = Arc::new(snapshot.bundle.clone()); + } + + pub fn execute(&mut self, command: Command) -> Result, 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 { + 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 { + 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::(); + 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)); + } +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 3fc2db17..b3b1c4ce 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -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; diff --git a/crates/oa-desktop/src/source_workspace.rs b/crates/oa-desktop/src/source_workspace.rs new file mode 100644 index 00000000..b692cbbf --- /dev/null +++ b/crates/oa-desktop/src/source_workspace.rs @@ -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, + future: Vec, +} + +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, + pub reachable: BTreeSet, + 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::(), + ) + }) + }) + .collect(); + if histories.iter().map(|(_, _, bytes)| bytes).sum::() <= 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) { + 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> { + 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> { + source_project::compile_bundle(&reachable(bundle)?, LIMITS) +} + +pub fn lint(bundle: &SourceBundle) -> Vec { + 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::()) + .sum::() + <= 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")); + } +} diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index d0b5ea40..e187ad5d 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -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); diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 2d0c5898..f221114f 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -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() diff --git a/harness/STATE.md b/harness/STATE.md index 8e43dda2..4d7333d3 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -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. diff --git a/harness/TASK_LEDGER.md b/harness/TASK_LEDGER.md index 060b7aa4..9439d84e 100644 --- a/harness/TASK_LEDGER.md +++ b/harness/TASK_LEDGER.md @@ -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. diff --git a/harness/release-source-integration.md b/harness/release-source-integration.md index 1ed28dc5..c75ff474 100644 --- a/harness/release-source-integration.md +++ b/harness/release-source-integration.md @@ -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