diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 55c33212..8b1edac3 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -10,6 +10,7 @@ mod hosted; mod reference_authoring; #[cfg(test)] mod review_navigation_tests; +pub(crate) mod source_rename; mod source_symbols; #[cfg(test)] mod transition_context_tests; @@ -436,6 +437,8 @@ pub enum Message { crate::source_navigation::SymbolEvent, ), SourceSymbolsReady(crate::source_navigation::Completion), + SourceRename(source_rename::Event), + SourceRenameReady(source_rename::Completion), DiagramUsage(crate::diagram_usage::Event), DiagramUsagesReady(crate::diagram_usage::Completion), DiagramRevealViewport(crate::diagram_usage::Context, u64, iced::Size), @@ -521,6 +524,7 @@ pub struct App { pub source_navigation: crate::source_navigation::State, source_find_instance: Id, pub source_symbols: crate::source_navigation::SymbolState, + pub source_rename: source_rename::State, pub(crate) diagram_usages: Option, pub(crate) diagram_navigation_focus: Option, source_diagram_job: Option, @@ -640,6 +644,7 @@ impl App { source_navigation: crate::source_navigation::State::default(), source_find_instance: Id::new_v4(), source_symbols: crate::source_navigation::SymbolState::default(), + source_rename: source_rename::State::default(), diagram_usages: None, diagram_navigation_focus: None, source_diagram_job: None, @@ -691,19 +696,37 @@ impl App { || self.behavior.has_pending() } fn sync_source(&mut self) { + self.sync_source_with_alias_editor(None); + } + fn sync_source_with_alias_editor(&mut self, hint: Option>) { self.source_workspace.accept(self.document.bundle()); - self.show_source_file(false); + self.show_source_file_with_alias_editor(false, hint); 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.show_source_file_with_alias_editor(restore_cursor, None); + } + fn show_source_file_with_alias_editor( + &mut self, + restore_cursor: bool, + hint: Option>, + ) { self.source_symbols.reveal = None; self.source_tree.reveal(&self.source_workspace.active); 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); + if !hint.is_some_and(|hint| { + hint.apply( + &mut self.source_editor, + &self.source_workspace.active, + &source, + ) + }) { + self.source_editor = text_editor::Content::with_text(&source); + } self.source_snapshot = Arc::from(source); self.source_jump = restore_cursor .then(|| { @@ -748,6 +771,9 @@ impl App { } } fn sync(&mut self) { + self.sync_with_alias_editor(None); + } + fn sync_with_alias_editor(&mut self, hint: Option>) { self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.model_issues = validate(self.project()); self.normalize_creation(); @@ -777,7 +803,7 @@ impl App { .map(|d| d.id); } self.refresh_draft(); - self.sync_source(); + self.sync_source_with_alias_editor(hint); } fn normalize_creation(&mut self) { if !self.create_kind.supports(self.layer) { @@ -970,6 +996,7 @@ impl App { let source_version = self.source_version + 1; let lint_running = self.lint_running; let navigation_in_flight = self.source_symbols.in_flight; + let rename_in_flight = self.source_rename.in_flight; let diagram_lookup_in_flight = self.diagram_lookup_in_flight; let guide = std::mem::take(&mut self.guide); let mut hosted = std::mem::take(&mut self.hosted); @@ -983,6 +1010,7 @@ impl App { self.source_version = source_version; self.lint_running = lint_running; self.source_symbols.in_flight = navigation_in_flight; + self.source_rename.in_flight = rename_in_flight; self.diagram_lookup_in_flight = diagram_lookup_in_flight; self.guide = guide; self.path = path; @@ -1146,7 +1174,9 @@ impl App { || self.diagram_usages.is_some() || self.behavior.source_active() || (self.page == Page::Text - && (self.source_symbols.open || self.source_navigation.open) + && (self.source_symbols.open + || self.source_navigation.open + || self.source_rename.open()) && self.architecture.is_none()), ) .map(|()| Message::Escape), @@ -1194,6 +1224,13 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + // Reject stale rename intents before camera/reveal reconciliation or + // source interaction can disturb a newer navigation destination. + if let Message::SourceRename(event) = &message + && !self.source_rename_envelope(event) + { + return Task::none(); + } let usage_viewport = self.diagram_reveal_viewport(&message); let window_resized = matches!(&message, Message::WindowResized(_)); // Reject stale measurements before legacy viewport handlers can change @@ -1346,6 +1383,9 @@ impl App { if let Message::SourceSymbolsReady(completion) = message { return self.source_symbols_ready(completion); } + if let Message::SourceRenameReady(completion) = message { + return self.source_rename_ready(completion); + } if let Message::DiagramUsagesReady(completion) = message { return self.diagram_usages_ready(completion); } @@ -1567,6 +1607,18 @@ impl App { { return Task::none(); } + if self.source_rename.open() { + if matches!( + message, + Message::Escape | Message::Request(Pending::Close(_)) + ) { + self.close_source_rename(); + return Task::none(); + } + if !modal_event && !matches!(message, Message::SourceRename(_)) { + return Task::none(); + } + } if matches!( message, Message::Page(_) | Message::Layer(_) | Message::Diagram(_) @@ -1611,6 +1663,7 @@ impl App { self.last_workspace_page = self.page; } let mut task = self.handle(message); + self.refresh_source_rename_context(); self.reconcile_reference_chooser(); self.route_state_diagram_workspace(); if self.page != Page::Home { @@ -3210,6 +3263,7 @@ impl App { if self.pending_recovery.as_ref() != Some(&receipt.target) { return Task::none(); } + self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again."); self.pending_recovery = None; self.source_diagnostics .retain(|issue| issue.diagnostic.code != "pending_save"); @@ -3297,6 +3351,8 @@ impl App { Message::SourceViewport(ticket) => return self.source_viewport(ticket), Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it Message::SourceSymbols(_, event) => return self.source_symbol_event(event), + Message::SourceRename(event) => return self.source_rename_event(event), + Message::SourceRenameReady(completion) => return self.source_rename_ready(completion), Message::SourceSymbolsReady(completion) => { return self.source_symbols_ready(completion); } diff --git a/crates/oa-desktop/src/app/diagram_reveal.rs b/crates/oa-desktop/src/app/diagram_reveal.rs index b28d857f..ab4dd765 100644 --- a/crates/oa-desktop/src/app/diagram_reveal.rs +++ b/crates/oa-desktop/src/app/diagram_reveal.rs @@ -85,6 +85,7 @@ impl App { | Message::DiagramUsage(_) | Message::DiagramUsagesReady(_) | Message::SourceSymbolsReady(_) + | Message::SourceRenameReady(_) | Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..)) | Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..)) | Message::StateDiagram(behavior::state_edit::Event::Viewport(..)) diff --git a/crates/oa-desktop/src/app/diagram_usages.rs b/crates/oa-desktop/src/app/diagram_usages.rs index 3db67d1b..fb17862f 100644 --- a/crates/oa-desktop/src/app/diagram_usages.rs +++ b/crates/oa-desktop/src/app/diagram_usages.rs @@ -15,6 +15,7 @@ impl App { fn usage_navigation_available(&self) -> bool { !self.busy + && !self.source_rename.open() && self.pending.is_none() && self.file_prompt.is_none() && !self.delete_pending diff --git a/crates/oa-desktop/src/app/source_rename.rs b/crates/oa-desktop/src/app/source_rename.rs new file mode 100644 index 00000000..7bcbaa0a --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename.rs @@ -0,0 +1,1226 @@ +//! Editor-local, explicitly approved alias transactions. Workers own immutable +//! snapshots; only the live form can approve adoption into the current document. +use super::*; +use crate::{ + document::PreparedAliasRename, + source_navigation::{self as nav, query}, + source_rename::{origin, preview}, +}; +mod alias_input; +mod editor_patch; +const LINES_PER_PAGE: usize = 8; + +// A transient display hint from the same approved candidate. Document authority, +// model synchronization and source ownership remain with their existing paths. +pub(super) struct EditorRefresh<'a> { + path: &'a str, + before: &'a str, + after: &'a str, +} + +impl EditorRefresh<'_> { + pub(super) fn apply(self, editor: &mut text_editor::Content, path: &str, source: &str) -> bool { + self.path == path + && self.after == source + && editor_patch::replace(editor, self.before, self.after) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Begin { + pub document: Id, + pub revision: u64, + pub source: query::SnapshotKey, + pub path: String, + pub cursor: text_editor::Cursor, +} + +#[derive(Debug, Clone)] +pub enum Event { + Begin(Begin), + Input(Id, String), + PasteRejected(Id), + Preview(Id, Id), + Confirm(Id, Id, Id), + Close(Id), + Search(Id, String), + File(Id, Id, bool), + Hunk(Id, Id, bool), + Line(Id, Id, bool), + Segment(Id, Id, bool), + Details(Id, bool), + DetailSegment(Id, bool), + ReadOnly(Id, text_editor::Action), + Undo(Id), +} + +#[derive(Debug, Clone)] +struct Context { + begin: Begin, + accepted: Arc, +} + +#[derive(Debug, Clone)] +struct Job { + id: Id, + invocation: Id, + generation: Id, + context: Context, +} + +#[derive(Debug)] +struct Target { + id: Id, + alias: String, + caption: String, + origin: query::Origin, +} + +#[derive(Debug)] +struct Approval { + id: Id, + prepared: PreparedAliasRename, + preview: preview::Preview, + cursor: text_editor::Cursor, +} + +#[derive(Debug, Clone)] +enum Payload { + Target(Result, String>), + Preview(Result, String>), +} + +#[derive(Debug, Clone)] +pub struct Completion { + job: Job, + payload: Payload, +} + +struct Form { + invocation: Id, + generation: Id, + context: Context, + target: Option>, + replacement: String, + notice: String, + stale: bool, + pending: Option, + approval: Option>, + search: String, + file: usize, + hunk: usize, + line: usize, + segment: usize, + preview_destination: Id, + details: bool, + detail_segment: usize, + detail_editor: text_editor::Content, +} + +#[derive(Debug)] +struct Undo { + id: Id, + context: Context, +} + +#[derive(Default)] +pub struct State { + form: Option
, + pub(super) in_flight: Option, + undo: Option, +} + +impl State { + pub fn open(&self) -> bool { + self.form.is_some() + } +} + +impl App { + pub fn source_rename_begin(&self) -> Begin { + Begin { + document: self.canvas_document, + revision: self.document.revision(), + source: self.source_key(), + path: self.source_workspace.active.clone(), + cursor: self.source_editor.cursor(), + } + } + + pub fn source_rename_message(&self) -> Message { + Message::SourceRename(Event::Begin(self.source_rename_begin())) + } + + // Called before source_interaction or any form mutation. In particular an + // old Close/Confirm cannot disturb a newer form with the same source path. + pub(super) fn source_rename_envelope(&self, event: &Event) -> bool { + if let Event::Begin(begin) = event { + return self.page == Page::Text + && !self.source_rename.open() + && *begin == self.source_rename_begin(); + } + if let Event::Undo(id) = event { + return self.source_rename.undo.as_ref().is_some_and(|undo| { + undo.id == *id + && !self.source_rename.open() + && self.rename_undo_matches(&undo.context) + }); + } + let Some(form) = &self.source_rename.form else { + return false; + }; + match event { + Event::Preview(invocation, generation) => { + *invocation == form.invocation && *generation == form.generation + } + Event::Confirm(invocation, generation, preview) => { + *invocation == form.invocation + && *generation == form.generation + && form + .approval + .as_ref() + .is_some_and(|ready| ready.id == *preview) + } + Event::File(invocation, preview, _) + | Event::Hunk(invocation, preview, _) + | Event::Line(invocation, preview, _) + | Event::Segment(invocation, preview, _) => { + *invocation == form.invocation + && form + .approval + .as_ref() + .is_some_and(|ready| ready.id == *preview) + } + Event::Input(invocation, _) + | Event::PasteRejected(invocation) + | Event::Close(invocation) + | Event::Search(invocation, _) + | Event::Details(invocation, _) + | Event::DetailSegment(invocation, _) + | Event::ReadOnly(invocation, _) => *invocation == form.invocation, + Event::Begin(_) | Event::Undo(_) => false, + } + } + + fn rename_admission(&self) -> Result<(), String> { + if self.page != Page::Text + || self.busy + || self.pending.is_some() + || self.file_prompt.is_some() + || self.delete_pending + || self.workflow.is_some() + || self.hosted.visible + || self.model_menu.is_some() + || self.architecture.is_some() + || self.diagram_usages.is_some() + || self.reference_input_active() + || self.behavior.has_pending() + || self.draft_dirty() + { + return Err("Finish or cancel the current property, behavior or file operation before renaming. No drafts were applied.".into()); + } + let workspace = &self.source_workspace; + let accepted = self.document.bundle(); + if !workspace.applied || workspace.has_edits() { + return Err( + "Apply or revert source drafts before renaming an alias. Your drafts are retained." + .into(), + ); + } + if workspace.entry != accepted.entry + || workspace.files.len() != accepted.files.len() + || !workspace + .files + .iter() + .zip(&accepted.files) + .all(|((path, buffer), (other, text))| path == other && buffer.text == *text) + || workspace.reachable.len() != accepted.files.len() + || !workspace.reachable.iter().eq(accepted.files.keys()) + || self.source_editor.text() != workspace.text() + { + return Err("Rename requires exactly the accepted included files. Close a clean detached buffer, or reattach/revert its draft first.".into()); + } + Ok(()) + } + + fn rename_context(&self) -> Context { + Context { + begin: self.source_rename_begin(), + accepted: self.document.snapshot(), + } + } + + fn rename_context_matches(&self, context: &Context) -> bool { + context.begin == self.source_rename_begin() + && Arc::ptr_eq(&context.accepted, &self.document.snapshot()) + && self.rename_admission().is_ok() + } + + fn rename_undo_matches(&self, context: &Context) -> bool { + let mut current = self.source_rename_begin(); + current.cursor = context.begin.cursor; + context.begin == current + && Arc::ptr_eq(&context.accepted, &self.document.snapshot()) + && self.rename_admission().is_ok() + } + + pub(super) fn invalidate_source_rename(&mut self, message: &str) { + if let Some(form) = &mut self.source_rename.form { + form.stale = true; + form.pending = None; + form.approval = None; + form.notice = message.into(); + } + self.source_rename.undo = None; + } + + pub(super) fn refresh_source_rename_context(&mut self) { + let stale = self + .source_rename + .form + .as_ref() + .is_some_and(|form| !form.stale && !self.rename_context_matches(&form.context)); + if stale { + self.invalidate_source_rename("Source context changed. Your replacement is retained; close and invoke Rename again on the current source."); + } + if self + .source_rename + .undo + .as_ref() + .is_some_and(|undo| !self.rename_undo_matches(&undo.context)) + { + self.source_rename.undo = None; + } + if let Some(form) = &mut self.source_rename.form + && form.details + { + refresh_details(form); + } + } + + pub(super) fn close_source_rename(&mut self) { + if let Some(form) = self.source_rename.form.take() { + // Do not release the occupied worker. Its matching completion does. + if self.rename_context_matches(&form.context) { + self.queue_source_reveal(form.context.begin.cursor, false); + } + } + } + + pub fn source_rename_undo_message(&self) -> Option { + self.source_rename + .undo + .as_ref() + .filter(|undo| !self.source_rename.open() && self.rename_undo_matches(&undo.context)) + .map(|undo| Message::SourceRename(Event::Undo(undo.id))) + } + + pub(super) fn source_rename_event(&mut self, event: Event) -> Task { + let mut reset_preview_scroll = false; + match event { + Event::Begin(_) => return self.begin_source_rename(), + Event::Close(_) => self.close_source_rename(), + Event::Input(_, value) => { + let Some(form) = &mut self.source_rename.form else { + return Task::none(); + }; + if form.replacement != value { + form.replacement = value; + form.generation = Id::new_v4(); + form.approval = None; + form.pending = None; + if !form.stale { + form.notice = replacement_notice(form.target.as_deref(), &form.replacement) + .unwrap_or_else(|| { + "Choose Preview to review the complete change.".into() + }); + } + } + } + Event::PasteRejected(_) => { + let form = self.source_rename.form.as_mut().unwrap(); + form.generation = Id::new_v4(); + form.approval = None; + form.pending = None; + form.notice = "Paste rejected: aliases cannot contain line breaks, tabs or other control characters. The field is unchanged; paste a single-line alias and choose Preview.".into(); + if form.stale { + form.notice + .push_str(" The source context changed; close and invoke Rename again."); + } + } + Event::Preview(_, _) => return self.preview_source_rename(), + Event::Confirm(_, _, _) => self.confirm_source_rename(), + Event::Undo(_) => { + let Some(undo) = self.source_rename.undo.take() else { + return Task::none(); + }; + if !self.source_rename.open() + && self.rename_undo_matches(&undo.context) + && self.document.undo() + { + self.sync(); + self.status = + "Alias rename undone as one model change. Redo model restores it.".into(); + } + } + Event::Search(_, value) => { + let form = self.source_rename.form.as_mut().unwrap(); + if form.search == value { + return Task::none(); + } + form.search = value; + reset_preview_scroll = form.approval.is_some(); + if let Some(approval) = &form.approval + && let Some(index) = approval + .preview + .files() + .iter() + .position(|file| file.path().contains(&form.search)) + { + form.file = index; + form.hunk = 0; + form.line = 0; + form.segment = 0; + } + } + Event::File(_, _, next) + | Event::Hunk(_, _, next) + | Event::Line(_, _, next) + | Event::Segment(_, _, next) => { + let form = self.source_rename.form.as_mut().unwrap(); + let ready = form.approval.as_ref().unwrap(); + let previous_page = (form.file, form.hunk, form.line, form.segment); + match event { + Event::File(..) => { + let matching: Vec<_> = ready + .preview + .files() + .iter() + .enumerate() + .filter_map(|(i, file)| file.path().contains(&form.search).then_some(i)) + .collect(); + if let Some(position) = matching.iter().position(|i| *i == form.file) { + form.file = matching[step(position, matching.len(), next)]; + } + form.hunk = 0; + form.line = 0; + form.segment = 0; + } + Event::Hunk(..) => { + form.hunk = step( + form.hunk, + ready.preview.files()[form.file].hunks().len(), + next, + ); + form.line = 0; + form.segment = 0; + } + Event::Line(..) => { + let count = ready.preview.files()[form.file].hunks()[form.hunk] + .lines() + .len() + .div_ceil(LINES_PER_PAGE); + form.line = step(form.line / LINES_PER_PAGE, count, next) * LINES_PER_PAGE; + form.segment = 0; + } + _ => form.segment = step(form.segment, segment_total(form), next), + } + reset_preview_scroll = + previous_page != (form.file, form.hunk, form.line, form.segment); + } + Event::Details(_, value) => { + let form = self.source_rename.form.as_mut().unwrap(); + if form.details == value { + return Task::none(); + } + form.details = value; + // Leaving or returning to the preview ends the old visit. Keep + // its selected page and retained scroll; only retire delayed + // operations, without scheduling another return-to-top. + form.preview_destination = Id::new_v4(); + form.detail_segment = 0; + refresh_details(form); + } + Event::DetailSegment(_, next) => { + let form = self.source_rename.form.as_mut().unwrap(); + form.detail_segment = step( + form.detail_segment, + preview::segment_count(&details(form)), + next, + ); + refresh_details(form); + } + Event::ReadOnly(_, action) => { + if !action.is_edit() { + self.source_rename + .form + .as_mut() + .unwrap() + .detail_editor + .perform(action); + } + } + } + if reset_preview_scroll && let Some(form) = &mut self.source_rename.form { + return reset_preview_position(form); + } + Task::none() + } + + fn begin_source_rename(&mut self) -> Task { + if let Err(error) = self.rename_admission() { + self.status = error; + return Task::none(); + } + if self.source_rename.in_flight.is_some() { + self.status = + "The previous rename task is finishing. Try Rename again when it completes.".into(); + return Task::none(); + } + let context = self.rename_context(); + let Some(offset) = nav::cursor_offset(self.source_workspace.text(), context.begin.cursor) + else { + self.status = "Place the cursor on a source alias or its @ reference.".into(); + return Task::none(); + }; + let input = match self + .with_source_input(|input| query::admit_owned(input, query::Limits::default())) + { + Ok(input) => input, + Err(error) => { + self.status = format!("Rename source admission failed: {error:?}"); + return Task::none(); + } + }; + let job = Job { + id: Id::new_v4(), + invocation: Id::new_v4(), + generation: Id::new_v4(), + context, + }; + self.source_rename.undo = None; + self.source_rename.in_flight = Some(job.id); + self.source_rename.form = Some(Form { + invocation: job.invocation, + generation: job.generation, + context: job.context.clone(), + target: None, + replacement: String::new(), + notice: "Checking the selected source alias…".into(), + stale: false, + pending: Some(job.id), + approval: None, + search: String::new(), + file: 0, + hunk: 0, + line: 0, + segment: 0, + preview_destination: Id::new_v4(), + details: false, + detail_segment: 0, + detail_editor: text_editor::Content::new(), + }); + Task::perform( + async move { + let worker = job.clone(); + let result = tokio::task::spawn_blocking(move || { + let snapshot = input + .capture() + .map_err(|error| format!("Rename source admission failed: {error:?}"))?; + resolve_target(&snapshot, &worker.context, offset).map(Arc::new) + }) + .await + .unwrap_or_else(|error| Err(format!("Rename target task failed: {error}"))); + Completion { + job, + payload: Payload::Target(result), + } + }, + Message::SourceRenameReady, + ) + } + + fn preview_source_rename(&mut self) -> Task { + let Some(form) = &self.source_rename.form else { + return Task::none(); + }; + if form.stale || !self.rename_context_matches(&form.context) { + self.invalidate_source_rename("Source context changed. Close and invoke Rename again; your replacement is retained."); + return Task::none(); + } + if self.source_rename.in_flight.is_some() { + return Task::none(); + } + let Some(target) = form.target.clone() else { + return Task::none(); + }; + if let Some(notice) = replacement_notice(Some(&target), &form.replacement) { + self.source_rename.form.as_mut().unwrap().notice = notice; + return Task::none(); + } + let request = match self + .document + .alias_rename_request(target.id, form.replacement.clone()) + { + Ok(request) => request, + Err(error) => { + self.source_rename.form.as_mut().unwrap().notice = error.to_string(); + return Task::none(); + } + }; + let job = Job { + id: Id::new_v4(), + invocation: form.invocation, + generation: form.generation, + context: form.context.clone(), + }; + self.source_rename.in_flight = Some(job.id); + let form = self.source_rename.form.as_mut().unwrap(); + form.pending = Some(job.id); + form.approval = None; + form.notice = "Preparing the complete rename preview…".into(); + Task::perform( + async move { + let path = job.context.begin.path.clone(); + let result = tokio::task::spawn_blocking(move || { + let prepared = request.prepare().map_err(|error| error.to_string())?; + prepare_approval(prepared, &target, &path).map(Arc::new) + }) + .await + .unwrap_or_else(|error| Err(format!("Rename preview task failed: {error}"))); + Completion { + job, + payload: Payload::Preview(result), + } + }, + Message::SourceRenameReady, + ) + } + + pub(super) fn source_rename_ready(&mut self, completion: Completion) -> Task { + if self.source_rename.in_flight != Some(completion.job.id) { + return Task::none(); + } + self.source_rename.in_flight = None; + let job = &completion.job; + let wanted = self.source_rename.form.as_ref().is_some_and(|form| { + form.invocation == job.invocation + && form.generation == job.generation + && form.pending == Some(job.id) + && !form.stale + }) && self.rename_context_matches(&job.context); + if !wanted { + self.refresh_source_rename_context(); + return Task::none(); + } + let form = self.source_rename.form.as_mut().unwrap(); + form.pending = None; + let mut reset_preview_scroll = false; + match completion.payload { + Payload::Target(Ok(target)) => { + form.replacement = target.alias.clone(); + form.target = Some(target); + form.notice = "No change. Enter a different source alias, then Preview.".into(); + if form.details { + refresh_details(form); + return Task::none(); + } + return nav::interaction::guarded_focus( + input_id(form.invocation), + self.source_symbols.raw_interaction.clone(), + self.source_symbols + .raw_interaction + .load(std::sync::atomic::Ordering::SeqCst), + ); + } + Payload::Preview(Ok(ready)) => { + if !ready.prepared.changed() { + form.notice = "No change. Nothing was applied.".into(); + } else { + form.notice = "Review every changed file. Rename alias adopts this preview as one model change; Save remains separate.".into(); + form.approval = Some(ready); + form.file = 0; + form.hunk = 0; + form.line = 0; + form.segment = 0; + form.search.clear(); + reset_preview_scroll = true; + } + } + Payload::Target(Err(error)) | Payload::Preview(Err(error)) => form.notice = error, + } + if form.details { + refresh_details(form); + } + if reset_preview_scroll { + return reset_preview_position(form); + } + Task::none() + } + + fn confirm_source_rename(&mut self) { + let Some(form) = &self.source_rename.form else { + return; + }; + if form.stale || !self.rename_context_matches(&form.context) { + self.invalidate_source_rename( + "This preview is stale. Close and invoke Rename on the current source.", + ); + return; + } + let Some(target) = &form.target else { return }; + let Some(approval) = &form.approval else { + return; + }; + if approval.prepared.target() != target.id + || approval.prepared.old_alias() != target.alias + || approval.prepared.new_alias() != form.replacement + || approval.prepared.revision() != form.context.begin.revision + { + return; + } + let approval = self + .source_rename + .form + .as_mut() + .unwrap() + .approval + .take() + .unwrap(); + match self.document.commit_alias_rename(&approval.prepared) { + Ok(true) => { + let path = self.source_workspace.active.clone(); + let hint = approval + .prepared + .before() + .bundle + .files + .get(&path) + .zip(approval.prepared.after().bundle.files.get(&path)) + .map(|(before, after)| EditorRefresh { + path: &path, + before, + after, + }); + self.source_rename.form = None; + self.sync_with_alias_editor(hint); + self.source_editor.move_to(approval.cursor); + self.queue_source_reveal(approval.cursor, false); + self.source_rename.undo = Some(Undo { + id: Id::new_v4(), + context: self.rename_context(), + }); + self.status = "Alias renamed. Engineering name unchanged. Undo rename / Undo model restores the whole change; Save writes accepted changes.".into(); + } + Ok(false) => { + self.source_rename.form.as_mut().unwrap().notice = + "No change. Nothing was applied.".into() + } + Err(error) => self.source_rename.form.as_mut().unwrap().notice = error.to_string(), + } + } +} + +fn replacement_notice(target: Option<&Target>, value: &str) -> Option { + let target = target?; + if value == target.alias { + Some("No change. Enter a different source alias.".into()) + } else if !valid_alias(value) { + Some("Use a letter or underscore, then ASCII letters, digits or underscores; reserved aliases are unavailable. Input is not trimmed.".into()) + } else { + None + } +} + +fn resolve_target( + snapshot: &query::Snapshot, + context: &Context, + offset: usize, +) -> Result { + let result = snapshot + .query(query::Query { + stamp: query::QueryStamp { + snapshot: context.begin.source, + serial: 0, + }, + path: &context.begin.path, + byte_offset: offset, + action: query::Action::Definition, + }) + .map_err(|error| format!("Cannot resolve this alias: {error:?}"))?; + let scope = &result.scope; + if !scope.closure_complete + || !scope.applied + || !scope.validated + || scope.detached_files != 0 + || scope.unverified_files != 0 + || result.total_definitions != 1 + || result.definitions.len() != 1 + || result.omitted_definitions != 0 + { + return Err("Rename needs one complete, validated definition in the included source. Resolve ambiguous or incomplete source first.".into()); + } + let row = &result.definitions[0]; + let query::Identity::Unique(anchor) = row.declaration.identity else { + return Err("Rename requires one unique authored UUID for this alias.".into()); + }; + let id = anchor.id(); + let location = context + .accepted + .index + .declarations + .get(&id) + .ok_or("The authored declaration does not belong to the accepted model.")?; + let accepted_alias = context + .accepted + .decoded + .project + .identities() + .into_iter() + .find_map(|(key, alias)| (key == id).then_some(alias)); + if row.membership != query::Membership::Included + || row.evidence != query::Evidence::Validated + || row.declaration.authored_id != Some(id) + || accepted_alias != Some(row.declaration.alias.as_str()) + || location.path != row.path + || row.span.start < location.span.start + || row.span.end > location.span.end + { + return Err("The source alias does not exactly match its accepted declaration.".into()); + } + let origin = result + .origin + .ok_or("Place the cursor on an alias or its @ reference.")?; + let source = context + .accepted + .bundle + .files + .get(&context.begin.path) + .ok_or("Origin file is unavailable.")?; + if !nav::exact_cursor(source, context.begin.cursor) { + return Err("The source selection changed.".into()); + } + if let Some(selection) = context.begin.cursor.selection { + let a = + nav::byte_offset(source, context.begin.cursor.position).ok_or("Invalid selection.")?; + let b = nav::byte_offset(source, selection).ok_or("Invalid selection.")?; + if a != b { + let start = a.min(b); + let end = a.max(b); + let alias_only = start >= origin.span.start && end <= origin.span.end; + let reference = origin + .marker + .is_some_and(|marker| start == marker.start && end == origin.span.end); + if !alias_only && !reference { + return Err("Select only the source alias or its complete @ reference.".into()); + } + } + } + let project = &context.accepted.decoded.project; + let placement = project + .diagrams + .values() + .find_map(|diagram| diagram.nodes.values().find(|node| node.id == id)); + let caption = if let Some(node) = placement { + format!("Placement of {}", project.named(node.element)) + } else { + format!( + "{} · {}", + row.declaration.kind, + row.declaration.name.as_deref().unwrap_or("Unnamed") + ) + }; + Ok(Target { + id, + alias: row.declaration.alias.clone(), + caption, + origin, + }) +} + +fn prepare_approval( + prepared: PreparedAliasRename, + target: &Target, + path: &str, +) -> Result { + let preview = preview::Preview::build(&prepared.before().bundle, &prepared.after().bundle) + .map_err(|error| error.to_string())?; + let old = &prepared.before().bundle.files[path]; + let new = &prepared.after().bundle.files[path]; + let span = origin::map( + old, + new, + prepared.old_alias(), + prepared.new_alias(), + target.origin.span, + target.origin.marker, + ) + .map_err(|error| error.to_string())?; + let position = |offset| { + let prefix = &new[..offset]; + text_editor::Position { + line: prefix.bytes().filter(|b| *b == b'\n').count(), + column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + } + }; + let cursor = text_editor::Cursor { + position: position(span.start), + selection: Some(position(span.end)), + }; + if !nav::exact_cursor(new, cursor) { + return Err("Cannot preserve the exact renamed occurrence.".into()); + } + Ok(Approval { + id: Id::new_v4(), + prepared, + preview, + cursor, + }) +} + +fn step(index: usize, count: usize, next: bool) -> usize { + if next { + index.saturating_add(1).min(count.saturating_sub(1)) + } else { + index.saturating_sub(1) + } +} + +fn segment_total(form: &Form) -> usize { + let Some(ready) = &form.approval else { + return 0; + }; + let file = &ready.preview.files()[form.file]; + file.hunks()[form.hunk] + .lines() + .iter() + .skip(form.line) + .take(LINES_PER_PAGE) + .map(|line| { + preview::segment_count( + line.before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap_or(""), + ) + .max(preview::segment_count( + line.after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap_or(""), + )) + }) + .max() + .unwrap_or(0) +} + +fn details(form: &Form) -> String { + let target = form + .target + .as_ref() + .map_or("Resolving alias", |target| target.caption.as_str()); + let path = form + .approval + .as_ref() + .map_or(form.context.begin.path.as_str(), |ready| { + ready.preview.files()[form.file].path() + }); + format!( + "{target}\nOrigin: {}\nDisplayed file: {path}\nSource alias: {}\nReplacement: {}\n{}", + form.context.begin.path, + form.target + .as_ref() + .map_or("", |target| target.alias.as_str()), + form.replacement, + form.notice + ) +} + +fn refresh_details(form: &mut Form) { + let full = details(form); + form.detail_segment = form + .detail_segment + .min(preview::segment_count(&full).saturating_sub(1)); + let segment = preview::text_segment(&full, form.detail_segment).unwrap_or(""); + if form.detail_editor.text() != segment { + form.detail_editor = text_editor::Content::with_text(segment); + } +} + +fn input_id(invocation: Id) -> iced::widget::Id { + iced::widget::Id::from(format!("source-rename-{invocation}")) +} + +// Each navigation gets a fresh destination even when returning to the same page +// or changing a filter that leaves the page tuple unchanged (the ABA case). +fn reset_preview_position(form: &mut Form) -> Task { + form.preview_destination = Id::new_v4(); + if !form.details + && let Some(ready) = &form.approval + { + iced::widget::operation::snap_to( + preview_scroll_id(form, ready.id), + iced::widget::scrollable::RelativeOffset::START, + ) + } else { + Task::none() + } +} + +// The operation targets this visit, not a later page, visit or approval. +// A new widget id alone does not reset Iced's retained Scrollable state. +fn preview_scroll_id(form: &Form, approval: Id) -> iced::widget::Id { + iced::widget::Id::from(format!( + "source-rename-diff-{}-{approval}-{}-{}-{}-{}-{}", + form.invocation, form.preview_destination, form.file, form.hunk, form.line, form.segment + )) +} + +/// Complete indexed preview with bounded rendering: eight admitted source rows +/// and one UTF-8 segment per side at a time. No candidate content is discarded. +pub(crate) fn dialog(app: &App) -> Element<'_, Message> { + dialog_content( + app, + &app.source_rename + .form + .as_ref() + .expect("open rename form") + .detail_editor, + ) +} + +fn dialog_content<'a, R>( + app: &'a App, + detail_editor: &'a text_editor::Content, +) -> Element<'a, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'static, +{ + use crate::views::short; + use iced::{ + Fill, Font, + widget::{button, column, container, row, scrollable, text, text_input}, + }; + let form = app.source_rename.form.as_ref().expect("open rename form"); + let invocation = form.invocation; + let message = |event| Message::SourceRename(event); + let can_preview = !form.stale + && app.source_rename.in_flight.is_none() + && form.target.is_some() + && replacement_notice(form.target.as_deref(), &form.replacement).is_none(); + let caption = form + .target + .as_ref() + .map_or("Checking source target…".into(), |target| { + short(&target.caption, 76) + }); + let mut alias = text_input("Source alias", &form.replacement) + .id(input_id(invocation)) + .padding(8) + .size(14); + if form.target.is_some() { + alias = alias + .on_input(move |value| message(Event::Input(invocation, value))) + .on_submit(message(Event::Preview(invocation, form.generation))); + } + let alias = alias_input::guard( + alias.into(), + form.target + .is_some() + .then(|| message(Event::PasteRejected(invocation))), + ); + let content: Element<'_, Message, iced::Theme, R> = if form.details { + let count = preview::segment_count(&details(form)); + column![ + label("Selectable target, full paths and feedback · all segments available"), + pager( + format!("Details {}/{}", form.detail_segment + 1, count.max(1)), + (form.detail_segment > 0).then(|| message(Event::DetailSegment(invocation, false))), + (form.detail_segment + 1 < count) + .then(|| message(Event::DetailSegment(invocation, true))) + ), + iced::widget::text_editor(detail_editor) + .font(Font::MONOSPACE) + .size(12) + .height(Fill) + .on_action(move |event| message(Event::ReadOnly(invocation, event))), + ] + .spacing(8) + .height(Fill) + .into() + } else if let Some(ready) = &form.approval { + let files = ready.preview.files(); + let file = &files[form.file]; + let hunks = file.hunks(); + let lines = hunks[form.hunk].lines(); + let segments = segment_total(form); + let matched: Vec<_> = files + .iter() + .enumerate() + .filter_map(|(i, file)| file.path().contains(&form.search).then_some(i)) + .collect(); + let selected = matched.iter().position(|i| *i == form.file); + let preview_id = ready.id; + let mut displayed = column![].spacing(8).width(Fill); + for line in lines.iter().skip(form.line).take(LINES_PER_PAGE) { + let before = line + .before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap_or(""); + let after = line + .after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap_or(""); + displayed = displayed + .push( + text(format!( + "Line {} · {}", + line.number(), + if line.changed() { "changed" } else { "context" } + )) + .size(12), + ) + .push(text("− Before").size(11)) + .push( + text( + preview::text_segment(before, form.segment) + .unwrap_or("[No further segment on this side]"), + ) + .font(Font::MONOSPACE) + .size(12) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ) + .push(text("+ After").size(11)) + .push( + text( + preview::text_segment(after, form.segment) + .unwrap_or("[No further segment on this side]"), + ) + .font(Font::MONOSPACE) + .size(12) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ); + } + column![ + label(format!("{} changed files · {} changed lines · {} preview bytes. This operation only; earlier unsaved changes are separate.", files.len(), ready.preview.changed_line_count(), ready.preview.display_bytes())), + text_input("Find changed file by path", &form.search).padding(6).size(12) + .on_input(move |value| message(Event::Search(invocation, value))), + label(if matched.is_empty() { "No paths match. The previous file remains visible; clear search to browse all changed files." } else { "All matching files are reachable with Previous / Next." }), + pager(format!("File {}/{} · {}", form.file + 1, files.len(), short(file.path(), 45)), + selected.filter(|i| *i > 0).map(|_| message(Event::File(invocation, preview_id, false))), + selected.filter(|i| i + 1 < matched.len()).map(|_| message(Event::File(invocation, preview_id, true)))), + row![ + pager(format!("Hunk {}/{}", form.hunk + 1, hunks.len()), + (form.hunk > 0).then(|| message(Event::Hunk(invocation, preview_id, false))), + (form.hunk + 1 < hunks.len()).then(|| message(Event::Hunk(invocation, preview_id, true)))), + pager(format!("Rows {}–{}/{}", form.line + 1, (form.line + LINES_PER_PAGE).min(lines.len()), lines.len()), + (form.line > 0).then(|| message(Event::Line(invocation, preview_id, false))), + (form.line + LINES_PER_PAGE < lines.len()).then(|| message(Event::Line(invocation, preview_id, true)))), + ].spacing(8), + pager(format!("Row segments {}/{} · both sides", form.segment + 1, segments.max(1)), + (form.segment > 0).then(|| message(Event::Segment(invocation, preview_id, false))), + (form.segment + 1 < segments).then(|| message(Event::Segment(invocation, preview_id, true)))), + scrollable(displayed).id(preview_scroll_id(form, preview_id)).height(Fill).width(Fill).spacing(8), + ].spacing(6).height(Fill).into() + } else { + scrollable(column![ + text("Preview changes the source alias and its typed uses across all included files.").size(13), + text("Engineering names, identities and other model fields stay unchanged. Physical files outside the loaded project are untouched.").size(13), + text("After Rename alias, use Undo rename or Undo model for the whole transaction. Ctrl+Z in Text remains Undo text. Save writes all accepted unsaved changes.").size(13), + ].spacing(12)).height(Fill).into() + }; + let confirm = form + .approval + .as_ref() + .filter(|_| !form.stale) + .map(|ready| message(Event::Confirm(invocation, form.generation, ready.id))); + container( + column![ + row![ + text("Rename alias").size(20), + container(text(caption).size(12)).width(Fill), + action( + if form.details { + "Preview pane" + } else { + "Full details" + }, + message(Event::Details(invocation, !form.details)) + ) + ] + .spacing(10), + label("Source alias · Engineering name stays unchanged"), + container(alias).id("source-rename-input"), + text(short(&form.notice, 190)).size(12), + container(content).id("source-rename-preview").height(Fill), + container( + row![ + action("Cancel", message(Event::Close(invocation))), + button(text("Preview").size(13)).padding(10).on_press_maybe( + can_preview.then(|| message(Event::Preview(invocation, form.generation))) + ), + button(text("Rename alias").size(13)) + .padding(10) + .on_press_maybe(confirm), + ] + .spacing(10) + ) + .id("source-rename-footer"), + ] + .spacing(8) + .height((app.window_size.height - 120.).clamp(200., 680.)), + ) + .width(Fill) + .into() +} + +fn pager( + label: String, + previous: Option, + next: Option, +) -> Element<'static, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'static, +{ + use iced::{ + Fill, + widget::{button, container, row, text}, + }; + row![ + button(text("Previous").size(11)) + .padding(5) + .on_press_maybe(previous), + container(text(label).size(11)).width(Fill), + button(text("Next").size(11)) + .padding(5) + .on_press_maybe(next), + ] + .spacing(6) + .align_y(iced::Center) + .width(Fill) + .into() +} + +fn action<'a, R>(label: &str, message: Message) -> iced::widget::Button<'a, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'a, +{ + iced::widget::button(iced::widget::text(label.to_owned()).size(12)) + .padding([8, 10]) + .on_press(message) + .style(crate::style::nav) +} + +fn label<'a, R>(value: impl Into) -> iced::widget::Text<'a, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'a, +{ + iced::widget::text(value.into()) + .size(11) + .color(crate::style::MUTED) +} + +#[cfg(test)] +mod tests; + +#[cfg(all(test, windows, debug_assertions))] +#[path = "source_rename_timing_tests.rs"] +mod timing_tests; diff --git a/crates/oa-desktop/src/app/source_rename/alias_input.rs b/crates/oa-desktop/src/app/source_rename/alias_input.rs new file mode 100644 index 00000000..f7240271 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/alias_input.rs @@ -0,0 +1,219 @@ +//! Keep Iced's single-line input from silently filtering pasted control bytes. +//! Only the alias field uses this adapter. The child retains editing and focus. +use iced::advanced::{ + Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget, +}; +use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse}; + +pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>( + input: Element<'a, Message, Theme, R>, + rejected: Option, +) -> Element<'a, Message, Theme, R> { + Element::new(AliasInput { input, rejected }) +} + +struct AliasInput<'a, Message, Theme, R> { + input: Element<'a, Message, Theme, R>, + rejected: Option, +} + +#[derive(Default)] +struct State { + modifiers: keyboard::Modifiers, +} + +// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its +// previously admitted paste while V is held, until KeyReleased clears that cache. +struct CapturedClipboard<'a> { + content: Option, + original: &'a mut dyn Clipboard, +} +impl Clipboard for CapturedClipboard<'_> { + fn read(&self, kind: clipboard::Kind) -> Option { + if kind == clipboard::Kind::Standard { + self.content.clone() + } else { + self.original.read(kind) + } + } + fn write(&mut self, kind: clipboard::Kind, contents: String) { + self.original.write(kind, contents); + } +} + +impl Widget + for AliasInput<'_, Message, Theme, R> +{ + fn size(&self) -> Size { + self.input.as_widget().size() + } + fn size_hint(&self) -> Size { + self.input.as_widget().size_hint() + } + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(self.input.as_widget())] + } + fn diff(&self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_ref(&self.input)); + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &R, + limits: &layout::Limits, + ) -> layout::Node { + self.input + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.input.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn widget::Operation, + ) { + self.input + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let focused = tree.children[0] + .state + .downcast_ref::>() + .is_focused(); + let state = tree.state.downcast_mut::(); + // Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers. + // Mirror its Escape reset and its Latin/physical-key paste shortcut. + if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event { + state.modifiers = *modifiers; + } + let paste = matches!(event, + Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. }) + if key.to_latin(*physical_key) == Some('v') + && state.modifiers.command() && !state.modifiers.alt()); + if focused + && paste + && let Some(rejected) = &self.rejected + { + let content = clipboard.read(clipboard::Kind::Standard); + if content + .as_ref() + .is_some_and(|value| value.chars().any(char::is_control)) + { + // Do not invoke the child: even an empty replacement would erase + // its selection. Approval invalidation is an explicit App event. + shell.publish(rejected.clone()); + shell.capture_event(); + return; + } + self.input.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + &mut CapturedClipboard { + content, + original: clipboard, + }, + shell, + viewport, + ); + return; + } + self.input.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + // In Iced 0.14.2 the only KeyPressed branch that removes focus is its + // Escape branch, which also resets stored modifiers. Observe that branch + // after its text/shortcut early returns and effective-key conversion. + if focused + && matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. })) + && !tree.children[0] + .state + .downcast_ref::>() + .is_focused() + { + tree.state.downcast_mut::().modifiers = keyboard::Modifiers::default(); + } + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.input.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.input.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +#[cfg(all(test, debug_assertions))] +mod tests; diff --git a/crates/oa-desktop/src/app/source_rename/alias_input/tests.rs b/crates/oa-desktop/src/app/source_rename/alias_input/tests.rs new file mode 100644 index 00000000..579880f4 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/alias_input/tests.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::views::source_tests::FontLayout; +use iced::keyboard::{ + Key, Modifiers, + key::{Code, Named, Physical}, +}; +use std::cell::Cell; + +#[derive(Debug, Clone, PartialEq)] +enum Signal { + Input(String), + Rejected, +} + +struct ClipboardText { + text: String, + reads: Cell, +} +impl Clipboard for ClipboardText { + fn read(&self, _: clipboard::Kind) -> Option { + self.reads.set(self.reads.get() + 1); + Some(self.text.clone()) + } + fn write(&mut self, _: clipboard::Kind, _: String) {} +} +type InputState = iced::widget::text_input::State<::Paragraph>; + +struct Mounted { + input: Element<'static, Signal, iced::Theme, FontLayout>, + tree: widget::Tree, + node: layout::Node, + clipboard: ClipboardText, + messages: Vec, +} +impl Mounted { + fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self { + let mut child = iced::widget::text_input("Alias", "send_water"); + if enabled { + child = child.on_input(Signal::Input); + } + let mut input = guard(child.into(), enabled.then_some(Signal::Rejected)); + let mut tree = widget::Tree::new(input.as_widget()); + let node = input.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, Size::new(500., 40.)), + ); + if focused { + let state = tree.children[0].state.downcast_mut::(); + state.focus(); + state.select_range(0, 10); + } + let mut mounted = Self { + input, + tree, + node, + clipboard: ClipboardText { + text: "foo\nbar".into(), + reads: Cell::new(0), + }, + messages: vec![], + }; + mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged( + modifiers, + ))); + mounted + } + fn state(&self) -> &InputState { + self.tree.children[0].state.downcast_ref::() + } + fn event(&mut self, event: Event) -> bool { + let mut shell = Shell::new(&mut self.messages); + self.input.as_widget_mut().update( + &mut self.tree, + &event, + Layout::new(&self.node), + mouse::Cursor::Unavailable, + &FontLayout, + &mut self.clipboard, + &mut shell, + &Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)), + ); + shell.is_event_captured() + } +} +fn key(key: Key, modified_key: Key, code: Code) -> Event { + Event::Keyboard(keyboard::Event::KeyPressed { + key, + modified_key, + physical_key: Physical::Code(code), + location: keyboard::Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }) +} +fn paste() -> Event { + key( + Key::Character("v".into()), + Key::Character("v".into()), + Code::KeyV, + ) +} + +#[test] +fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() { + for focused in [false, true] { + for enabled in [false, true] { + for modifiers in [ + Modifiers::empty(), + Modifiers::SHIFT, + Modifiers::COMMAND, + Modifiers::COMMAND | Modifiers::ALT, + Modifiers::COMMAND | Modifiers::SHIFT, + ] { + let mut mounted = Mounted::new(focused, enabled, modifiers); + let before = mounted.state().cursor(); + let expected = focused && enabled && modifiers.command() && !modifiers.alt(); + let captured = mounted.event(paste()); + assert_eq!( + mounted.messages, + if expected { + vec![Signal::Rejected] + } else { + vec![] + } + ); + assert_eq!(mounted.clipboard.reads.get(), usize::from(expected)); + assert_eq!(mounted.state().cursor(), before); + if expected { + assert!(captured); + } + } + } + } +} + +#[test] +fn effective_escape_reset_follows_child_after_early_returns() { + // Raw Escape mapped to Home must not clear only the guard's Command state. + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + mounted.event(key( + Key::Named(Named::Escape), + Key::Named(Named::Home), + Code::Escape, + )); + assert!(mounted.state().is_focused()); + assert!(mounted.event(paste())); + assert_eq!(mounted.messages, vec![Signal::Rejected]); + + // Effective Escape clears both states; a later focus operation does not + // invent a new ModifiersChanged event or revive the previous Command state. + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + mounted.event(key( + Key::Named(Named::Home), + Key::Named(Named::Escape), + Code::Home, + )); + assert!(!mounted.state().is_focused()); + mounted.tree.children[0] + .state + .downcast_mut::() + .focus(); + mounted.event(paste()); + assert!(mounted.messages.is_empty()); + assert_eq!(mounted.clipboard.reads.get(), 0); + + // Printable text returns before effective Escape and leaves modifiers live. + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + let mut event = key( + Key::Named(Named::Home), + Key::Named(Named::Escape), + Code::Home, + ); + if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event { + *text = Some("x".into()); + } + mounted.event(event); + assert!(mounted.state().is_focused()); + assert_eq!(mounted.messages, vec![Signal::Input("x".into())]); + mounted.messages.clear(); + mounted.event(paste()); + assert_eq!(mounted.messages, vec![Signal::Rejected]); +} + +#[test] +fn held_paste_cache_contains_only_previously_admitted_text() { + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + mounted.clipboard.text = "water".into(); + mounted.event(paste()); + assert_eq!(mounted.messages, vec![Signal::Input("water".into())]); + mounted.messages.clear(); + mounted.clipboard.text = "other".into(); + mounted.event(paste()); + assert_eq!( + mounted.messages, + vec![Signal::Input("waterwater".into())], + "Iced reuses its first admitted paste while the key is held" + ); + mounted.messages.clear(); + let before = mounted.state().cursor(); + mounted.clipboard.text = "bad\tvalue".into(); + mounted.event(paste()); + assert_eq!(mounted.messages, vec![Signal::Rejected]); + assert_eq!(mounted.state().cursor(), before); + mounted.messages.clear(); + mounted.event(Event::Keyboard(keyboard::Event::KeyReleased { + key: Key::Character("v".into()), + modified_key: Key::Character("v".into()), + physical_key: Physical::Code(Code::KeyV), + location: keyboard::Location::Standard, + modifiers: Modifiers::empty(), + })); + mounted.clipboard.text = "later".into(); + mounted.event(paste()); + assert_eq!( + mounted.messages, + vec![Signal::Input("waterwaterlater".into())] + ); +} diff --git a/crates/oa-desktop/src/app/source_rename/editor_patch.rs b/crates/oa-desktop/src/app/source_rename/editor_patch.rs new file mode 100644 index 00000000..f30a08b8 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/editor_patch.rs @@ -0,0 +1,303 @@ +//! Display-only reuse after a guarded alias rename. This is not model authority. +use iced::{advanced::text::Renderer, widget::text_editor}; +use std::sync::Arc; + +/// Replace exact, unchanged-line-count source in an existing editor. +/// +/// The caller must first authorize/commit the model change and must reconstruct +/// from accepted source on false. Preflight failures do not mutate the editor; +/// a failed final readback can leave it changed. On changing success the cursor +/// is at 0:0 without selection; the caller restores its approved origin/reveal. +/// Direct Content actions deliberately bypass App draft edits and Undo history. +pub(super) fn replace( + editor: &mut text_editor::Content, + before: &str, + after: &str, +) -> bool { + if editor.text() != before { + return false; + } + let Some(old_lines) = lines(before) else { + return false; + }; + let Some(new_lines) = lines(after) else { + return false; + }; + if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() { + return false; + } + let mut patches = Vec::new(); + for (line, ((old, old_ending), (new, new_ending))) in + old_lines.into_iter().zip(new_lines).enumerate() + { + let Some(actual) = editor.line(line) else { + return false; + }; + // Cosmic also recognizes bare CR and LFCR. Comparing the actual lines + // catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too. + if old_ending != new_ending || actual.text != old || actual.ending != old_ending { + return false; + } + if old == new { + continue; + } + let mut start = old + .bytes() + .zip(new.bytes()) + .take_while(|(a, b)| a == b) + .count(); + while !old.is_char_boundary(start) || !new.is_char_boundary(start) { + start -= 1; + } + let mut suffix = old[start..] + .bytes() + .rev() + .zip(new[start..].bytes().rev()) + .take_while(|(a, b)| a == b) + .count(); + while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix) + { + suffix -= 1; + } + // All positions and owned payloads are prepared before the first edit. + patches.push(( + line, + start, + old.len() - suffix, + Arc::new(new[start..new.len() - suffix].to_owned()), + )); + } + if patches.is_empty() { + return true; + } + let empty = Arc::new(String::new()); + for (line, start, end, replacement) in patches.into_iter().rev() { + select(editor, line, start, end); + editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste( + replacement, + ))); + } + // Iced overwrites its earliest-change marker on each Edit. An explicit + // zero-width selection + empty Paste changes no bytes, but marks line zero. + // This also refreshes previously unhighlighted lines before the first patch. + // It resets only line zero's text cache; other unchanged lines retain theirs. + select(editor, 0, 0, 0); + editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty))); + editor.text() == after +} + +fn select( + editor: &mut text_editor::Content, + line: usize, + start: usize, + end: usize, +) { + editor.move_to(text_editor::Cursor { + position: text_editor::Position { + line, + column: start, + }, + // move_to(None) does not clear a previous selection in this Iced version. + selection: Some(text_editor::Position { line, column: end }), + }); +} + +fn lines(source: &str) -> Option> { + use text_editor::LineEnding; + let mut result = Vec::new(); + let mut parts = source.split('\n').peekable(); + while let Some(part) = parts.next() { + let (text, ending) = if parts.peek().is_none() { + (part, LineEnding::None) + } else if let Some(text) = part.strip_suffix('\r') { + (text, LineEnding::CrLf) + } else { + (part, LineEnding::Lf) + }; + if text.contains('\r') + || (text.is_empty() + && ending == LineEnding::CrLf + && result + .last() + .is_some_and(|(_, previous)| *previous == LineEnding::Lf)) + { + return None; + } + result.push((text, ending)); + } + Some(result) +} + +#[cfg(all(test, debug_assertions))] +mod tests { + use super::*; + use crate::views::source_tests::FontLayout; + use iced::advanced::{layout, text::Highlighter, widget::Tree}; + use iced::{Element, Font, Length, Size}; + use std::{cell::RefCell, ops::Range}; + + #[test] + fn exact_text_matrix_and_reverse_preserve_endings() { + for (before, after) in [ + ("", ""), + ("", "alias"), + ("alias", ""), + ("same", "same"), + ("a\n", "alias\n"), + ("a\r\n", "alias\r\n"), + ("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"), + ("\na\n\n", "\nlong_alias\n\n"), + ("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"), + ( + "雨🙂 e\u{301} @a @a // a", + "雨🙂 e\u{301} @a_long @a_long // a", + ), + ("@abc", "@abcdef"), + ("@abcdef", "@abc"), + ("@abc_suffix", "@def_suffix"), + ("@none", "@identifier"), + // Shared prefix/suffix bytes inside distinct UTF-8 codepoints must + // not be used as String split positions, even outside alias scope. + ("雨xé", "雪xê"), + ("é", "©"), + ( + "unchanged\n@a\nunchanged\n@a\n", + "unchanged\n@long\nunchanged\n@long\n", + ), + ] { + let mut editor = text_editor::Content::::with_text(before); + assert!( + replace(&mut editor, before, after), + "{before:?} -> {after:?}" + ); + assert_eq!(editor.text(), after); + let expected = text_editor::Content::::with_text(after); + assert_eq!(editor.line_count(), expected.line_count()); + for (actual, expected) in editor.lines().zip(expected.lines()) { + assert_eq!(actual.text, expected.text); + assert_eq!(actual.ending, expected.ending); + } + if before != after { + assert_eq!( + editor.cursor().position, + text_editor::Position { line: 0, column: 0 } + ); + assert_eq!(editor.cursor().selection, None); + } + assert!(replace(&mut editor, after, before)); + assert_eq!(editor.text(), before); + } + } + + #[test] + fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() { + for (actual, before, after) in [ + ("live", "stale", "changed"), + ("a\nb", "a\nb", "long\nb\nc"), + ("a\nb", "a\nb", "long\r\nb"), + // Refusal on a later line must not apply the earlier planned edit. + ("a\nb\r\n", "a\nb\r\n", "long\nb\n"), + ("a\rb", "a\rb", "long\rb"), + ("a\n\rb", "a\n\rb", "long\n\rb"), + ("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"), + // The replacement must not introduce a greedy LFCR boundary either. + ("a\nx\r\n", "a\nx\r\n", "a\n\r\n"), + ("a", "a", "long\r"), + ] { + let mut editor = text_editor::Content::::with_text(actual); + select(&mut editor, 0, 0, 1); + let cursor = editor.cursor(); + let selection = editor.selection(); + assert!( + !replace(&mut editor, before, after), + "{before:?} -> {after:?}" + ); + assert_eq!(editor.text(), actual); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.selection(), selection); + } + } + + #[test] + fn unchanged_content_preserves_selection_and_next_direct_edit_works() { + let mut editor = text_editor::Content::::with_text("@a"); + select(&mut editor, 0, 1, 2); + let cursor = editor.cursor(); + assert!(replace(&mut editor, "@a", "@a")); + assert_eq!(editor.cursor(), cursor); + assert!(replace(&mut editor, "@a", "@long")); + editor.move_to(text_editor::Cursor { + position: text_editor::Position { line: 0, column: 5 }, + selection: None, + }); + editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!'))); + assert_eq!(editor.text(), "@long!"); + } + + thread_local! { + static CHANGES: RefCell> = const { RefCell::new(Vec::new()) }; + } + + struct Tracked(crate::highlight::ArcadiaHighlighter); + impl Highlighter for Tracked { + type Settings = Arc; + type Highlight = oa_core::language::TokenClass; + type Iterator<'a> = std::vec::IntoIter<(Range, Self::Highlight)>; + fn new(settings: &Self::Settings) -> Self { + Self(crate::highlight::ArcadiaHighlighter::new(settings)) + } + fn update(&mut self, settings: &Self::Settings) { + self.0.update(settings); + } + fn change_line(&mut self, line: usize) { + CHANGES.with(|changes| changes.borrow_mut().push(line)); + self.0.change_line(line); + } + fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { + self.0.highlight_line(line) + } + fn current_line(&self) -> usize { + self.0.current_line() + } + } + + fn view( + editor: &text_editor::Content, + source: Arc, + ) -> Element<'_, (), iced::Theme, FontLayout> { + text_editor(editor) + .font(Font::MONOSPACE) + .size(14) + .height(Length::Fill) + .highlight_with::(source, crate::highlight::format) + .into() + } + + #[test] + fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() { + let before = "// earlier\n// unmodified\n@a"; + let after = "// earlier\n// unmodified\n@none"; + let mut editor = text_editor::Content::::with_text(before); + let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.)); + let mut tree = { + let mut widget = view(&editor, Arc::from(before)); + let mut tree = Tree::new(widget.as_widget()); + let _ = widget + .as_widget_mut() + .layout(&mut tree, &FontLayout, &limits); + tree + }; + CHANGES.with(|changes| changes.borrow_mut().clear()); + assert!(replace(&mut editor, before, after)); + { + let mut widget = view(&editor, Arc::from(after)); + let _ = widget + .as_widget_mut() + .layout(&mut tree, &FontLayout, &limits); + } + // Font/metrics/bounds are already stable. This notification must come + // from the helper's empty edit, not a fresh renderer initialization. + CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0])); + assert_eq!(editor.text(), after); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs new file mode 100644 index 00000000..e208548b --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs @@ -0,0 +1,448 @@ +//! Combined App-boundary oracles: the workers are completed independently and +//! no returned Task is polled. Rendering measurements use the real reveal path. +use super::*; +use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage}; +use iced::{Size, Vector}; + +fn model_id(number: u8) -> Id { + Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap() +} + +fn navigation_fixture() -> App { + let mut source = bundle(); + let entry = source.files.get_mut("project.arcadia").unwrap(); + *entry = entry.replace( + "include \"uses.arcadia\";", + "include \"uses.arcadia\"; include \"views.arcadia\";", + ); + source.files.insert( + "views.arcadia".into(), + concat!( + "// Keep @pump and the exact distant placement.\r\n", + "diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n", + "node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n", + ).into(), + ); + let mut app = fixture(); + app.document = Document::from_bundle(source.clone(), LIMITS).unwrap(); + assert_eq!(app.document.bundle(), &source); + assert!( + !validate(app.project()) + .iter() + .any(|issue| issue.severity == validation::Severity::Error) + ); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + seek_pump(&mut app); + app.canvas_size = Some(Size::new(1200., 700.)); + app.zoom = 0.8; + app.pan = Vector::new(31., 47.); + app +} + +fn seek_pump(app: &mut App) { + let source = app.source_workspace.text(); + let offset = source.rfind("pump;").unwrap(); + app.source_editor + .move_to(cursor(source, offset, offset + 4)); +} + +fn source_completion(app: &App, request: nav::Request) -> nav::Completion { + let snapshot = Arc::new( + app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()), + ); + let results = snapshot + .query(query::Query { + stamp: request.stamp, + path: &request.path, + byte_offset: request.offset, + action: request.action, + }) + .unwrap(); + nav::Completion { + request, + result: Ok((snapshot, Arc::new(results))), + } +} + +fn diagram_completion(app: &App) -> usage::Completion { + let panel = app.diagram_usages.as_ref().unwrap(); + let process = app.behavior.topology.usage_snapshot().into_query_state(); + usage::Completion { + token: panel.token, + context: panel.context, + result: Ok(usage::collect( + app.project(), + panel.context.subject, + &process, + &behavior::scenario::State::default(), + )), + } +} + +fn finish_lookup(app: &mut App) -> (Id, Usage) { + let completion = diagram_completion(app); + send(app, Message::DiagramUsagesReady(completion)); + let panel = app.diagram_usages.as_ref().unwrap(); + assert!(!panel.loading); + assert!(app.diagram_lookup_in_flight.is_none()); + let found = panel + .usages + .iter() + .find(|u| { + u.surface == Surface::Saved(model_id(8)) + && u.target == UsageTarget::Placement(model_id(9)) + }) + .expect("the exact placed pump is represented") + .clone(); + assert_eq!(found.subject, model_id(2)); + assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.)); + (panel.token, found) +} + +fn navigate(app: &mut App) -> Usage { + let message = app.diagram_usage_message(model_id(2)); + send(app, message); + let (token, found) = finish_lookup(app); + send( + app, + Message::DiagramUsage(usage::Event::Choose(token, found.clone())), + ); + assert_eq!(app.page, Page::Diagram); + assert_eq!(app.active_diagram, Some(model_id(8))); + assert_eq!(app.selected, model_id(2)); + assert_eq!(app.selected_node, Some(model_id(9))); + assert!(app.diagram_usages.is_none()); + assert!(app.pending_diagram_reveal.is_some()); + assert!(app.diagram_focus(found.surface).is_some()); + found +} + +#[derive(Debug, PartialEq)] +struct Navigation { + page: Page, + selected: Id, + placement: Option, + diagram: Option, + zoom: f32, + pan: Vector, + pending: Option<(usage::Context, Usage, u64)>, + focus: Option<(usage::Context, Usage, Id)>, + canvas_epoch: u64, + camera_epoch: u64, + source_interaction: Id, + source_reveal: Option, +} + +fn navigation(app: &App) -> Navigation { + Navigation { + page: app.page, + selected: app.selected, + placement: app.selected_node, + diagram: app.active_diagram, + zoom: app.zoom, + pan: app.pan, + pending: app.pending_diagram_reveal.clone(), + focus: app + .diagram_navigation_focus + .as_ref() + .map(|f| (f.context, f.usage.clone(), f.selected)), + canvas_epoch: app.canvas_epoch, + camera_epoch: app.camera_epoch, + source_interaction: app.source_symbols.interaction, + source_reveal: app.source_reveal_ticket(), + } +} + +fn first_measurement(app: &mut App, found: &Usage) { + let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap(); + let (context, epoch) = (*context, *epoch); + let actual = Size::new(500., 300.); + send(app, Message::DiagramRevealViewport(context, epoch, actual)); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!(app.canvas_size, Some(actual)); + assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05); + assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05); + assert_eq!(app.selected, model_id(2)); + assert_eq!(app.selected_node, Some(model_id(9))); + assert!(app.diagram_focus(found.surface).is_some()); +} + +#[test] +fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() { + let mut app = navigation_fixture(); + let original = evidence(&app); + let snapshot = app.document.snapshot(); + let queued = app.diagram_usage_message(model_id(2)); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + let form = app.source_rename.form.as_ref().unwrap(); + let approved = ( + form.invocation, + form.generation, + form.approval.as_ref().unwrap().id, + ); + send(&mut app, queued); + let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages); + send(&mut app, source); + let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)); + send(&mut app, definition); + assert!(app.diagram_usages.is_none()); + assert!(app.diagram_lookup_in_flight.is_none()); + assert!(app.source_symbols.in_flight.is_none()); + assert!(!app.source_navigation_available()); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + ( + form.invocation, + form.generation, + form.approval.as_ref().unwrap().id + ), + approved + ); + assert_eq!(form.replacement, "send_water"); + assert_eq!(evidence(&app), original); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + send(&mut app, Message::Escape); + send(&mut app, confirm); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), original); +} + +#[test] +fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() { + for finish in [false, true] { + let mut app = navigation_fixture(); + let before = evidence(&app); + let queued_rename = app.source_rename_message(); + let open = app.diagram_usage_message(model_id(2)); + send(&mut app, open); + if finish { + finish_lookup(&mut app); + } + let panel = app.diagram_usages.as_ref().unwrap(); + let token = panel.token; + let loading = panel.loading; + assert!(app.rename_admission().is_err()); + assert!(!app.source_navigation_available()); + send(&mut app, queued_rename); + assert!(!app.source_rename.open()); + assert!(app.source_rename.in_flight.is_none()); + let panel = app.diagram_usages.as_ref().unwrap(); + assert_eq!((panel.token, panel.loading), (token, loading)); + assert_eq!(evidence(&app), before); + send(&mut app, Message::DiagramUsage(usage::Event::Close(token))); + let rename = app.source_rename_message(); + send(&mut app, rename); + assert!( + app.source_rename.open(), + "the dismissed chooser no longer owns input" + ); + } +} + +#[test] +fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() { + for preview_pending in [false, true] { + let mut app = navigation_fixture(); + let before = evidence(&app); + let completion = if preview_pending { + begin(&mut app); + input(&mut app, "send_water"); + let job = start_preview(&mut app); + preview_completion(&app, job) + } else { + let rename = app.source_rename_message(); + send(&mut app, rename); + target_completion(&app, current_job(&app)) + }; + let occupied = completion.job.id; + event(&mut app, Event::Close(completion.job.invocation)); + assert_eq!(app.source_rename.in_flight, Some(occupied)); + let found = navigate(&mut app); + let location = navigation(&app); + assert_eq!(evidence(&app), before); + let mut forged = completion.clone(); + forged.job.id = Id::new_v4(); + send(&mut app, Message::SourceRenameReady(forged)); + assert_eq!(app.source_rename.in_flight, Some(occupied)); + assert_eq!(navigation(&app), location); + send(&mut app, Message::SourceRenameReady(completion.clone())); + assert!(app.source_rename.in_flight.is_none()); + assert!(!app.source_rename.open()); + assert_eq!(navigation(&app), location); + assert_eq!(evidence(&app), before); + send(&mut app, Message::SourceRenameReady(completion)); + assert_eq!( + navigation(&app), + location, + "duplicate completion is passive too" + ); + first_measurement(&mut app, &found); + assert_eq!(evidence(&app), before); + } +} + +#[test] +fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged() +{ + let mut app = navigation_fixture(); + let before = evidence(&app); + let snapshot = app.document.snapshot(); + let queued_begin = app.source_rename_message(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(invocation)); + let found = navigate(&mut app); + let location = navigation(&app); + for stale in [ + queued_begin, + Message::SourceRename(Event::Close(invocation)), + confirm, + ] { + send(&mut app, stale); + assert_eq!(navigation(&app), location); + assert_eq!(evidence(&app), before); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + } + first_measurement(&mut app, &found); +} + +#[test] +fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() { + let mut app = navigation_fixture(); + let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)); + send(&mut app, source); + let request = app.source_symbols.request.clone().unwrap(); + let source_ready = source_completion(&app, request.clone()); + let rename = app.source_rename_message(); + send(&mut app, rename); + let rename_ready = target_completion(&app, current_job(&app)); + let rename_job = rename_ready.job.id; + event(&mut app, Event::Close(rename_ready.job.invocation)); + let open = app.diagram_usage_message(model_id(2)); + send(&mut app, open); + let old_diagram = diagram_completion(&app); + let diagram_job = old_diagram.token; + let old_document = app.canvas_document; + let project = app.project().clone(); + app.reset(project, None); + assert_ne!(app.canvas_document, old_document); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.source_rename.in_flight, Some(rename_job)); + assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job)); + assert!(!app.source_rename.open()); + assert!(app.diagram_usages.is_none()); + let before = evidence(&app); + let snapshot = app.document.snapshot(); + let new_open = app.diagram_usage_message(model_id(2)); + send(&mut app, new_open); + let new_token = app.diagram_usages.as_ref().unwrap().token; + assert_ne!(new_token, diagram_job); + assert_eq!( + app.diagram_lookup_in_flight, + Some(diagram_job), + "replacement waits for old worker" + ); + send(&mut app, Message::SourceRenameReady(rename_ready)); + assert!(app.source_rename.in_flight.is_none()); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job)); + send(&mut app, Message::SourceSymbolsReady(source_ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(!app.source_symbols.open); + send(&mut app, Message::DiagramUsagesReady(old_diagram)); + assert_eq!(app.diagram_lookup_in_flight, Some(new_token)); + assert!(app.diagram_usages.as_ref().unwrap().loading); + finish_lookup(&mut app); + assert_eq!(evidence(&app), before); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert!(!app.source_rename.open()); +} + +#[test] +fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() { + let mut app = navigation_fixture(); + let baseline = app.document.snapshot(); + let project = app.project().id; + app.document + .execute(Command::Rename(project, "Previously accepted name".into())) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + seek_pump(&mut app); + let before = app.document.snapshot(); + let mut expected_project = app.project().clone(); + expected_project + .elements + .get_mut(&model_id(2)) + .unwrap() + .alias = "send_water".into(); + let mut expected_source = app.document.bundle().clone(); + let defs = expected_source.files.get_mut("defs.arcadia").unwrap(); + *defs = defs.replace("oa_activity pump ", "oa_activity send_water "); + let uses = expected_source.files.get_mut("uses.arcadia").unwrap(); + *uses = uses + .replace("source @pump;", "source @send_water;") + .replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;"); + let views = expected_source.files.get_mut("views.arcadia").unwrap(); + *views = views.replace("element @pump;", "element @send_water;"); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + assert_eq!(app.project(), &expected_project); + assert_eq!(app.document.bundle(), &expected_source); + let renamed = app.document.snapshot(); + let accepted = evidence(&app); + let queued_undo_rename = app + .source_rename_undo_message() + .expect("fresh rename offers its guarded undo"); + let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages); + send(&mut app, message); + let request = app + .source_symbols + .request + .clone() + .expect("renamed exact caret remains queryable"); + let ready = source_completion(&app, request); + send(&mut app, Message::SourceSymbolsReady(ready)); + let (token, found) = finish_lookup(&mut app); + send( + &mut app, + Message::DiagramUsage(usage::Event::Choose(token, found.clone())), + ); + assert_eq!(app.selected, model_id(2)); + assert_eq!(app.selected_node, Some(model_id(9))); + let destination = navigation(&app); + send(&mut app, queued_undo_rename); + assert_eq!(navigation(&app), destination); + assert_eq!(evidence(&app), accepted); + first_measurement(&mut app, &found); + assert_eq!(evidence(&app), accepted); + assert!(Arc::ptr_eq(&renamed, &app.document.snapshot())); + for (message, expected) in [ + (Message::Undo, before.clone()), + (Message::Redo, renamed.clone()), + (Message::Undo, before.clone()), + (Message::Undo, baseline.clone()), + (Message::Redo, before), + (Message::Redo, renamed), + ] { + send(&mut app, message); + assert!( + Arc::ptr_eq(&expected, &app.document.snapshot()), + "exact history entry restored" + ); + assert_eq!(app.project(), &expected.project); + assert_eq!(app.document.bundle(), &expected.bundle); + assert_eq!(app.source_workspace.bundle(), expected.bundle); + assert!(!app.source_dirty()); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs b/crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs new file mode 100644 index 00000000..90b38641 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs @@ -0,0 +1,449 @@ +//! Real paragraph advances and clipping through the actual preview dialog. +//! Native screenshots additionally check raster ink and the platform renderer. +use super::*; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, layout, renderer, text as shaped, widget}; +use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector}; +use std::collections::{BTreeMap, BTreeSet}; + +struct Glyph { + text: String, + paragraph_y: u32, + line: usize, + bytes: std::ops::Range, + bounds: Rectangle, + clip: Rectangle, +} + +struct GlyphRecorder { + wanted: BTreeSet, + transforms: Vec, + clips: Vec, + glyphs: Vec, + quads: Vec, +} +impl GlyphRecorder { + fn new(wanted: BTreeSet, viewport: Rectangle) -> Self { + Self { + wanted, + transforms: vec![Transformation::IDENTITY], + clips: vec![viewport], + glyphs: vec![], + quads: vec![], + } + } + fn transform(&self) -> Transformation { + *self.transforms.last().unwrap() + } + fn clip(&self, bounds: Rectangle) -> Rectangle { + self.clips + .last() + .unwrap() + .intersection(&(bounds * self.transform())) + .unwrap_or(Rectangle::with_size(Size::ZERO)) + } +} +impl renderer::Renderer for GlyphRecorder { + fn start_layer(&mut self, bounds: Rectangle) { + self.clips.push(self.clip(bounds)); + } + fn end_layer(&mut self) { + self.clips.pop().unwrap(); + } + fn start_transformation(&mut self, transform: Transformation) { + self.transforms.push(self.transform() * transform); + } + fn end_transformation(&mut self) { + self.transforms.pop().unwrap(); + } + fn reset(&mut self, bounds: Rectangle) { + self.transforms = vec![Transformation::IDENTITY]; + self.clips = vec![bounds]; + self.glyphs.clear(); + self.quads.clear(); + } + fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into) { + self.quads.push(quad.bounds * self.transform()); + } + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} +impl shaped::Renderer for GlyphRecorder { + type Font = Font; + type Paragraph = ::Paragraph; + type Editor = ::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::ICED_LOGO; + fn default_font(&self) -> Font { + shaped::Renderer::default_font(&FontLayout) + } + fn default_size(&self) -> Pixels { + shaped::Renderer::default_size(&FontLayout) + } + fn fill_paragraph( + &mut self, + paragraph: &Self::Paragraph, + position: Point, + _: Color, + clip: Rectangle, + ) { + let buffer = paragraph.buffer(); + let text = buffer + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n"); + if !self.wanted.contains(&text) { + return; + } + let clip = self.clip(clip); + for run in buffer.layout_runs() { + for glyph in run.glyphs { + if run.text[glyph.start..glyph.end] + .chars() + .all(char::is_whitespace) + { + continue; + } + let bounds = Rectangle { + x: position.x + glyph.x + glyph.x_offset * glyph.font_size, + y: position.y + run.line_top, + width: glyph.w, + height: run.line_height, + } * self.transform(); + self.glyphs.push(Glyph { + text: text.clone(), + paragraph_y: position.y.to_bits(), + line: run.line_i, + bytes: glyph.start..glyph.end, + bounds, + clip, + }); + } + } + } + fn fill_editor( + &mut self, + editor: &Self::Editor, + position: Point, + color: Color, + clip: Rectangle, + ) { + shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip); + } + fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) { + shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip); + } +} + +#[derive(Default)] +struct ScrollGeometry { + id: Option, + bounds: Option, + content: Option, + offset: Vector, +} +impl widget::Operation for ScrollGeometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + content: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == self.id.as_ref() { + self.bounds = Some(bounds); + self.content = Some(content); + self.offset = offset; + } + } +} +fn normalized(text: &str) -> String { + text.replace("\r\n", "\n").replace('\r', "\n") +} +fn required_chars(text: &str) -> BTreeSet<(usize, usize)> { + text.split('\n') + .enumerate() + .flat_map(|(line, text)| { + text.char_indices() + .filter(|(_, ch)| !ch.is_whitespace()) + .map(move |(byte, _)| (line, byte)) + }) + .collect() +} + +fn declarations(long: bool) -> App { + let name = if long { + format!("Assess {} request", "界🚀".repeat(90)) + } else { + "Assess 救援 request".into() + }; + let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ + ("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()), + ("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")), + ]) }; + let mut app = fixture(); + app.document = Document::from_bundle(source, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.source_workspace.active = "救援/assess.arcadia".into(); + app.selected = app.project().id; + app.refresh_draft(); + app.show_source_file(false); + let start = app + .source_workspace + .text() + .find("oa_activity assess") + .unwrap() + + "oa_activity ".len(); + app.source_editor.move_to(cursor( + app.source_workspace.text(), + start, + start + "assess".len(), + )); + begin(&mut app); + let replacement = if long { + "a".repeat(255) + } else { + "assess_damage".into() + }; + input(&mut app, &replacement); + let _ = preview(&mut app); + app +} + +fn assert_page_glyphs_visible(app: &App, host: Size) { + let form = app.source_rename.form.as_ref().unwrap(); + let ready = form.approval.as_ref().unwrap(); + let file = &ready.preview.files()[form.file]; + let mut expected = BTreeMap::::new(); + for line in file.hunks()[form.hunk] + .lines() + .iter() + .skip(form.line) + .take(LINES_PER_PAGE) + { + for source in [ + line.before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap(), + line.after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap(), + ] { + if let Some(fragment) = preview::text_segment(source, form.segment) { + let text = normalized(fragment); + if !required_chars(&text).is_empty() { + *expected.entry(text).or_default() += 1; + } + } + } + } + assert!(!expected.is_empty()); + let viewport = Rectangle::new(Point::ORIGIN, host); + let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport); + let editor = text_editor::Content::::new(); + let mut view = dialog_content(app, &editor); + let mut tree = widget::Tree::new(view.as_widget()); + let node = + view.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host)); + let mut geometry = ScrollGeometry { + id: Some(preview_scroll_id(form, ready.id)), + ..ScrollGeometry::default() + }; + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut geometry); + let bounds = geometry.bounds.unwrap(); + let content = geometry.content.unwrap(); + assert!(bounds.height >= 40.); + let maximum = (content.height - bounds.height).max(0.); + let steps = (maximum / (bounds.height * 0.5)).ceil() as usize; + let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new(); + let mut reserved_bar_gap = true; + for step in 0..=steps { + let offset = (step as f32 * bounds.height * 0.5).min(maximum); + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &renderer, + &mut widget::operation::scrollable::scroll_to( + preview_scroll_id(form, ready.id), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(offset), + }, + ), + ); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut geometry); + assert!( + (geometry.offset.y - offset).abs() <= 0.5, + "actual scroll operation reaches the requested source offset" + ); + renderer::Renderer::reset(&mut renderer, viewport); + view.as_widget().draw( + &tree, + &mut renderer, + &iced::Theme::Light, + &renderer::Style::default(), + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + // The vertical scrollbar's actual painted thumb fixes its excluded x + // region; the outer scroll clip alone cannot detect overpainting. + let scrollbar_left = renderer + .quads + .iter() + .filter(|quad| { + quad.width > 0. + && quad.width <= 16. + && quad.height > 0. + && quad.x >= bounds.x + bounds.width - 18. + && quad.x + quad.width <= bounds.x + bounds.width + 0.5 + && quad.y >= bounds.y - 0.5 + && quad.y + quad.height <= bounds.y + bounds.height + 0.5 + }) + .map(|quad| quad.x) + .min_by(f32::total_cmp); + if maximum > 0. { + let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar"); + reserved_bar_gap &= content.x + content.width <= left - 7.5; + } + for glyph in &renderer.glyphs { + let right = glyph.bounds.x + glyph.bounds.width; + if let Some(left) = scrollbar_left { + assert!( + right <= left - 0.5, + "source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}", + glyph.text, + glyph.bytes, + glyph.bounds + ); + } + assert!( + glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5, + "source glyph escaped reserved width: {:?} {:?}", + glyph.text, + glyph.bounds + ); + assert!( + glyph.bounds.x >= glyph.clip.x - 0.5 + && right <= glyph.clip.x + glyph.clip.width + 0.5, + "source glyph horizontally clipped: {:?} {:?} {:?}", + glyph.text, + glyph.bounds, + glyph.clip + ); + if glyph.bounds.y >= glyph.clip.y - 0.5 + && glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5 + { + let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap(); + let chars = logical_line + .char_indices() + .filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace()) + .map(|(byte, _)| (glyph.line, byte)); + visible + .entry((glyph.text.clone(), glyph.paragraph_y)) + .or_default() + .extend(chars); + } + } + } + assert!( + reserved_bar_gap, + "source content must reserve actual scrollbar width plus gap" + ); + assert!( + bounds.width >= host.width - 30., + "preview must use available dialog width: {bounds:?} / {host:?}" + ); + for (text, count) in expected { + let required = required_chars(&text); + let instances: Vec<_> = visible + .iter() + .filter(|((value, _), _)| value == &text) + .collect(); + assert_eq!( + instances.len(), + count, + "all before/after occurrences must be seen: {text:?}" + ); + for (_, seen) in instances { + assert_eq!( + seen, &required, + "every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}" + ); + } + } +} + +#[test] +fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() { + for long in [false, true] { + let mut app = declarations(long); + let ready = app + .source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .clone(); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + let before = evidence(&app); + for (width, height) in [(1100., 700.), (580., 700.)] { + app.window_size = Size::new(width, height); + let host = Size::new((width - 84.).min(708.), height - 120.); + let total = segment_total(app.source_rename.form.as_ref().unwrap()); + if long { + assert!( + total >= 3, + "include unbroken ASCII and wide Unicode across segments" + ); + } + for segment in 0..total { + assert_page_glyphs_visible(&app, host); + if segment + 1 < total { + event(&mut app, Event::Segment(invocation, ready.id, true)); + } + } + for _ in 1..total { + event(&mut app, Event::Segment(invocation, ready.id, false)); + } + } + assert_eq!( + evidence(&app), + before, + "preview geometry/paging cannot alter transaction inputs" + ); + assert_eq!( + app.source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .id, + ready.id + ); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs new file mode 100644 index 00000000..4adb3bf4 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs @@ -0,0 +1,385 @@ +//! Execute the controller's scroll operations on a retained, scrolled dialog. +use super::*; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, layout, widget}; +use iced::futures::StreamExt; +use iced::{Rectangle, Size, Vector}; + +const HOST: Size = Size::new(720., 660.); + +fn paged_fixture() -> App { + let mut source = bundle(); + let name = "Long engineering name 雨 ".repeat(18); + source.files.get_mut("defs.arcadia").unwrap().replace_range( + .., + &format!( + "oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\ + oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n" + ), + ); + let mut uses = String::new(); + for index in 0..16 { + if index == 12 { + uses.push_str(&"// unchanged gap\r\n".repeat(10)); + } + uses.push_str(&format!( + "interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n" + )); + } + source.files.insert("uses.arcadia".into(), uses); + let mut app = fixture(); + app.window_size = HOST; + app.document = Document::from_bundle(source, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len(); + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + begin(&mut app); + input(&mut app, "pump_long"); + let _ = preview(&mut app); + app +} + +fn scroll_id(app: &App) -> widget::Id { + let form = app.source_rename.form.as_ref().unwrap(); + preview_scroll_id(form, form.approval.as_ref().unwrap().id) +} + +struct Geometry { + id: widget::Id, + offset: Option, +} +impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == Some(&self.id) { + self.offset = Some(offset); + } + } +} + +struct Mounted { + editor: text_editor::Content, + tree: widget::Tree, +} +impl Mounted { + fn new(app: &App) -> Self { + let editor = text_editor::Content::::new(); + let tree = widget::Tree::new(dialog_content(app, &editor).as_widget()); + Self { editor, tree } + } + fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) { + let mut view = dialog_content(app, &self.editor); + // Preserve the actual scroll state across the new page's view diff. + self.tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut self.tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, HOST), + ); + view.as_widget_mut() + .operate(&mut self.tree, Layout::new(&node), &FontLayout, operation); + } + fn offset(&mut self, app: &App) -> f32 { + let mut geometry = Geometry { + id: scroll_id(app), + offset: None, + }; + self.operate(app, &mut geometry); + geometry.offset.expect("mounted preview scrollable").y + } + fn scroll_down(&mut self, app: &App) -> f32 { + self.operate( + app, + &mut widget::operation::scrollable::scroll_to( + scroll_id(app), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(10_000.), + }, + ), + ); + let offset = self.offset(app); + assert!(offset > 10., "fixture must really be scrolled"); + offset + } + async fn execute(&mut self, app: &App, task: Task) -> usize { + let mut count = 0; + if let Some(mut actions) = iced_runtime::task::into_stream(task) { + while let Some(action) = actions.next().await { + let iced_runtime::Action::Widget(mut operation) = action else { + panic!("preview paging must only schedule a widget operation"); + }; + self.operate(app, operation.as_mut()); + count += 1; + } + } + count + } +} + +#[tokio::test] +async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + mounted.scroll_down(&app); + let old_id = scroll_id(&app); + let task = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + assert_ne!(old_id, scroll_id(&app)); + // Negative control: even after diff/layout with the new id and new file, + // Iced retains the previous offset until the returned operation executes. + assert!(mounted.offset(&app) > 10.); + assert_eq!(mounted.execute(&app, task).await, 1); + assert_eq!(mounted.offset(&app), 0.); + + for navigation in [ + Event::Line(invocation, approval, true), + Event::Segment(invocation, approval, true), + Event::Hunk(invocation, approval, true), + Event::File(invocation, approval, false), + Event::Search(invocation, "uses".into()), + Event::Search(invocation, "no-path-match".into()), + Event::Search(invocation, String::new()), + ] { + mounted.scroll_down(&app); + let task = app.update(Message::SourceRename(navigation)); + assert_eq!(mounted.execute(&app, task).await, 1); + assert_eq!(mounted.offset(&app), 0.); + assert_eq!(evidence(&app), before); + } + // Passive redraw and unchanged filter input preserve deliberate scrolling. + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::Search( + invocation, + String::new(), + ))); + assert_eq!(mounted.execute(&app, task).await, 0); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::Input( + invocation, + "pump_long".into(), + ))); + assert_eq!(mounted.execute(&app, task).await, 0); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::File( + invocation, approval, false, + ))); + assert_eq!( + mounted.execute(&app, task).await, + 0, + "clamped first-page request is a no-op" + ); + assert_eq!(mounted.offset(&app), offset); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let old_approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + let old_page_task = app.update(Message::SourceRename(Event::File( + invocation, + old_approval, + true, + ))); + let next_page_task = app.update(Message::SourceRename(Event::Line( + invocation, + old_approval, + true, + ))); + assert_eq!(mounted.execute(&app, next_page_task).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_page_task).await, 1); + assert_eq!(mounted.offset(&app), offset, "old page target is absent"); + + let old_approval_task = app.update(Message::SourceRename(Event::Search( + invocation, + "defs".into(), + ))); + input(&mut app, "pump_new"); + let _ = preview(&mut app); // Real new approval in the same invocation/page. + assert_ne!( + app.source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .id, + old_approval + ); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_approval_task).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old approval target is absent" + ); + let task = app.update(Message::SourceRename(Event::File( + invocation, + old_approval, + true, + ))); + assert_eq!( + mounted.execute(&app, task).await, + 0, + "stale envelope is rejected before scheduling" + ); + assert_eq!(mounted.offset(&app), offset); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + + let first_visit = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + let next_page = app.update(Message::SourceRename(Event::Line( + invocation, approval, true, + ))); + assert_eq!(mounted.execute(&app, next_page).await, 1); + let return_visit = app.update(Message::SourceRename(Event::Line( + invocation, approval, false, + ))); + assert_eq!(mounted.execute(&app, return_visit).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, first_visit).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old visit to the identical page is stale" + ); + + let old_filter = app.update(Message::SourceRename(Event::Search( + invocation, + "uses".into(), + ))); + let new_filter = app.update(Message::SourceRename(Event::Search( + invocation, + "us".into(), + ))); + assert_eq!(mounted.execute(&app, new_filter).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_filter).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old filter on the same page is stale" + ); + + input(&mut app, "pump_new"); + let job = start_preview(&mut app); + let completion = preview_completion(&app, job); + // No intermediate frame mounts the temporary no-approval view. The old + // Scrollable survives even though the new approval has a different id. + let ready = app.update(Message::SourceRenameReady(completion)); + assert!(mounted.offset(&app) > 10.); + assert_eq!(mounted.execute(&app, ready).await, 1); + assert_eq!(mounted.offset(&app), 0.); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll() +{ + // Exercise both an actually mounted Details view and a fast round trip + // between frames, where Iced retains the same preview Scrollable state. + for mount_details in [true, false] { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + mounted.scroll_down(&app); + let held_reset = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + let old_preview = scroll_id(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let page = (form.file, form.hunk, form.line, form.segment); + + let show_details = app.update(Message::SourceRename(Event::Details(invocation, true))); + assert_eq!(mounted.execute(&app, show_details).await, 0); + let details_visit = scroll_id(&app); + assert_ne!(details_visit, old_preview); + if mount_details { + let mut hidden = Geometry { + id: old_preview.clone(), + offset: None, + }; + mounted.operate(&app, &mut hidden); + assert!( + hidden.offset.is_none(), + "Details actually replaced the preview" + ); + } + let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true))); + assert_eq!(mounted.execute(&app, repeat_details).await, 0); + assert_eq!( + scroll_id(&app), + details_visit, + "unchanged visibility keeps its visit" + ); + + let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false))); + assert_eq!( + mounted.execute(&app, show_preview).await, + 0, + "returning does not force a scroll reset" + ); + let returned_visit = scroll_id(&app); + assert_ne!(returned_visit, old_preview); + assert_ne!(returned_visit, details_visit); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!((form.file, form.hunk, form.line, form.segment), page); + assert_eq!(form.approval.as_ref().unwrap().id, approval); + + let offset = mounted.scroll_down(&app); + let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false))); + assert_eq!(mounted.execute(&app, repeat_preview).await, 0); + assert_eq!(scroll_id(&app), returned_visit); + assert_eq!( + mounted.offset(&app), + offset, + "repeated Preview preserves deliberate scroll" + ); + assert_eq!(mounted.execute(&app, held_reset).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "the previous visit cannot reset the returned preview" + ); + assert_eq!(evidence(&app), before); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/tests.rs b/crates/oa-desktop/src/app/source_rename/tests.rs new file mode 100644 index 00000000..2b90ec6e --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/tests.rs @@ -0,0 +1,1427 @@ +use super::*; +use oa_core::source_project::SourceBundle; +use std::collections::BTreeMap; + +#[path = "navigation_union_tests.rs"] +mod navigation_union_tests; + +#[cfg(debug_assertions)] +#[path = "preview_glyph_tests.rs"] +mod preview_glyph_tests; + +#[cfg(debug_assertions)] +#[path = "preview_scroll_tests.rs"] +mod preview_scroll_tests; + +fn bundle() -> SourceBundle { + SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ + ("project.arcadia".into(), concat!("arcadia 7;\r\nproject model \"救援\" id \"00000000-0000-0000-0000-000000000001\" {\r\n", + " include \"defs.arcadia\"; include \"uses.arcadia\";\r\n}\r\n").into()), + ("defs.arcadia".into(), concat!("oa_activity pump \"送水 🚀\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n", + "oa_activity backup \"送水 🚀\" id \"00000000-0000-0000-0000-000000000004\" {}\r\n").into()), + ("uses.arcadia".into(), concat!("// keep @pump and Unicode 雨\r\n", + "interaction first \"先\" id \"00000000-0000-0000-0000-000000000003\" { description \"literal @pump\"; source @pump; target @backup; } ", + "interaction second \"後\" id \"00000000-0000-0000-0000-000000000005\" { source @ // keep @pump\r\n pump; target @backup; }\r\n").into()), + ]) } +} + +fn fixture() -> App { + let mut app = App::with_project(Project::empty()); + app.document = Document::from_bundle(bundle(), LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.page = Page::Text; + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + let start = app.source_workspace.text().rfind("pump;").unwrap(); + let cursor = cursor(app.source_workspace.text(), start, start + 4); + app.source_editor.move_to(cursor); + app +} + +fn cursor(text: &str, start: usize, end: usize) -> text_editor::Cursor { + let pos = |offset| { + let prefix = &text[..offset]; + text_editor::Position { + line: prefix.bytes().filter(|b| *b == b'\n').count(), + column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + } + }; + text_editor::Cursor { + position: pos(start), + selection: Some(pos(end)), + } +} + +fn send(app: &mut App, message: Message) { + // Drop unpolled Tasks; deterministic completion still travels through the + // real update route. These tests never launch an application or file worker. + let _ = app.update(message); +} + +fn event(app: &mut App, event: Event) { + send(app, Message::SourceRename(event)); +} + +fn current_job(app: &App) -> Job { + let form = app.source_rename.form.as_ref().unwrap(); + Job { + id: form.pending.unwrap(), + invocation: form.invocation, + generation: form.generation, + context: form.context.clone(), + } +} + +fn target_completion(app: &App, job: Job) -> Completion { + let snapshot = app + .with_source_input(|input| query::capture(input, query::Limits::default())) + .unwrap(); + let offset = nav::cursor_offset( + &job.context.accepted.bundle.files[&job.context.begin.path], + job.context.begin.cursor, + ) + .unwrap(); + let result = resolve_target(&snapshot, &job.context, offset).map(Arc::new); + Completion { + job, + payload: Payload::Target(result), + } +} + +fn begin(app: &mut App) { + let message = app.source_rename_message(); + send(app, message); + let ready = target_completion(app, current_job(app)); + send(app, Message::SourceRenameReady(ready)); + assert!(app.source_rename.form.as_ref().unwrap().target.is_some()); +} + +fn input(app: &mut App, replacement: &str) { + let id = app.source_rename.form.as_ref().unwrap().invocation; + event(app, Event::Input(id, replacement.into())); +} + +fn preview_completion(app: &App, job: Job) -> Completion { + let form = app.source_rename.form.as_ref().unwrap(); + let target = form.target.as_ref().unwrap(); + let result = app + .document + .alias_rename_request(target.id, form.replacement.clone()) + .unwrap() + .prepare() + .map_err(|error| error.to_string()) + .and_then(|prepared| prepare_approval(prepared, target, &form.context.begin.path)) + .map(Arc::new); + Completion { + job, + payload: Payload::Preview(result), + } +} + +fn start_preview(app: &mut App) -> Job { + let form = app.source_rename.form.as_ref().unwrap(); + let message = Event::Preview(form.invocation, form.generation); + event(app, message); + current_job(app) +} + +fn preview(app: &mut App) -> Message { + let job = start_preview(app); + let completion = preview_completion(app, job); + send(app, Message::SourceRenameReady(completion)); + let form = app.source_rename.form.as_ref().unwrap(); + Message::SourceRename(Event::Confirm( + form.invocation, + form.generation, + form.approval.as_ref().unwrap().id, + )) +} + +#[derive(Debug, PartialEq)] +struct Evidence { + project: Project, + source: SourceBundle, + drafts: SourceBundle, + revision: u64, + workspace: query::SnapshotKey, + cursor: text_editor::Cursor, + undo: bool, + redo: bool, + dirty: bool, +} + +fn evidence(app: &App) -> Evidence { + Evidence { + project: app.project().clone(), + source: app.document.bundle().clone(), + drafts: app.source_workspace.bundle(), + revision: app.document.revision(), + workspace: app.source_key(), + cursor: app.source_editor.cursor(), + undo: app.document.can_undo(), + redo: app.document.can_redo(), + dirty: app.document.dirty(), + } +} + +fn assert_accepted_display(app: &App, accepted: &SourceBundle) { + let active = &accepted.files[&app.source_workspace.active]; + assert_eq!( + app.source_editor.text(), + *active, + "displayed accepted source" + ); + assert_eq!( + app.source_snapshot.as_ref(), + active.as_str(), + "snapshot for highlighting" + ); + assert_eq!(app.source_workspace.text(), active, "active workspace text"); + assert_eq!( + app.source_workspace.bundle(), + *accepted, + "all accepted workspace files" + ); + assert!( + !app.source_workspace.dirty(), + "display synchronization creates no source draft" + ); +} + +#[test] +fn complete_alias_change_preserves_literals_and_last_comment_separated_origin_and_undo() { + let mut app = fixture(); + let original = evidence(&app); + let mut expected_model = original.project.clone(); + let id = Id::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); + expected_model.elements.get_mut(&id).unwrap().alias = "send_water".into(); + let mut expected_source = original.source.clone(); + expected_source + .files + .get_mut("defs.arcadia") + .unwrap() + .replace_range(12..16, "send_water"); + let uses = expected_source.files.get_mut("uses.arcadia").unwrap(); + *uses = uses + .replace("source @pump;", "source @send_water;") + .replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;"); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + assert_eq!(evidence(&app), original, "preview is not Apply"); + send(&mut app, confirm.clone()); + assert!(!app.source_rename.open()); + assert_eq!(app.project(), &expected_model); + assert_eq!(app.document.bundle(), &expected_source); + assert_accepted_display(&app, &expected_source); + let text = &expected_source.files["uses.arcadia"]; + let start = text.rfind("send_water;").unwrap(); + assert_eq!( + app.source_editor.cursor(), + cursor(text, start, start + "send_water".len()) + ); + let accepted = evidence(&app); + send(&mut app, confirm); + assert_eq!(evidence(&app), accepted, "repeated approval has no effect"); + // A plain caret movement is not a newer model operation. + send( + &mut app, + Message::SourceEdit(text_editor::Action::Move(text_editor::Motion::Home)), + ); + let undo = app + .source_rename_undo_message() + .expect("caret move retains Undo rename"); + send(&mut app, undo); + assert_eq!(app.document.bundle(), &original.source); + assert_accepted_display(&app, &original.source); + assert_eq!(app.project(), &original.project); + send(&mut app, Message::Redo); + assert_eq!(app.document.bundle(), &expected_source); + assert_accepted_display(&app, &expected_source); + assert_eq!(app.project(), &expected_model); +} + +#[test] +fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_history() { + for mismatch in ["none", "path", "before", "after"] { + let mut app = fixture(); + let original_project = app.project().clone(); + let original_source = app.document.bundle().clone(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + let renamed_project = app.project().clone(); + let renamed_source = app.document.bundle().clone(); + assert_accepted_display(&app, &renamed_source); + + // Undo the actual accepted transaction at the Document boundary, leaving + // the display/workspace on the newer source until the common sync runs. + assert!(app.document.undo()); + assert_eq!(app.project(), &original_project); + assert_eq!(app.document.bundle(), &original_source); + assert!(!app.document.can_undo()); + assert!(app.document.can_redo()); + let revision = app.document.revision(); + let dirty = app.document.dirty(); + let workspace_key = app.source_key(); + let path = app.source_workspace.active.clone(); + let before = app.source_editor.text(); + let after = original_source.files[&path].clone(); + assert_ne!(before, after, "fallback must visibly replace stale text"); + + // Observable stale derived state makes a skipped sync fail independently + // of the editor byte oracle. None of this changes Document authority. + let selected = Id::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); + app.selected = selected; + app.draft = None; + app.source_snapshot = Arc::from("stale snapshot"); + app.source_select_line = true; + app.source_jump = Some(cursor(&before, 0, 1)); + app.source_navigation.query = "send_water".into(); + app.source_navigation.refresh(&before); + assert_ne!(app.source_navigation.label(), "0 matches"); + let mut invalid = original_source.clone(); + invalid.files.insert(path.clone(), "?".into()); + app.source_diagnostics = source_workspace::lint(&invalid); + assert!(!app.source_diagnostics.is_empty()); + let version = app.source_version; + app.lint_requested = Some(version); + app.model_issues = vec![oa_core::validation::Issue { + severity: oa_core::validation::Severity::Error, + element: None, + code: "stale-test-issue".into(), + field: None, + message: "Must be replaced by accepted-model validation".into(), + }]; + let hint = match mismatch { + "none" => None, + "path" => Some(EditorRefresh { + path: "different.arcadia", + before: &before, + after: &after, + }), + "before" => Some(EditorRefresh { + path: &path, + before: "not the displayed source", + after: &after, + }), + "after" => Some(EditorRefresh { + path: &path, + before: &before, + after: &before, + }), + _ => unreachable!(), + }; + if mismatch == "none" { + app.sync(); + } else { + app.sync_with_alias_editor(hint); + } + + assert_accepted_display(&app, &original_source); + assert_eq!(app.project(), &original_project, "{mismatch}"); + assert_eq!(app.document.bundle(), &original_source); + assert_eq!( + app.document.revision(), + revision, + "display sync adds no model operation" + ); + assert_eq!(app.document.dirty(), dirty); + assert!(!app.document.can_undo()); + assert!(app.document.can_redo()); + assert_ne!( + app.source_key(), + workspace_key, + "workspace adopts accepted source" + ); + assert_eq!(app.source_version, version + 1); + assert_eq!(app.lint_requested, None); + assert_eq!(app.source_diagnostics, app.document.compiled().diagnostics); + assert_eq!(app.source_navigation.label(), "0 matches"); + assert!(!app.source_select_line); + assert_eq!(app.source_jump, None); + let draft = app + .draft + .as_ref() + .expect("accepted property draft refreshed"); + assert_eq!(draft.original, selected); + assert_eq!(draft.value["alias"], "pump"); + assert!(!draft.dirty()); + assert_eq!( + serde_json::to_value(&app.model_issues).unwrap(), + serde_json::to_value(validate(&original_project)).unwrap(), + ); + + // A display fallback must not consume, replace or append model history. + send(&mut app, Message::Redo); + assert_eq!(app.project(), &renamed_project); + assert_eq!(app.document.bundle(), &renamed_source); + assert_accepted_display(&app, &renamed_source); + assert!(!app.document.can_redo()); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &original_project); + assert_eq!(app.document.bundle(), &original_source); + assert_accepted_display(&app, &original_source); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn stale_dialog_envelopes_do_not_cancel_new_same_path_form_or_its_approval() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let old_confirm = preview(&mut app); + let old_id = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(old_id)); + begin(&mut app); + input(&mut app, "current_water"); + let _ = preview(&mut app); + let form = app.source_rename.form.as_ref().unwrap(); + let current_id = form.invocation; + let approval_id = form.approval.as_ref().unwrap().id; + let interaction = app.source_symbols.interaction; + let before = evidence(&app); + for stale in [ + old_confirm, + Message::SourceRename(Event::Close(old_id)), + Message::SourceRename(Event::Input(old_id, "stale".into())), + ] { + send(&mut app, stale); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!(form.invocation, current_id); + assert_eq!(form.replacement, "current_water"); + assert_eq!(form.approval.as_ref().unwrap().id, approval_id); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(evidence(&app), before); + } +} + +#[test] +fn close_and_reset_retain_worker_slot_until_exact_completion_without_reopening() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let job = start_preview(&mut app); + let ready = preview_completion(&app, job.clone()); + event(&mut app, Event::Close(job.invocation)); + assert_eq!(app.source_rename.in_flight, Some(job.id)); + let project = app.project().clone(); + app.reset(project, None); + assert_eq!(app.source_rename.in_flight, Some(job.id)); + let mut impostor = ready.clone(); + impostor.job.id = Id::new_v4(); + send(&mut app, Message::SourceRenameReady(impostor)); + assert_eq!(app.source_rename.in_flight, Some(job.id)); + send(&mut app, Message::SourceRenameReady(ready)); + assert!(app.source_rename.in_flight.is_none()); + assert!(!app.source_rename.open()); +} + +#[test] +fn replacement_generation_aba_and_modal_shortcuts_cannot_commit_stale_preview() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let stale = preview(&mut app); + let before = evidence(&app); + input(&mut app, "other_water"); + input(&mut app, "send_water"); + send(&mut app, stale); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + for message in [ + Message::Save(false), + Message::Undo, + Message::Page(Page::Guide), + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), + Message::SourceNavigation(nav::Event::Open(false)), + ] { + send(&mut app, message); + assert_eq!(evidence(&app), before); + assert!(app.source_rename.open()); + } + send(&mut app, Message::Escape); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), before); +} + +#[test] +fn property_draft_and_detached_buffer_refuse_without_hidden_apply() { + let mut app = fixture(); + send( + &mut app, + Message::Field("/name".into(), "Retain property draft".into()), + ); + assert!(app.draft_dirty()); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + assert!(!app.source_rename.open()); + assert!(app.draft_dirty()); + assert_eq!( + app.draft.as_ref().unwrap().inputs["/name"].raw, + "Retain property draft" + ); + assert_eq!(evidence(&app), before); + let mut app = fixture(); + let mut detached = app.document.bundle().clone(); + detached + .files + .insert("orphan.arcadia".into(), "// retained orphan\n".into()); + app.source_workspace = Workspace::new(&detached, true); + app.source_workspace + .set_reachable(app.document.bundle().files.keys().cloned().collect()); + app.show_source_file(false); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), before); +} + +#[test] +fn same_alias_invalid_and_cancel_keep_existing_redo_and_source_selection() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + send(&mut app, Message::Undo); + let start = app.source_workspace.text().rfind("pump;").unwrap(); + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + assert!(app.document.can_redo()); + let before = evidence(&app); + begin(&mut app); + for replacement in ["pump", " bad alias", "true"] { + input(&mut app, replacement); + let form = app.source_rename.form.as_ref().unwrap(); + let message = Event::Preview(form.invocation, form.generation); + event(&mut app, message); + assert!(app.source_rename.in_flight.is_none()); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + assert_eq!(evidence(&app), before); + } + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(invocation)); + assert_eq!(evidence(&app), before); + send(&mut app, Message::Redo); + assert!(app.document.bundle().files["defs.arcadia"].contains("oa_activity send_water ")); +} + +#[test] +fn source_edit_undo_and_recovery_invalidation_do_not_revive_approval() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let old = preview(&mut app); + let before = app.document.bundle().clone(); + let text = app.source_workspace.text().to_owned(); + let cursor = app.source_editor.cursor(); + app.source_workspace + .edit(format!("{text}// temporary\n"), cursor); + app.source_workspace.undo(false); + assert_eq!(app.source_workspace.text(), text); + send(&mut app, old); + assert!(app.source_rename.form.as_ref().unwrap().stale); + assert_eq!(app.document.bundle(), &before); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(invocation)); + begin(&mut app); + input(&mut app, "send_water"); + let old = preview(&mut app); + let target = PathBuf::from("recovery/project.arcadia"); + app.pending_recovery = Some(target.clone()); + send( + &mut app, + Message::SourceRecovered(Ok(RecoverySnapshot { + target, + disk: Err("readback unavailable".into()), + })), + ); + send(&mut app, old); + assert!(app.source_rename.form.as_ref().unwrap().stale); + assert_eq!(app.document.bundle(), &before); + assert_eq!( + app.source_rename.form.as_ref().unwrap().replacement, + "send_water" + ); +} + +#[test] +fn full_details_selection_survives_readonly_and_passive_messages_and_tracks_input() { + let mut app = fixture(); + begin(&mut app); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Details(invocation, true)); + event( + &mut app, + Event::ReadOnly(invocation, text_editor::Action::SelectAll), + ); + let selected = app + .source_rename + .form + .as_ref() + .unwrap() + .detail_editor + .cursor(); + assert!(selected.selection.is_some()); + let size = app.window_size; + send(&mut app, Message::WindowResized(size)); + let version = app.source_version; + send(&mut app, Message::Linted(version, Ok(vec![]))); + assert_eq!( + app.source_rename + .form + .as_ref() + .unwrap() + .detail_editor + .cursor(), + selected + ); + input(&mut app, "send_water"); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + form.detail_editor.text(), + preview::text_segment(&details(form), form.detail_segment).unwrap() + ); + assert!(details(form).contains("Replacement: send_water")); + event( + &mut app, + Event::ReadOnly( + invocation, + text_editor::Action::Edit(text_editor::Edit::Insert('x')), + ), + ); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + form.detail_editor.text(), + preview::text_segment(&details(form), form.detail_segment).unwrap() + ); +} + +fn many_uses(count: usize) -> App { + uses_fixture(count, false) +} + +fn uses_fixture(count: usize, multiline: bool) -> App { + let mut source = bundle(); + let mut uses = String::new(); + for i in 0..count { + uses.push_str(&format!( + "interaction r{i} \"同名\" id \"{}\" {{ source @pump; target @backup; }} ", + Id::from_u128(100 + i as u128) + )); + if multiline { + uses.push_str("\r\n"); + if i + 1 == count / 2 { + uses.push_str(&"// unchanged context 雨\r\n".repeat(10)); + } + } + } + uses.push_str("\r\n"); + source.files.insert("uses.arcadia".into(), uses); + let mut app = fixture(); + app.document = Document::from_bundle(source, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + let start = app.source_workspace.text().rfind("@pump").unwrap() + 1; + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + app +} + +#[test] +fn last_same_line_use_beyond_reference_row_limit_and_selection_boundaries() { + for count in [2, 220] { + for selection_kind in 0..3 { + let mut app = many_uses(count); + let original = evidence(&app); + let mut selection = app.source_editor.cursor(); + if selection_kind == 1 { + std::mem::swap( + &mut selection.position, + selection.selection.as_mut().unwrap(), + ); + } else if selection_kind == 2 { + selection.selection.as_mut().unwrap().column += 1; + } + app.source_editor.move_to(selection); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + let ready = target_completion(&app, current_job(&app)); + send(&mut app, Message::SourceRenameReady(ready)); + if selection_kind == 2 { + assert!(app.source_rename.form.as_ref().unwrap().target.is_none()); + assert_eq!(evidence(&app), before); + continue; + } + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + let expected = original.source.files["uses.arcadia"] + .replace("source @pump;", "source @send_water;"); + assert_eq!(app.document.bundle().files["uses.arcadia"], expected); + let start = expected.rfind("@send_water").unwrap() + 1; + assert_eq!( + app.source_editor.cursor(), + cursor(&expected, start, start + 10) + ); + } + } +} + +#[test] +fn genuine_save_receipts_preserve_same_destination_preview_but_invalidate_save_as() { + for relocated in [false, true] { + let mut app = fixture(); + let directory = tempfile::tempdir().unwrap(); + let original_path = directory.path().join("original").join("project.arcadia"); + let destination = if relocated { + directory.path().join("relocated").join("project.arcadia") + } else { + original_path.clone() + }; + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + app.path = Some(original_path); + app.document.mark_recovered(); + let receipt = write_snapshot( + app.canvas_document, + app.document.revision(), + app.document.snapshot(), + destination, + None, + ) + .unwrap(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + let original = app.document.bundle().clone(); + send(&mut app, Message::Saved(Ok(Some(receipt)))); + assert!( + !app.document.dirty(), + "actual saved snapshot became the live baseline" + ); + send(&mut app, confirm); + if relocated { + assert_eq!(app.document.bundle(), &original); + assert!(app.source_rename.form.as_ref().unwrap().stale); + } else { + assert!(!app.source_rename.open()); + assert!(app.document.dirty()); + let undo = app.source_rename_undo_message().unwrap(); + send(&mut app, undo); + assert_eq!(app.document.bundle(), &original); + assert!( + !app.document.dirty(), + "Undo uses the latest saved marker, not the preview-time marker" + ); + } + } +} + +#[test] +fn invalid_open_retains_accepted_document_but_refuses_alias_rename() { + let mut app = fixture(); + let original = evidence(&app); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("project.arcadia"); + let invalid = "arcadia 7;\nproject broken { ? invalid source }\n"; + std::fs::write(&path, invalid).unwrap(); + let disk = source_files::load_project(&path, LIMITS).unwrap(); + send( + &mut app, + Message::Loaded(Ok(Some(LoadedSnapshot::Source(disk)))), + ); + assert_eq!(app.document.bundle(), &original.source); + assert_eq!(app.project(), &original.project); + assert!(!app.source_workspace.applied); + assert_eq!(app.source_workspace.text(), invalid); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), before); +} + +fn captured_escape(app: &App) -> Vec { + use iced::advanced::{Layout, Shell, layout, subscription, widget}; + use iced::futures::{StreamExt, stream, task::noop_waker_ref}; + use iced::keyboard::{self, key::Named}; + use std::task::{Context as PollContext, Poll}; + let mut input: iced::Element<'_, (), iced::Theme, ()> = + iced::widget::text_input("Source alias", "retained_alias") + .on_input(|_| ()) + .into(); + let mut tree = widget::Tree::new(input.as_widget()); + let size = iced::Size::new(300., 40.); + let node = + input + .as_widget_mut() + .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); + tree.state + .downcast_mut::>() + .focus(); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(Named::Escape), + modified_key: keyboard::Key::Named(Named::Escape), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }); + let mut widget_messages = Vec::new(); + let mut shell = Shell::new(&mut widget_messages); + input.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + assert!(shell.is_event_captured()); + let event = subscription::Event::Interaction { + window: iced::window::Id::unique(), + event, + status: iced::event::Status::Captured, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let _entered = runtime.enter(); + let mut messages = Vec::new(); + for recipe in subscription::into_recipes(app.subscription()) { + let mut output = recipe.stream(Box::pin(stream::iter([event.clone()]))); + let mut context = PollContext::from_waker(noop_waker_ref()); + while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) { + messages.push(message); + } + } + messages +} + +#[test] +fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { + for pending in [false, true] { + for other_modal in 0..4 { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let job = start_preview(&mut app); + let ready = preview_completion(&app, job.clone()); + if !pending { + send(&mut app, Message::SourceRenameReady(ready.clone())); + } + app.source_navigation.open = true; + app.source_symbols.open = true; + match other_modal { + 1 => app.hosted.visible = true, + 2 => { + app.file_prompt = Some(FilePrompt { + operation: FileOperation::Save, + path: "retained.arcadia".into(), + overwrite: false, + }) + } + 3 => app.pending = Some(Pending::Walkthrough), + _ => {} + } + let before = evidence(&app); + let mut messages = captured_escape(&app); + assert_eq!(messages.len(), 1); + send(&mut app, messages.remove(0)); + if other_modal != 0 { + assert!(app.source_rename.open(), "existing modal has priority"); + let mut messages = captured_escape(&app); + assert_eq!(messages.len(), 1); + send(&mut app, messages.remove(0)); + } + assert!(!app.source_rename.open()); + assert!( + app.source_symbols.open && app.source_navigation.open, + "rename Escape does not fall through" + ); + if pending { + assert_eq!(app.source_rename.in_flight, Some(job.id)); + send(&mut app, Message::SourceRenameReady(ready)); + assert!(!app.source_rename.open()); + } + assert_eq!(evidence(&app), before); + app.source_symbols.open = false; + app.source_navigation.open = false; + assert!(captured_escape(&app).is_empty()); + } + } +} + +#[test] +fn paging_all_utf8_fragments_preserves_exact_preview_and_approval() { + for multiline in [false, true] { + check_all_preview_pages( + if multiline { + uses_fixture(24, true) + } else { + many_uses(220) + }, + multiline, + ); + } +} + +fn check_all_preview_pages(mut app: App, multiline: bool) { + begin(&mut app); + input(&mut app, &"water".repeat(40)); + let confirm = preview(&mut app); + let original = evidence(&app); + let ready = app + .source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .clone(); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + let mut row_transitions = 0; + let mut hunk_transitions = 0; + for file_index in 0..ready.preview.files().len() { + let file = &ready.preview.files()[file_index]; + for hunk_index in 0..file.hunks().len() { + let hunk = &file.hunks()[hunk_index]; + for page in 0..hunk.lines().len().div_ceil(LINES_PER_PAGE) { + let mut reconstructed: BTreeMap = BTreeMap::new(); + let segments = segment_total(app.source_rename.form.as_ref().unwrap()); + for segment in 0..segments { + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + (form.file, form.hunk, form.line, form.segment), + (file_index, hunk_index, page * LINES_PER_PAGE, segment) + ); + let mut mounted = 0; + for line in hunk.lines().iter().skip(form.line).take(LINES_PER_PAGE) { + let before = line + .before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap(); + let after = line + .after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap(); + let a = preview::text_segment(before, segment).unwrap_or(""); + let b = preview::text_segment(after, segment).unwrap_or(""); + mounted += a.len() + b.len(); + let pair = reconstructed.entry(line.number()).or_default(); + pair.0.push_str(a); + pair.1.push_str(b); + } + assert!(mounted <= LINES_PER_PAGE * 2 * 255); + if segment + 1 < segments { + event(&mut app, Event::Segment(invocation, ready.id, true)); + } + } + for line in hunk + .lines() + .iter() + .skip(page * LINES_PER_PAGE) + .take(LINES_PER_PAGE) + { + let pair = &reconstructed[&line.number()]; + assert_eq!( + pair.0, + line.before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap() + ); + assert_eq!( + pair.1, + line.after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap() + ); + } + if page + 1 < hunk.lines().len().div_ceil(LINES_PER_PAGE) { + event(&mut app, Event::Line(invocation, ready.id, true)); + row_transitions += 1; + } + } + if hunk_index + 1 < file.hunks().len() { + event(&mut app, Event::Hunk(invocation, ready.id, true)); + hunk_transitions += 1; + } + } + if file_index + 1 < ready.preview.files().len() { + event(&mut app, Event::File(invocation, ready.id, true)); + } + } + if multiline { + assert!( + row_transitions >= 2, + "both multiline hunks require row paging" + ); + assert!(hunk_transitions >= 1, "unchanged gap must separate hunks"); + } + assert_eq!(evidence(&app), original); + send(&mut app, confirm); + assert_eq!(app.document.bundle(), &ready.prepared.after().bundle); +} + +#[cfg(debug_assertions)] +#[test] +fn actual_dialog_real_paragraph_geometry_keeps_input_preview_and_footer_reachable() { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Layout, layout, widget}; + #[derive(Default)] + struct Bounds { + diff_id: Option, + input: Option, + preview: Option, + footer: Option, + diff: Option, + } + impl widget::Operation for Bounds { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: iced::Rectangle) { + if id == Some(&widget::Id::new("source-rename-input")) { + self.input = Some(bounds); + } + if id == Some(&widget::Id::new("source-rename-preview")) { + self.preview = Some(bounds); + } + if id == Some(&widget::Id::new("source-rename-footer")) { + self.footer = Some(bounds); + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: iced::Rectangle, + _: iced::Rectangle, + _: iced::Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == self.diff_id.as_ref() { + self.diff = Some(bounds); + } + } + } + let mut app = many_uses(12); + let id = Id::from_u128(2); + app.document + .execute(Command::Rename(id, "長い救援の名前 🚀 ".repeat(100))) + .unwrap(); + app.sync(); + let start = app.source_workspace.text().rfind("@pump").unwrap() + 1; + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + begin(&mut app); + input(&mut app, &"water".repeat(100)); + let _ = preview(&mut app); + for (width, height) in [(1100., 700.), (580., 700.), (1440., 900.)] { + app.window_size = iced::Size::new(width, height); + for details in [false, true] { + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Details(invocation, details)); + let exact = app + .source_rename + .form + .as_ref() + .unwrap() + .detail_editor + .text(); + let editor = text_editor::Content::::with_text(&exact); + let mut view = dialog_content(&app, &editor); + let mut tree = widget::Tree::new(view.as_widget()); + let host = iced::Size::new((width - 84.).min(708.), height - 120.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, host), + ); + let form = app.source_rename.form.as_ref().unwrap(); + let mut bounds = Bounds { + diff_id: Some(preview_scroll_id(form, form.approval.as_ref().unwrap().id)), + ..Bounds::default() + }; + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &FontLayout, &mut bounds); + let input = bounds.input.unwrap(); + let body = bounds.preview.unwrap(); + let footer = bounds.footer.unwrap(); + assert!(input.height >= 28. && input.y >= 0.); + assert!(body.height >= 80., "{width}×{height} body={body:?}"); + assert!( + footer.y >= body.y + body.height && footer.y + footer.height <= host.height + 1. + ); + if !details { + assert!( + bounds.diff.unwrap().height >= 40., + "{width}×{height} diff={:?}", + bounds.diff + ); + } + } + } +} + +#[cfg(debug_assertions)] +#[test] +fn mounted_editor_f2_requires_focus_and_no_modifiers() { + use crate::views::{source_binding, source_tests::FontLayout}; + use iced::advanced::widget::operation::Focusable; + use iced::advanced::{Layout, Shell, layout, text as shaped, widget}; + use iced::keyboard::{ + self, Key, Modifiers, + key::{Code, Named, Physical}, + }; + let app = fixture(); + let begin = app.source_rename_begin(); + let context = app.source_symbol_context(); + for focused in [false, true] { + for modifiers in [ + Modifiers::empty(), + Modifiers::SHIFT, + Modifiers::CTRL, + Modifiers::ALT, + Modifiers::LOGO, + ] { + let content = + text_editor::Content::::with_text(app.source_workspace.text()); + let mut editor: iced::Element<'_, Message, iced::Theme, FontLayout> = + iced::widget::text_editor(&content) + .on_action(Message::SourceEdit) + .key_binding(|press| source_binding(press, &context, Some(&begin))) + .height(200.) + .into(); + let mut tree = widget::Tree::new(editor.as_widget()); + let size = iced::Size::new(500., 200.); + let node = editor.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, size), + ); + if focused { + tree.state + .downcast_mut::>() + .focus(); + } + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(Named::F2), + modified_key: Key::Named(Named::F2), + physical_key: Physical::Code(Code::F2), + location: keyboard::Location::Standard, + modifiers, + text: None, + repeat: false, + }); + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + editor.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + let renames: Vec<_> = messages + .iter() + .filter_map(|message| match message { + Message::SourceRename(Event::Begin(context)) => Some(context), + _ => None, + }) + .collect(); + assert_eq!(renames.len(), usize::from(focused && modifiers.is_empty())); + if let Some(context) = renames.first() { + assert_eq!(*context, &begin); + } + } + } +} + +#[cfg(debug_assertions)] +#[test] +fn actual_dialog_enter_requests_preview_even_when_confirm_is_available() { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Layout, Shell, layout, widget}; + use iced::keyboard::{ + self, Key, + key::{Code, Named, Physical}, + }; + struct Focus(iced::widget::Id); + impl widget::Operation for Focus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: iced::Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + if id == Some(&self.0) { + state.focus(); + } else { + state.unfocus(); + } + } + } + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let _ = preview(&mut app); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let generation = form.generation; + let mut messages = Vec::new(); + { + let editor = text_editor::Content::::new(); + let mut view = dialog_content(&app, &editor); + let mut tree = widget::Tree::new(view.as_widget()); + let size = iced::Size::new(708., 580.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, size), + ); + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &FontLayout, + &mut Focus(input_id(invocation)), + ); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(Named::Enter), + modified_key: Key::Named(Named::Enter), + physical_key: Physical::Code(Code::Enter), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }); + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + } + assert_eq!(messages.len(), 1); + assert!( + matches!(messages[0], Message::SourceRename(Event::Preview(i,g)) if i == invocation && g == generation) + ); + send(&mut app, messages.remove(0)); + assert_eq!(evidence(&app), before); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + assert!(app.source_rename.in_flight.is_some()); +} + +#[cfg(debug_assertions)] +#[test] +fn actual_alias_clipboard_rejects_controls_without_normalizing_or_moving_selection() { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Clipboard, Layout, Shell, clipboard, layout, text, widget}; + use iced::keyboard::{ + self, Key, Modifiers, + key::{Code, Physical}, + }; + use std::cell::Cell; + struct ClipboardText { + value: String, + reads: Cell, + } + impl Clipboard for ClipboardText { + fn read(&self, _: clipboard::Kind) -> Option { + self.reads.set(self.reads.get() + 1); + Some(self.value.clone()) + } + fn write(&mut self, _: clipboard::Kind, _: String) {} + } + struct Select(iced::widget::Id); + impl widget::Operation for Select { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: iced::Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + if id == Some(&self.0) { + state.focus(); + } else { + state.unfocus(); + } + } + } + fn focused_cursor(tree: &widget::Tree) -> Option { + type InputState = + iced::widget::text_input::State<::Paragraph>; + if tree.tag == widget::tree::Tag::of::() { + let state = tree.state.downcast_ref::(); + if state.is_focused() { + return Some(state.cursor()); + } + } + tree.children.iter().find_map(focused_cursor) + } + for (pasted, pending) in [ + ("foo\nbar", false), + ("foo\tbar", false), + ("foo\r\nbar", false), + ("foo\0bar", false), + ("water_flow", false), + ("foo\nbar", true), + ] { + let rejected = pasted.chars().any(char::is_control); + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let stale_confirm = preview(&mut app); + let pending_completion = pending.then(|| { + let job = start_preview(&mut app); + preview_completion(&app, job) + }); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let generation = form.generation; + let mut messages = Vec::new(); + let editor = text_editor::Content::::new(); + let mut tree; + let selected; + { + let mut view = dialog_content(&app, &editor); + tree = widget::Tree::new(view.as_widget()); + let size = iced::Size::new(708., 580.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, size), + ); + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &FontLayout, + &mut Select(input_id(invocation)), + ); + // Iced visits text_input before focusable. A separate traversal + // selects after focus(), which otherwise moves the caret to the end. + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &FontLayout, + &mut widget::operation::text_input::select_range(input_id(invocation), 0, 10), + ); + selected = focused_cursor(&tree).unwrap(); + assert_eq!( + selected.state(&iced::widget::text_input::Value::new("send_water")), + iced::widget::text_input::cursor::State::Selection { start: 0, end: 10 } + ); + let mut clipboard = ClipboardText { + value: pasted.into(), + reads: Cell::new(0), + }; + for event in [ + iced::Event::Keyboard(keyboard::Event::ModifiersChanged(Modifiers::COMMAND)), + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Character("v".into()), + modified_key: Key::Character("v".into()), + physical_key: Physical::Code(Code::KeyV), + location: keyboard::Location::Standard, + // Iced's paste branch deliberately uses the stored modifiers. + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }), + ] { + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut clipboard, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + if matches!( + event, + iced::Event::Keyboard(keyboard::Event::KeyPressed { .. }) + ) { + assert!(shell.is_event_captured()); + } + } + assert_eq!( + clipboard.reads.get(), + 1, + "child uses the checked clipboard snapshot" + ); + if rejected { + assert_eq!(focused_cursor(&tree), Some(selected)); + } + } + assert_eq!(messages.len(), 1); + if rejected { + assert!( + matches!(messages[0], Message::SourceRename(Event::PasteRejected(id)) if id == invocation) + ); + } else { + assert!( + matches!(&messages[0], Message::SourceRename(Event::Input(id, value)) if *id == invocation && value == pasted), + "paste must replace the entire active selection: {:?}", + messages[0] + ); + } + send(&mut app, messages.remove(0)); + let form = app.source_rename.form.as_ref().unwrap(); + assert_ne!(form.generation, generation); + assert!(form.approval.is_none()); + assert_eq!( + form.replacement, + if rejected { "send_water" } else { pasted } + ); + if rejected { + assert!(form.notice.contains("Paste rejected")); + let view = dialog_content(&app, &editor); + tree.diff(view.as_widget()); + assert_eq!( + focused_cursor(&tree), + Some(selected), + "refusal feedback retains mounted selection" + ); + } + send(&mut app, stale_confirm); + assert_eq!(evidence(&app), before); + if let Some(completion) = pending_completion { + assert_eq!(app.source_rename.in_flight, Some(completion.job.id)); + assert!(app.source_rename.form.as_ref().unwrap().pending.is_none()); + send(&mut app, Message::SourceRenameReady(completion)); + assert!(app.source_rename.in_flight.is_none()); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + assert_eq!(evidence(&app), before); + } + } +} diff --git a/crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs b/crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs new file mode 100644 index 00000000..ea37c4d4 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs @@ -0,0 +1,313 @@ +//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint. +//! Child of source_rename_timing_tests, preserving the separate real App test. +use super::*; +use crate::{highlight, views::source_tests::FontLayout}; +use iced::advanced::widget::operation::Focusable; +use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree}; +use iced::{ + Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation, +}; + +// The same real Paragraph and Editor as FontLayout. During draw, retain only a +// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor. +#[derive(Default)] +struct ObservedFontLayout { + drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>, +} +impl renderer::Renderer for ObservedFontLayout { + fn start_layer(&mut self, _: Rectangle) {} + fn end_layer(&mut self) {} + fn start_transformation(&mut self, _: Transformation) {} + fn end_transformation(&mut self) {} + fn reset(&mut self, _: Rectangle) { + self.drawn = None; + } + fn fill_quad(&mut self, _: renderer::Quad, _: impl Into) {} + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} +impl shaped::Renderer for ObservedFontLayout { + type Font = Font; + type Paragraph = ::Paragraph; + type Editor = ::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::ICED_LOGO; + fn default_font(&self) -> Font { + shaped::Renderer::default_font(&FontLayout) + } + fn default_size(&self) -> Pixels { + shaped::Renderer::default_size(&FontLayout) + } + fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {} + fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) { + self.drawn = Some((editor.downgrade(), position, clip)); + } + fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} +} + +type Content = text_editor::Content; +const HOST: Size = Size::new(640., 350.); + +fn view(editor: &Content, source: Arc) -> Element<'_, (), iced::Theme, ObservedFontLayout> { + text_editor(editor) + .on_action(|_| ()) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(Length::Fill) + .highlight_with::(source, highlight::format) + .into() +} + +fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) { + assert_eq!(editor.text(), expected); + assert_eq!(editor.cursor(), cursor); + let lines: Vec<_> = expected.split('\n').collect(); + assert_eq!(editor.line_count(), lines.len()); + for (index, part) in lines.iter().enumerate() { + let (text, ending) = if index + 1 == lines.len() { + (*part, text_editor::LineEnding::None) + } else if let Some(text) = part.strip_suffix('\r') { + (text, text_editor::LineEnding::CrLf) + } else { + (*part, text_editor::LineEnding::Lf) + }; + let actual = editor.line(index).unwrap(); + assert_eq!(actual.text, text); + assert_eq!(actual.ending, ending); + } +} + +fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) { + let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw"); + let editor = weak.upgrade().expect("drawn editor is still live"); + let buffer = editor.buffer(); + assert_eq!(weak.bounds, Size::new(608., 318.)); + assert_eq!(buffer.size(), (Some(608.), Some(318.))); + assert_eq!(buffer.metrics().font_size, 14.); + assert_eq!( + buffer.scroll().horizontal, + 0., + "fixture origin fits the viewport" + ); + let start = cursor.position; + let end = cursor.selection.expect("approved alias selection"); + assert_eq!(start.line, end.line); + assert_eq!(end.column - start.column, alias.len()); + let line = &buffer.lines[start.line]; + assert_eq!(&line.text()[start.column..end.column], alias); + let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139)); + let font = graphics::text::to_attributes(Font::MONOSPACE); + for byte in start.column..end.column { + let attrs = line.attrs_list().get_span(byte); + assert_eq!( + attrs.color_opt, + Some(color), + "accepted alias syntax attributes" + ); + assert_eq!(attrs.family, font.family); + } + let mut covered = vec![false; alias.len()]; + for run in buffer.layout_runs().filter(|run| run.line_i == start.line) { + for glyph in run.glyphs { + let from = glyph.start.max(start.column); + let to = glyph.end.min(end.column); + if from >= to { + continue; + } + assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color"); + let x = position.x + glyph.x + glyph.x_offset * glyph.font_size; + let y = position.y + run.line_top; + assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5); + assert!(y + run.line_height > clip.y && y < clip.y + clip.height); + covered[from - start.column..to - start.column].fill(true); + } + } + assert!( + covered.into_iter().all(|seen| seen), + "entire alias has visible shaped glyphs" + ); + // This temporary strong upgrade is dropped before the next editor mutation. +} + +#[derive(Clone, Copy)] +enum Arm { + Constructor, + Reuse, +} +impl Arm { + fn name(self) -> &'static str { + match self { + Self::Constructor => "with_text", + Self::Reuse => "same_line_reuse", + } + } + fn phases(self) -> [&'static str; 4] { + match self { + Self::Constructor => [ + "constructor_replace_origin", + "constructor_next_layout", + "constructor_highlight_draw", + "constructor_combined", + ], + Self::Reuse => [ + "reuse_replace_origin", + "reuse_next_layout", + "reuse_highlight_draw", + "reuse_combined", + ], + } + } +} + +fn trial( + fixture: &Fixture, + cursor: text_editor::Cursor, + arm: Arm, + trial: usize, + samples: &mut Samples, +) { + let before = &fixture.bundle.files[ORIGIN]; + let after = &fixture.expected_bundle.files[ORIGIN]; + let mut editor = Content::with_text(before); + editor.move_to(fixture.cursor); + let mut renderer = ObservedFontLayout::default(); + let theme = iced::Theme::Light; + let defaults = renderer::Style::default(); + let viewport = Rectangle::with_size(HOST); + let limits = layout::Limits::new(Size::ZERO, HOST); + let mut tree = { + let mut widget = view(&editor, Arc::from(before.as_str())); + let mut tree = Tree::new(widget.as_widget()); + let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits); + tree.state + .downcast_mut::>() + .focus(); + widget.as_widget().draw( + &tree, + &mut renderer, + &theme, + &defaults, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + tree + }; + check_text(&editor, before, fixture.cursor); + check_draw(&renderer, fixture.cursor, OLD); + // No recorder Weak/Strong from setup may perturb replacement ownership. + renderer.drawn = None; + let next_source: Arc = Arc::from(after.as_str()); + + // Only normal operations and four timestamps occur between start and end. + // Source/model oracles, logging and recorder glyph inspection are outside. + let started = Instant::now(); + let replaced = match arm { + Arm::Constructor => { + editor = Content::with_text(after); + true + } + Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after), + }; + editor.move_to(cursor); + let replaced_at = Instant::now(); + let mut widget = view(&editor, next_source); + let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits); + let laid_out_at = Instant::now(); + widget.as_widget().draw( + &tree, + &mut renderer, + &theme, + &defaults, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + let drawn_at = Instant::now(); + drop(widget); + + assert!(replaced, "reuse must not silently time a fallback/refusal"); + check_text(&editor, after, cursor); + check_draw(&renderer, cursor, NEW); + renderer.drawn = None; + for (phase, duration) in arm.phases().into_iter().zip([ + replaced_at.duration_since(started), + laid_out_at.duration_since(replaced_at), + drawn_at.duration_since(laid_out_at), + drawn_at.duration_since(started), + ]) { + println!( + "{}", + serde_json::json!({ + "record": "editor_layout_timing", "arm": arm.name(), "phase": phase, + "trial": trial, "warmup": trial == 0, + "elapsed_ns": duration.as_nanos().to_string(), + "elapsed_ms": duration.as_secs_f64() * 1000.0, + }) + ); + if trial != 0 { + samples.0.entry(phase).or_default().push(duration); + } + } +} + +#[test] +#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"] +fn accepted_release1_constructor_vs_reuse_through_next_highlight() { + let fixture = Fixture::load(); + // Resolve the exact production approval and verify full Project/source once, + // outside all intervals. The actual App Confirm evaluator remains separate. + let cursor = { + let app = fixture.app(); + let target = resolve(&app, &app.rename_context()); + let prepared = app + .document + .alias_rename_request(target.id, NEW.into()) + .unwrap() + .prepare() + .unwrap(); + fixture.check_candidate(&prepared); + prepare_approval(prepared, &target, ORIGIN).unwrap().cursor + }; + println!( + "{}", + serde_json::json!({ + "record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height, + "font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill", + "active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(), + "after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1, + "measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions), + "scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm", + }) + ); + let mut samples = Samples::default(); + for index in 0..=5 { + let arms = if index % 2 == 0 { + [Arm::Constructor, Arm::Reuse] + } else { + [Arm::Reuse, Arm::Constructor] + }; + println!( + "{}", + serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()}) + ); + for arm in arms { + trial(&fixture, cursor, arm, index, &mut samples); + } + } + samples.report(); +} diff --git a/crates/oa-desktop/src/app/source_rename_timing_tests.rs b/crates/oa-desktop/src/app/source_rename_timing_tests.rs new file mode 100644 index 00000000..b7df39f7 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename_timing_tests.rs @@ -0,0 +1,347 @@ +//! Manual timing through the real Document and App paths. No disk writes, +//! executor polling, layout, GPU work or first-paint measurement occurs here. +//! Register as a test child of app::source_rename to use its actual worker helpers. +use super::*; +use crate::source_workspace::Workspace; +use oa_core::source_project::SourceBundle; +use std::{ + collections::BTreeMap, + path::PathBuf, + time::{Duration, Instant}, +}; + +#[cfg(debug_assertions)] +#[path = "source_rename_layout_timing_tests.rs"] +mod layout_timing_tests; + +const OLD: &str = "oa_restore_work"; +const NEW: &str = "oa_restore_pressure"; +const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia"; +const ORIGIN: &str = "traceability.arcadia"; + +struct Fixture { + bundle: SourceBundle, + project: Project, + expected_bundle: SourceBundle, + expected_project: Project, + target: Id, + cursor: text_editor::Cursor, +} + +impl Fixture { + fn load() -> Self { + // The optional entry selects a parent-pinned copy of this exact fixture, + // not an arbitrary performance generator or a mutable GUI destination. + let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/release1/project.arcadia") + }); + let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap(); + let bundle = loaded.reachable_bundle(); + assert_eq!( + bundle.files.len(), + 30, + "use the preserved release1 walkthrough" + ); + let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap(); + assert_eq!( + document.bundle(), + &bundle, + "fixture must need no stabilization/migration" + ); + assert!(!document.dirty()); + let project = document.project().clone(); + let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap(); + assert_eq!(project.elements[&target].alias, OLD); + assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW)); + let mut expected_project = project.clone(); + expected_project.elements.get_mut(&target).unwrap().alias = NEW.into(); + + // Independent literal source oracle, authored before running the rename. + // Do not replace the alias substring in filenames or the trace's alias. + let mut expected_bundle = bundle.clone(); + let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap(); + assert_eq!( + declarations.matches("oa_activity oa_restore_work ").count(), + 1 + ); + assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4); + *declarations = declarations + .replace( + "oa_activity oa_restore_work ", + "oa_activity oa_restore_pressure ", + ) + .replace("parent @oa_restore_work;", "parent @oa_restore_pressure;"); + let traces = expected_bundle.files.get_mut(ORIGIN).unwrap(); + assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1); + *traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;"); + let source = &bundle.files[ORIGIN]; + let start = source.rfind("@oa_restore_work;").unwrap() + 1; + let position = |offset| { + let prefix = &source[..offset]; + text_editor::Position { + line: prefix.bytes().filter(|byte| *byte == b'\n').count(), + column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + } + }; + let cursor = text_editor::Cursor { + position: position(start), + selection: Some(position(start + OLD.len())), + }; + assert!(nav::exact_cursor(source, cursor)); + println!( + "{}", + serde_json::json!({ + "record": "fixture", "entry": entry, "reachable_files": bundle.files.len(), + "source_bytes": bundle.files.values().map(String::len).sum::(), + "identities": project.identities().len(), "active_file": ORIGIN, + "active_file_bytes": source.len(), "old": OLD, "new": NEW, + "warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions), + "scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering" + }) + ); + Self { + bundle, + project, + expected_bundle, + expected_project, + target, + cursor, + } + } + + fn app(&self) -> App { + let mut app = App::with_project(Project::empty()); + app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.source_workspace.active = ORIGIN.into(); + app.selected = app.project().id; + app.page = Page::Text; + app.sync(); + app.source_editor.move_to(self.cursor); + assert_eq!(app.project(), &self.project); + assert_eq!(app.document.bundle(), &self.bundle); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo()); + assert!(app.rename_admission().is_ok()); + app + } + + fn check_candidate(&self, prepared: &PreparedAliasRename) { + assert!(prepared.changed()); + assert_eq!(prepared.before().bundle, self.bundle); + assert_eq!(prepared.before().decoded.project, self.project); + assert_eq!(prepared.after().bundle, self.expected_bundle); + assert_eq!(prepared.after().decoded.project, self.expected_project); + } + + fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) { + assert!( + std::ptr::eq(app.document.compiled(), candidate_address), + "adopt exact candidate allocation" + ); + assert_eq!(app.project(), &self.expected_project); + assert_eq!(app.document.bundle(), &self.expected_bundle); + assert_eq!(app.source_workspace.bundle(), self.expected_bundle); + assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]); + assert_eq!(app.document.revision(), 1); + assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo()); + assert!(app.document.undo()); + assert_eq!(app.project(), &self.project); + assert_eq!(app.document.bundle(), &self.bundle); + assert!( + !app.document.dirty(), + "live known saved baseline remains authoritative" + ); + assert!( + !app.document.can_undo(), + "exactly one preceding model transaction" + ); + assert!(app.document.redo()); + assert_eq!(app.project(), &self.expected_project); + assert_eq!(app.document.bundle(), &self.expected_bundle); + assert!(std::ptr::eq(app.document.compiled(), candidate_address)); + } +} + +#[derive(Default)] +struct Samples(BTreeMap<&'static str, Vec>); + +impl Samples { + fn time(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T { + let started = Instant::now(); + let result = action(); + let elapsed = started.elapsed(); + // Printing, assertions and dropping the returned value are outside this + // interval. Full Confirm still includes production's own local drops. + println!( + "{}", + serde_json::json!({ + "record": "timing", "phase": phase, "trial": trial, + "warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(), + "elapsed_ms": elapsed.as_secs_f64() * 1000.0 + }) + ); + if trial != 0 { + self.0.entry(phase).or_default().push(elapsed); + } + result + } + + fn report(mut self) { + for (phase, values) in &mut self.0 { + assert_eq!(values.len(), 5); + values.sort(); + println!( + "{}", + serde_json::json!({ + "record": "summary", "phase": phase, "samples": values.len(), + "min_ms": values[0].as_secs_f64() * 1000.0, + "median_ms": values[2].as_secs_f64() * 1000.0, + "max_ms": values[4].as_secs_f64() * 1000.0 + }) + ); + } + } +} + +fn resolve(app: &App, context: &Context) -> Target { + let snapshot = app + .with_source_input(|input| query::admit_owned(input, query::Limits::default())) + .unwrap() + .capture() + .unwrap(); + let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap(); + resolve_target(&snapshot, context, offset).unwrap() +} + +fn active_job(app: &App) -> Job { + let form = app.source_rename.form.as_ref().unwrap(); + let id = form.pending.unwrap(); + assert_eq!(app.source_rename.in_flight, Some(id)); + Job { + id, + invocation: form.invocation, + generation: form.generation, + context: form.context.clone(), + } +} + +fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) { + let mut app = fixture.app(); + let target = resolve(&app, &app.rename_context()); + assert_eq!(target.id, fixture.target); + let request = samples + .time("document_request_mint", trial, || { + app.document.alias_rename_request(target.id, NEW.into()) + }) + .unwrap(); + let prepared = samples + .time("worker_document_prepare", trial, || request.prepare()) + .unwrap(); + fixture.check_candidate(&prepared); + let approval = samples + .time("worker_preview_and_origin", trial, || { + prepare_approval(prepared, &target, ORIGIN) + }) + .unwrap(); + assert_eq!(approval.preview.files().len(), 2); + assert_eq!(approval.preview.changed_line_count(), 6); + let candidate_address = approval.prepared.after() as *const CompiledSourceProject; + // Same-destination save completion is a legitimate live update after Preview. + app.document.mark_saved(app.document.snapshot()); + let changed = samples + .time("document_guarded_commit", trial, || { + app.document.commit_alias_rename(&approval.prepared) + }) + .unwrap(); + assert!(changed); + samples.time("app_sync", trial, || app.sync()); + fixture.check_adopted(&mut app, candidate_address); +} + +fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) { + let mut app = fixture.app(); + let begin = app.source_rename_message(); + // These Tasks are deliberately not polled: deterministic worker helpers + // supply the same results through the real readiness messages below. + drop(app.update(begin)); + let target_job = active_job(&app); + let target = Arc::new(resolve(&app, &target_job.context)); + assert_eq!(target.id, fixture.target); + drop(app.update(Message::SourceRenameReady(Completion { + job: target_job, + payload: Payload::Target(Ok(target.clone())), + }))); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into())))); + let generation = app.source_rename.form.as_ref().unwrap().generation; + drop(app.update(Message::SourceRename(Event::Preview( + invocation, generation, + )))); + let preview_job = active_job(&app); + let prepared = app + .document + .alias_rename_request(target.id, NEW.into()) + .unwrap() + .prepare() + .unwrap(); + fixture.check_candidate(&prepared); + let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap()); + drop(target); + let candidate_address = approval.prepared.after() as *const CompiledSourceProject; + let expected_cursor = approval.cursor; + let approval_id = approval.id; + drop(app.update(Message::SourceRenameReady(Completion { + job: preview_job, + payload: Payload::Preview(Ok(approval)), + }))); + assert_eq!( + app.source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .id, + approval_id + ); + app.document.mark_saved(app.document.snapshot()); + let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id)); + let task = samples.time("app_confirm_update", trial, || app.update(confirm)); + samples.time("confirm_returned_task_drop_without_polling", trial, || { + drop(task) + }); + assert!(!app.source_rename.open()); + assert!(app.source_rename.in_flight.is_none()); + assert!(app.source_rename_undo_message().is_some()); + assert_eq!(app.source_editor.cursor(), expected_cursor); + fixture.check_adopted(&mut app, candidate_address); +} + +#[test] +#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"] +fn accepted_release1_prepare_commit_sync_and_confirm_timing() { + let fixture = Fixture::load(); + let mut samples = Samples::default(); + for trial in 0..=5 { + println!( + "{}", + serde_json::json!({ "record": "order", "trial": trial, + "warmup": trial == 0, "separate_phases_first": trial % 2 == 0 }) + ); + if trial % 2 == 0 { + separate_phases(&fixture, &mut samples, trial); + whole_confirm(&fixture, &mut samples, trial); + } else { + whole_confirm(&fixture, &mut samples, trial); + separate_phases(&fixture, &mut samples, trial); + } + } + samples.report(); +} diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index d199e964..03aaade7 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -52,7 +52,7 @@ impl App { } } - fn with_source_input(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T { + pub(super) fn with_source_input(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T { let files: Vec<_> = self .source_workspace .files @@ -83,13 +83,14 @@ impl App { }) } - fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool { + pub(super) fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool { snapshot.key() == self.source_key() && self.with_source_input(|input| snapshot.matches(input)) } pub fn source_navigation_available(&self) -> bool { self.page == Page::Text + && !self.source_rename.open() && !self.busy && self.pending.is_none() && self.file_prompt.is_none() diff --git a/crates/oa-desktop/src/document.rs b/crates/oa-desktop/src/document.rs index 8369645d..e68c312a 100644 --- a/crates/oa-desktop/src/document.rs +++ b/crates/oa-desktop/src/document.rs @@ -10,6 +10,10 @@ use std::{fmt, sync::Arc}; const HISTORY_LIMIT: usize = 100; const HISTORY_BYTES: usize = 32 * 1024 * 1024; +#[path = "document_alias_rename.rs"] +mod alias_rename; +pub use alias_rename::PreparedAliasRename; + struct HistoryEntry { snapshot: Arc, bytes: usize, @@ -91,6 +95,8 @@ impl From> for DocumentError { } pub struct Document { + // Distinguishes equal accepted snapshots belonging to different documents. + alias_owner: Arc<()>, current: Arc, past: Vec, future: Vec, @@ -121,6 +127,7 @@ impl Document { let current = Arc::new(source_project::compile_bundle(&bundle, limits)?); let current_bytes = estimated_bytes(¤t); Ok(Self { + alias_owner: Arc::new(()), current, current_bytes, past: Vec::new(), @@ -394,8 +401,17 @@ impl Document { return false; } let bytes = estimated_bytes(&compiled); + self.commit_snapshot(Arc::new(compiled), bytes) + } + + // Only validated internal paths supply a changed snapshot and its exact + // estimate. The ordinary commit wrapper checks bundle inequality; the alias + // path checks its private changed flag and exact accepted-snapshot authority. + // Alias preparation shares this algorithm while retaining the live save and + // history state, and does not clone/recompile the candidate on adoption. + fn commit_snapshot(&mut self, compiled: Arc, bytes: usize) -> bool { self.past.push(HistoryEntry { - snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)), + snapshot: std::mem::replace(&mut self.current, compiled), bytes: std::mem::replace(&mut self.current_bytes, bytes), }); self.future.clear(); diff --git a/crates/oa-desktop/src/document_alias_rename.rs b/crates/oa-desktop/src/document_alias_rename.rs new file mode 100644 index 00000000..aa98e63b --- /dev/null +++ b/crates/oa-desktop/src/document_alias_rename.rs @@ -0,0 +1,233 @@ +//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do +//! not authorize adoption; only this module can mint a prepared rename. +use super::*; +use std::collections::BTreeMap; + +/// A pinned intent minted by its owning Document. Move it to a worker and call +/// `prepare`; preparation has no access to the live Document or its histories. +#[derive(Debug)] +pub struct AliasRenameRequest { + owner: Arc<()>, + before: Arc, + revision: u64, + limits: ProjectLimits, + target: Id, + old_alias: String, + new_alias: String, +} + +#[derive(Debug)] +struct PreparedData { + request: AliasRenameRequest, + after: Arc, + before_bytes: usize, + after_bytes: usize, + changed: bool, +} + +/// Immutable, Clone-compatible transport for preview and approval. Neither +/// mutable candidate access nor deserialization can manufacture this authority. +#[derive(Debug, Clone)] +pub struct PreparedAliasRename(Arc); + +impl PreparedAliasRename { + pub fn before(&self) -> &CompiledSourceProject { + &self.0.request.before + } + + pub fn after(&self) -> &CompiledSourceProject { + &self.0.after + } + + pub fn target(&self) -> Id { + self.0.request.target + } + + pub fn old_alias(&self) -> &str { + &self.0.request.old_alias + } + + pub fn new_alias(&self) -> &str { + &self.0.request.new_alias + } + + pub fn revision(&self) -> u64 { + self.0.request.revision + } + + pub fn changed(&self) -> bool { + self.0.changed + } +} + +fn refusal(message: &str) -> DocumentError { + ModelError(message.into()).into() +} + +fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> { + if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES { + return Err(refusal( + "This rename exceeds the model Undo budget. No rename was applied.", + )); + } + Ok(()) +} + +impl Document { + pub fn alias_rename_request( + &self, + target: Id, + replacement: String, + ) -> Result { + if self.revision == u64::MAX { + return Err(refusal( + "The document revision is exhausted. Reopen before renaming.", + )); + } + let old_alias = self + .project() + .identities() + .into_iter() + .find_map(|(id, alias)| (id == target).then(|| alias.to_owned())) + .ok_or_else(|| refusal("The alias target is no longer present."))?; + // A candidate has a nonzero estimate. Avoid expensive preparation when + // even the current snapshot leaves no room for its immediate Undo. + if old_alias != replacement && self.current_bytes >= HISTORY_BYTES { + return Err(refusal( + "This rename exceeds the model Undo budget. No rename was applied.", + )); + } + Ok(AliasRenameRequest { + owner: self.alias_owner.clone(), + before: self.current.clone(), + revision: self.revision, + limits: self.limits, + target, + old_alias, + new_alias: replacement, + }) + } + + /// App must separately consume its current approval and check workspace, + /// invocation and draft freshness. This checks accepted Document authority. + pub fn commit_alias_rename( + &mut self, + prepared: &PreparedAliasRename, + ) -> Result { + let data = &prepared.0; + let request = &data.request; + if self.revision == u64::MAX + || self.revision != request.revision + || !Arc::ptr_eq(&self.alias_owner, &request.owner) + || !Arc::ptr_eq(&self.current, &request.before) + { + return Err(refusal( + "This rename preview is stale. Preview the current alias again.", + )); + } + if !data.changed { + return Ok(false); + } + if self.current_bytes != data.before_bytes { + return Err(refusal( + "The rename history estimate is stale. Preview again.", + )); + } + check_history_capacity(self.current_bytes, data.after_bytes)?; + Ok(self.commit_snapshot(data.after.clone(), data.after_bytes)) + } +} + +impl AliasRenameRequest { + /// Run on the preparation worker. The typed command and source updater are + /// the only candidate construction path, followed by alias-only invariants. + pub fn prepare(self) -> Result { + let before_project = &self.before.decoded.project; + let candidate = prepare_command( + before_project, + &Command::SetAlias(self.target, self.new_alias.clone()), + )?; + let mut expected = before_project.clone(); + assign_expected_alias(&mut expected, self.target, &self.new_alias)?; + let identities: BTreeMap<_, _> = before_project + .identities() + .into_iter() + .map(|(id, _)| (id, id)) + .collect(); + if candidate.project != expected || candidate.identity_map != identities { + return Err(refusal( + "Alias preparation changed unrelated model data or identities.", + )); + } + let after = source_project::update_bundle_with_identity_map( + &self.before, + &candidate.project, + &candidate.identity_map, + self.limits, + )?; + let before = &self.before; + if after.decoded.project != expected + || before.bundle.entry != after.bundle.entry + || !before.bundle.files.keys().eq(after.bundle.files.keys()) + || before.index.lexical_owners != after.index.lexical_owners + || !before + .index + .declarations + .iter() + .map(|(id, loc)| (id, &loc.path)) + .eq(after + .index + .declarations + .iter() + .map(|(id, loc)| (id, &loc.path))) + { + return Err(refusal( + "Alias preparation changed source membership or ownership.", + )); + } + let changed = before.bundle != after.bundle; + let before_bytes = estimated_bytes(before); + let after_bytes = estimated_bytes(&after); + if changed { + check_history_capacity(before_bytes, after_bytes)?; + } + Ok(PreparedAliasRename(Arc::new(PreparedData { + request: self, + after: Arc::new(after), + before_bytes, + after_bytes, + changed, + }))) + } +} + +// Independent full-model expectation: SetAlias may change exactly one header +// field, across every supported identity family. Do not reuse command execution +// to derive the expected result, which would hide collateral command changes. +fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> { + if project.id == id { + project.alias = alias.to_owned(); + } else if let Some(record) = project.elements.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.relations.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.diagrams.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.artifact_links.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.traces.get_mut(&id) { + record.alias = alias.to_owned(); + } else { + project + .diagrams + .values_mut() + .find_map(|diagram| diagram.nodes.get_mut(&id)) + .ok_or_else(|| refusal("The alias target is no longer present."))? + .alias = alias.to_owned(); + } + Ok(()) +} + +#[cfg(test)] +#[path = "document_alias_rename_tests.rs"] +mod tests; diff --git a/crates/oa-desktop/src/document_alias_rename_tests.rs b/crates/oa-desktop/src/document_alias_rename_tests.rs new file mode 100644 index 00000000..5e773f46 --- /dev/null +++ b/crates/oa-desktop/src/document_alias_rename_tests.rs @@ -0,0 +1,459 @@ +use super::*; + +fn fixture() -> Document { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), r#"arcadia 7; +project mission "Испытание" id "10000000-0000-4000-8000-000000000001" { + include "work.arcadia"; include "views.arcadia"; +} +"#.replace('\n', "\r\n")), + ("work.arcadia".into(), r#"// @assess and assess are explanatory prose. +oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; } +oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence + assess; } +sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {} +interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; } +trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; } +artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; } +"#.replace('\n', "\r\n")), + ("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity + transfer; waypoints [{ x 4.20e2; y -16; }]; }]; } +node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; } +"#.replace('\n', "\r\n")), + ]), + }; + let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap(); + assert_eq!( + document.bundle(), + &bundle, + "fixture must need no migration or ID insertion" + ); + document +} + +fn id(number: u8) -> Id { + Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap() +} + +fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename { + document + .alias_rename_request(target, replacement.into()) + .unwrap() + .prepare() + .unwrap() +} + +// Capture every live Document state relevant to refusal/no-op. Arc identity also +// catches replacing an equal snapshot or saved marker behind the user's back. +struct Unchanged { + current: Arc, + saved: Option>, + past: Vec<(Arc, usize)>, + future: Vec<(Arc, usize)>, + revision: u64, + current_bytes: usize, + recovered: bool, +} + +impl Unchanged { + fn capture(document: &Document) -> Self { + Self { + current: document.snapshot(), + saved: document.saved.clone(), + past: document + .past + .iter() + .map(|h| (h.snapshot.clone(), h.bytes)) + .collect(), + future: document + .future + .iter() + .map(|h| (h.snapshot.clone(), h.bytes)) + .collect(), + revision: document.revision, + current_bytes: document.current_bytes, + recovered: document.recovered_unsaved, + } + } + + fn assert(&self, document: &Document) { + assert!(Arc::ptr_eq(&self.current, &document.current)); + match (&self.saved, &document.saved) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), + (None, None) => {} + _ => panic!("saved baseline changed"), + } + for (before, after) in [ + (&self.past, &document.past), + (&self.future, &document.future), + ] { + assert_eq!(before.len(), after.len()); + for ((snapshot, bytes), entry) in before.iter().zip(after) { + assert!(Arc::ptr_eq(snapshot, &entry.snapshot)); + assert_eq!(*bytes, entry.bytes); + } + } + assert_eq!(self.revision, document.revision); + assert_eq!(self.current_bytes, document.current_bytes); + assert_eq!(self.recovered, document.recovered_unsaved); + } +} + +#[test] +fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() { + // Literal role-specific replacements are independent of the updater/tokenizer. + // They deliberately exclude prose, historical review text and URI spelling. + type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]); + let cases: &[AliasCase<'_>] = &[ + ( + 1, + "mission", + "renamed", + &[("project mission ", "project renamed ")], + ), + ( + 2, + "assess", + "renamed", + &[ + ("oa_activity assess ", "oa_activity renamed "), + ("\r\n assess;", "\r\n renamed;"), + ("@assess;", "@renamed;"), + ], + ), + ( + 5, + "transfer", + "renamed", + &[ + ("interaction transfer ", "interaction renamed "), + ("\r\n transfer;", "\r\n renamed;"), + ], + ), + ( + 6, + "review", + "renamed", + &[ + ("trace review ", "trace renamed "), + ("owner @review;", "owner @renamed;"), + ], + ), + ( + 7, + "evidence", + "renamed", + &[("artifact evidence ", "artifact renamed ")], + ), + ( + 8, + "overview", + "renamed", + &[ + ("diagram overview ", "diagram renamed "), + ("diagram @overview;", "diagram @renamed;"), + ], + ), + ( + 9, + "placement", + "renamed", + &[("node placement ", "node renamed ")], + ), + ]; + for &(number, old, new, replacements) in cases { + let mut document = fixture(); + let original = document.snapshot(); + let mut expected_model = document.project().clone(); + // Explicit per-family oracle, separate from production expectation helper. + match number { + 1 => expected_model.alias = new.into(), + 2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(), + 5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(), + 6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(), + 7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(), + 8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(), + 9 => { + expected_model + .diagrams + .get_mut(&id(8)) + .unwrap() + .nodes + .get_mut(&id(9)) + .unwrap() + .alias = new.into() + } + _ => unreachable!(), + } + let mut expected_bundle = document.bundle().clone(); + for source in expected_bundle.files.values_mut() { + for &(before, after) in replacements { + *source = source.replace(before, after); + } + } + let state = Unchanged::capture(&document); + let prepared = prepare(&document, id(number), new); + state.assert(&document); + assert!(prepared.changed()); + assert_eq!(prepared.old_alias(), old); + assert_eq!(prepared.new_alias(), new); + assert_eq!(prepared.target(), id(number)); + assert_eq!(prepared.revision(), 0); + assert_eq!(prepared.before().bundle, original.bundle); + assert_eq!(prepared.after().bundle, expected_bundle); + assert_eq!(prepared.after().decoded.project, expected_model); + assert!(document.commit_alias_rename(&prepared).unwrap()); + assert!( + Arc::ptr_eq(&document.current, &prepared.0.after), + "adopt the exact prepared Arc" + ); + assert_eq!(document.past.len(), 1); + assert!(document.dirty()); + let after = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&prepared.clone()).is_err()); + after.assert(&document); + assert!(document.undo()); + assert!(Arc::ptr_eq(&document.current, &original)); + assert!(!document.dirty()); + assert!(!document.can_undo()); + assert!(document.redo()); + assert_eq!(document.bundle(), &expected_bundle); + assert_eq!(document.project(), &expected_model); + document.mark_saved(document.snapshot()); + let reopened = + Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), &expected_model); + assert_eq!(reopened.bundle(), &expected_bundle); + assert!(!reopened.dirty()); + } +} + +#[test] +fn highlighted_keyword_alias_is_valid_without_normalizing_input() { + assert!(matches!( + language::tokenize("oa_activity")[0].class, + language::TokenClass::Keyword + )); + let mut document = fixture(); + let prepared = prepare(&document, id(2), "oa_activity"); + document.commit_alias_rename(&prepared).unwrap(); + assert_eq!(document.project().elements[&id(2)].alias, "oa_activity"); + assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба"); +} + +#[test] +fn invalid_and_identical_aliases_preserve_live_state_and_redo() { + let mut document = fixture(); + let first = prepare(&document, id(2), "changed"); + document.commit_alias_rename(&first).unwrap(); + assert!(document.undo()); + document.invalidate_saved_baseline(); + document.mark_recovered(); + let state = Unchanged::capture(&document); + for replacement in ["", "has space", "project", "assist", " assess", "assess "] { + assert!( + document + .alias_rename_request(id(2), replacement.into()) + .unwrap() + .prepare() + .is_err() + ); + state.assert(&document); + } + assert!( + document + .alias_rename_request(Id::nil(), "new".into()) + .is_err() + ); + let no_op = prepare(&document, id(2), "assess"); + assert!(!no_op.changed()); + assert!(!document.commit_alias_rename(&no_op).unwrap()); + state.assert(&document); + assert!(document.redo()); + assert_eq!(document.project().elements[&id(2)].alias, "changed"); +} + +#[test] +fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() { + let mut document = fixture(); + let old = prepare(&document, id(2), "renamed"); + let mut second = fixture(); + let second_state = Unchanged::capture(&second); + assert!(second.commit_alias_rename(&old).is_err()); + second_state.assert(&second); + // Even an identical shared accepted Arc cannot confer this Document's owner. + second.current = document.current.clone(); + let second_state = Unchanged::capture(&second); + assert!(second.commit_alias_rename(&old).is_err()); + second_state.assert(&second); + document + .execute(Command::Rename(id(2), "Later name".into())) + .unwrap(); + let edited = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&old).is_err()); + edited.assert(&document); + assert!(document.undo()); + assert!(Arc::ptr_eq(&document.current, &old.0.request.before)); + let undone = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&old).is_err()); + undone.assert(&document); + document.revision = u64::MAX - 1; + let last = prepare(&document, id(2), "renamed"); + document + .execute(Command::Rename(id(2), "Exhaust revision".into())) + .unwrap(); + document.undo(); + assert!(Arc::ptr_eq(&document.current, &last.0.request.before)); + let exhausted = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&last).is_err()); + assert!( + document + .alias_rename_request(id(2), "renamed".into()) + .is_err() + ); + assert!( + document + .alias_rename_request(id(2), "assess".into()) + .is_err() + ); + exhausted.assert(&document); +} + +#[test] +fn preview_retains_latest_saved_baseline_and_recovery_state() { + for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] { + let mut document = fixture(); + let prepared = prepare(&document, id(2), "renamed"); + let original = document.snapshot(); + match baseline { + "saved_before" => document.mark_saved(original.clone()), + "unknown" => document.invalidate_saved_baseline(), + "raw_repair" => { + let mut raw = document.bundle().clone(); + raw.files + .insert("project.arcadia".into(), "invalid repair source".into()); + document.bind_repair_baseline(raw); + } + "recovered" => document.mark_recovered(), + _ => unreachable!(), + } + let saved = document.saved.clone(); + let recovered = document.recovered_unsaved; + assert!(document.commit_alias_rename(&prepared).unwrap()); + assert_eq!(document.saved, saved); + assert_eq!(document.recovered_unsaved, recovered); + assert!(document.dirty()); + assert!(document.undo()); + assert_eq!(document.saved, saved); + assert_eq!(document.recovered_unsaved, recovered); + assert_eq!(document.dirty(), baseline != "saved_before"); + } + // A completed save after Preview remains authoritative, including when it + // clears recovery; a worker must not restore its earlier state on Confirm. + let mut document = fixture(); + document.mark_recovered(); + document.invalidate_saved_baseline(); + let prepared = prepare(&document, id(2), "renamed"); + document.mark_saved(document.snapshot()); + document.commit_alias_rename(&prepared).unwrap(); + assert!(!document.recovered_unsaved); + document.undo(); + assert!(!document.dirty()); + // Undo from saved C to B, then a new B->C rename can correctly finish clean. + document.redo(); + document.mark_saved(document.snapshot()); + document.undo(); + let return_to_saved = prepare(&document, id(2), "renamed"); + document.commit_alias_rename(&return_to_saved).unwrap(); + assert!(!document.dirty()); + assert!(!document.can_redo()); +} + +#[test] +fn mutable_public_preview_copies_cannot_change_prepared_authority() { + let mut document = fixture(); + let prepared = prepare(&document, id(2), "renamed"); + let exact = prepared.after().clone(); + let mut counterfeit = prepared.after().clone(); + counterfeit.decoded.project.name = "Injected unrelated edit".into(); + counterfeit.index.declarations.clear(); + let unchanged = Unchanged::capture(&document); + assert!(document.apply_compiled(counterfeit).is_err()); + unchanged.assert(&document); + let mut unrelated = exact.decoded.project.clone(); + unrelated.name = "A self-consistent unrelated candidate".into(); + let forged_dto = source_project::compile_bundle( + &source_project::format_bundle(&unrelated), + ProjectLimits::default(), + ) + .unwrap(); + assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project); + // No API converts either DTO to PreparedAliasRename. Mutating owned copies + // leaves the opaque capability and its exact adopted model/source intact. + document.commit_alias_rename(&prepared).unwrap(); + assert_eq!(document.project(), &exact.decoded.project); + assert_eq!(document.bundle(), &exact.bundle); +} + +#[test] +fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() { + let mut document = fixture(); + let mut bundle = document.bundle().clone(); + let padding = HISTORY_BYTES / 2 - document.current_bytes; + bundle + .files + .get_mut("views.arcadia") + .unwrap() + .push_str(&format!("//{}\r\n", "x".repeat(padding - 4))); + document.apply_bundle(bundle).unwrap(); + assert_eq!(document.current_bytes, HISTORY_BYTES / 2); + // A same-length alias retains the exact estimate; older history is evictable. + let boundary = prepare(&document, id(2), "rating"); + assert_eq!( + boundary.0.before_bytes + boundary.0.after_bytes, + HISTORY_BYTES + ); + let unchanged = Unchanged::capture(&document); + assert!( + document + .alias_rename_request(id(2), "longer_alias".into()) + .unwrap() + .prepare() + .is_err() + ); + unchanged.assert(&document); + document.commit_alias_rename(&boundary).unwrap(); + assert_eq!(document.past.len(), 1); + assert!(document.undo()); + assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before)); + assert!(!document.can_undo()); + assert!(document.redo()); + assert!(Arc::ptr_eq(&document.current, &boundary.0.after)); + assert!(check_history_capacity(usize::MAX, 1).is_err()); + assert!(check_history_capacity(HISTORY_BYTES, 1).is_err()); +} + +#[test] +fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() { + let mut document = fixture(); + let prepared = prepare(&document, id(2), "renamed"); + // Inject cache corruption to falsify the commit-side check independently of + // the preparation admission. Normal edits also change revision and Arc. + document.current_bytes = HISTORY_BYTES; + let unchanged = Unchanged::capture(&document); + assert!( + document + .alias_rename_request(id(2), "renamed".into()) + .is_err() + ); + assert!(document.commit_alias_rename(&prepared).is_err()); + // No-op intent needs no Undo reservation even at the admission boundary. + assert!( + document + .alias_rename_request(id(2), "assess".into()) + .is_ok() + ); + unchanged.assert(&document); +} diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index 9db3994e..50a6c6c9 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -805,7 +805,7 @@ fn article(topic: Topic) -> Article { const { Article { summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.", - keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back", + keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename F2 preview diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back", sections: &[ walkthrough( "Edit and review source", @@ -834,6 +834,15 @@ fn article(topic: Topic) -> Article { "Names, aliases and IDs serve different purposes", "The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.", ), + walkthrough( + "Rename a source alias", + &[ + "Place the caret on a declaration alias or live @reference, then choose Rename alias or press F2 in the source editor. First finish or revert pending source, property and behavior drafts; close or reattach any extra loaded detached files. Accepted edits do not need to be saved first.", + "Edit Source alias and choose Preview (Enter also previews). Pasting line breaks, tabs or other control characters is rejected without changing the field; choose Preview again after correcting the paste. Review the changed files, hunks and lines; long lines have additional segments. The engineering name and UUID stay unchanged. Comments and quoted prose keep their original spelling. This preview covers the rename; a later Save can include earlier accepted edits too.", + "Choose Rename alias to accept the complete preview, or Cancel/Escape to leave everything unchanged. An invalid, unchanged, stale or oversized preview cannot be confirmed. After editing the replacement, choose Preview again. If the source context changes, close the form and invoke Rename again on the current alias.", + "Use Undo rename immediately, or the model Undo/Redo controls, to restore the complete source/model snapshot. Ctrl+Z in Text still undoes source drafting. Save all and reopen use the normal project workflow; an unconfirmed preview is not saved.", + ], + ), example( "Readable references", "For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.", diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 703b56bf..cb86659d 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -25,6 +25,7 @@ mod recovery; mod route_edit; mod semantic_review; mod source_navigation; +mod source_rename; mod source_tree; mod source_workspace; mod style; diff --git a/crates/oa-desktop/src/source_navigation/query.rs b/crates/oa-desktop/src/source_navigation/query.rs index 3044fff3..316224b3 100644 --- a/crates/oa-desktop/src/source_navigation/query.rs +++ b/crates/oa-desktop/src/source_navigation/query.rs @@ -220,12 +220,22 @@ pub struct ReferenceRow { pub evidence: Evidence, } +/// Exact lexical occurrence that answered the query, independent of row limits. +/// The optional @ marker is separate from the alias; comments between them do +/// not become part of either hit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Origin { + pub span: Span, + pub marker: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Results { pub stamp: QueryStamp, pub action: Action, pub origin_path: String, pub origin_offset: usize, + pub origin: Option, pub alias: Option, pub scope: Scope, pub disposition: Disposition, @@ -823,10 +833,28 @@ impl Snapshot { .ok_or(QueryError::MissingFile)?; let index = self.index()?; let hit = index.hit(file, query.byte_offset)?; - let alias = match hit { - Some(symbols::Hit::Definition(handle)) => Some(index.definition(handle)?.alias), - Some(symbols::Hit::Reference(handle)) => Some(index.reference(handle)?.alias), - None => None, + let (alias, origin) = match hit { + Some(symbols::Hit::Definition(handle)) => { + let definition = index.definition(handle)?; + ( + Some(definition.alias), + Some(Origin { + span: definition.alias_location.span, + marker: None, + }), + ) + } + Some(symbols::Hit::Reference(handle)) => { + let reference = index.reference(handle)?; + ( + Some(reference.alias), + Some(Origin { + span: reference.alias_location.span, + marker: Some(reference.at.span), + }), + ) + } + None => (None, None), }; let mut budget = RowBudget { rows: self.limits.result_rows, @@ -840,6 +868,7 @@ impl Snapshot { action: query.action, origin_path: query.path.to_owned(), origin_offset: query.byte_offset, + origin, alias: alias.map(str::to_owned), scope: self.scope.clone(), disposition: Disposition::NoSymbol, diff --git a/crates/oa-desktop/src/source_navigation/query_tests.rs b/crates/oa-desktop/src/source_navigation/query_tests.rs index e0c9cde6..0b6bd7e1 100644 --- a/crates/oa-desktop/src/source_navigation/query_tests.rs +++ b/crates/oa-desktop/src/source_navigation/query_tests.rs @@ -107,6 +107,65 @@ fn reference_offset(fixture: &Fixture) -> usize { fixture.text("links.arcadia").find("source @").unwrap() + "source ".len() } +#[test] +fn origin_keeps_exact_hit_when_display_rows_are_omitted() { + let mut fixture = Fixture::standard(); + fixture + .text_mut("links.arcadia") + .push_str("// no @pump hit\r\n@pump @pump @ // intervening @pump\r\n pump"); + let source = fixture.text("links.arcadia"); + let start = source.rfind("pump").unwrap(); + let marker = source.rfind("@ // intervening").unwrap(); + let limits = Limits { + result_rows: 0, + ..Limits::default() + }; + let snapshot = fixture.snapshot(None, false, limits); + let expected = Some(Origin { + span: Span { + start, + end: start + 4, + }, + marker: Some(Span { + start: marker, + end: marker + 1, + }), + }); + for offset in [start, start + 3, marker] { + let result = query(&snapshot, "links.arcadia", offset, Action::References); + assert_eq!(result.origin, expected); + assert!(result.references.is_empty()); + assert!(result.omitted_references > 2); + } + let comment = source.rfind("intervening @pump").unwrap() + "intervening @".len(); + assert_eq!( + query(&snapshot, "links.arcadia", comment, Action::Definition).origin, + None + ); + assert_eq!( + query(&snapshot, "links.arcadia", start + 4, Action::Definition).origin, + None + ); + let definition_source = fixture.text("模块/活動.arcadia"); + let definition_start = definition_source.find("pump").unwrap(); + assert_eq!( + query( + &snapshot, + "模块/活動.arcadia", + definition_start, + Action::Definition + ) + .origin, + Some(Origin { + span: Span { + start: definition_start, + end: definition_start + 4 + }, + marker: None + }) + ); +} + #[test] fn exact_multifile_unicode_crlf_definition_and_repeated_lexical_references() { let fixture = Fixture::standard(); diff --git a/crates/oa-desktop/src/source_rename.rs b/crates/oa-desktop/src/source_rename.rs new file mode 100644 index 00000000..f185672c --- /dev/null +++ b/crates/oa-desktop/src/source_rename.rs @@ -0,0 +1,3 @@ +//! Pure preview and exact origin mapping for the guarded alias-rename controller. +pub mod origin; +pub mod preview; diff --git a/crates/oa-desktop/src/source_rename/origin.rs b/crates/oa-desktop/src/source_rename/origin.rs new file mode 100644 index 00000000..3da6ccb3 --- /dev/null +++ b/crates/oa-desktop/src/source_rename/origin.rs @@ -0,0 +1,278 @@ +//! Map a role-proven alias occurrence through the exact prepared source change. +//! This is cursor restoration, not target resolution or commit authority. +use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass}; +use std::fmt; + +const LIMITS: OutlineLimits = OutlineLimits { + max_source_bytes: 64 * 1024 * 1024, + max_tokens: 1_000_000, + max_declarations: 250_000, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginError { + Budget, + InvalidSource, + MissingOrigin, + ChangedStructure, +} + +impl fmt::Display for OriginError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.", + Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.", + Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.", + Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.", + }) + } +} + +impl std::error::Error for OriginError {} + +fn word(class: TokenClass) -> bool { + matches!(class, TokenClass::Identifier | TokenClass::Keyword) +} + +/// The controller passes the exact alias span returned by the admitted symbol +/// hit. `marker` is Some only for a reference (its separate @ span), including +/// invocation with the caret on @. A declaration passes None. +pub fn map( + before: &str, + after: &str, + old_alias: &str, + new_alias: &str, + origin: Span, + marker: Option, +) -> Result { + let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?; + let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?; + if old.has_lexical_errors || new.has_lexical_errors { + return Err(OriginError::InvalidSource); + } + if old.tokens.len() != new.tokens.len() { + return Err(OriginError::ChangedStructure); + } + let roles_before = roles(&old); + let roles_after = roles(&new); + let mut mapped = None; + for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() { + let left = &before[a.span.start..a.span.end]; + let right = &after[b.span.start..b.span.end]; + let alias_pair = word(a.class) + && word(b.class) + && left == old_alias + && right == new_alias + && roles_before[index].is_some() + && roles_after[index].is_some() + && roles_before[index].map(|role| role.is_some()) + == roles_after[index].map(|role| role.is_some()); + if a.span == origin { + if !alias_pair || roles_before[index] != Some(marker) { + return Err(OriginError::MissingOrigin); + } + mapped = Some(b.span); + } + if !alias_pair && (left != right || a.class != b.class) { + return Err(OriginError::ChangedStructure); + } + } + mapped.ok_or(OriginError::MissingOrigin) +} + +/// Outer None is not an alias role; Some(None) is a declaration alias; +/// Some(Some(span)) is a live @ reference with its exact separate marker. +/// Scan sorted declaration spans and tokens together, without repeated searches. +fn roles(outline: &Outline<'_>) -> Vec>> { + let mut result = vec![None; outline.tokens.len()]; + let mut declarations = outline.declarations.iter().peekable(); + let mut significant = None; + for (index, token) in outline.tokens.iter().enumerate() { + while declarations + .peek() + .is_some_and(|d| d.alias_span.start < token.span.start) + { + declarations.next(); + } + if word(token.class) { + if declarations + .peek() + .is_some_and(|d| d.alias_span == token.span) + { + result[index] = Some(None); + } else if let Some(previous) = significant { + let span: Span = previous; + if &outline.source[span.start..span.end] == "@" { + result[index] = Some(Some(span)); + } + } + } + if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) { + significant = Some(token.span); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn occurrence(source: &str, needle: &str, which: usize) -> Span { + let start = source.match_indices(needle).nth(which).unwrap().0; + Span { + start, + end: start + needle.len(), + } + } + + #[test] + fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() { + let before = + "oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n"; + let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n"; + let start = before.rfind(" a;").unwrap() + 1; + let marker = before.rfind("@ //").unwrap(); + let mapped = map( + before, + after, + "a", + "renamed", + Span { + start, + end: start + 1, + }, + Some(Span { + start: marker, + end: marker + 1, + }), + ) + .unwrap(); + assert_eq!(&after[mapped.start..mapped.end], "renamed"); + assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1); + assert_eq!( + before[..start].bytes().filter(|b| *b == b'\n').count(), + after[..mapped.start] + .bytes() + .filter(|b| *b == b'\n') + .count() + ); + } + + #[test] + fn preserves_final_same_line_occurrence_not_first_or_prose() { + let before = "oa_activity a \"a @a\" {} source @a; target @a;"; + let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;"; + let start = before.rfind("@a").unwrap(); + let result = map( + before, + after, + "a", + "longer", + Span { + start: start + 1, + end: start + 2, + }, + Some(Span { + start, + end: start + 1, + }), + ) + .unwrap(); + assert_eq!(result.start, after.rfind("@longer").unwrap() + 1); + let prose = occurrence(before, "a @a", 0); + assert!( + map( + before, + after, + "a", + "longer", + Span { + start: prose.start, + end: prose.start + 1 + }, + None + ) + .is_err() + ); + } + + #[test] + fn permits_identifier_keyword_transitions_in_alias_roles() { + let before = "oa_activity a \"Name\" {} source @a;"; + let alias = occurrence(before, "a \"Name", 0); + let origin = Span { + start: alias.start, + end: alias.start + 1, + }; + for replacement in ["none", "include", "oa_activity"] { + assert!(oa_core::valid_alias(replacement)); + assert_eq!( + language::tokenize(replacement)[0].class, + TokenClass::Keyword + ); + let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};"); + let mapped = map(before, &after, "a", replacement, origin, None).unwrap(); + assert_eq!(&after[mapped.start..mapped.end], replacement); + assert_eq!( + map(&after, before, replacement, "a", mapped, None).unwrap(), + origin + ); + } + } + + #[test] + fn rejects_changed_trivia_non_alias_words_and_wrong_marker() { + let before = "oa_activity a \"Name\" {} source @a;"; + let after = "oa_activity b \"Name\" {} source @b;"; + let start = before.rfind("@a").unwrap(); + let span = Span { + start: start + 1, + end: start + 2, + }; + let marker = Some(Span { + start, + end: start + 1, + }); + assert_eq!( + map( + before, + &after.replace("Name", "Other"), + "a", + "b", + span, + marker + ), + Err(OriginError::ChangedStructure) + ); + assert_eq!( + map( + before, + &after.replace(" source", " source"), + "a", + "b", + span, + marker + ), + Err(OriginError::ChangedStructure) + ); + assert_eq!( + map(before, after, "a", "b", span, None), + Err(OriginError::MissingOrigin) + ); + assert!( + map( + before, + after, + "a", + "b", + Span { + start: 0, + end: usize::MAX + }, + marker + ) + .is_err() + ); + } +} diff --git a/crates/oa-desktop/src/source_rename/preview.rs b/crates/oa-desktop/src/source_rename/preview.rs new file mode 100644 index 00000000..df96bb6d --- /dev/null +++ b/crates/oa-desktop/src/source_rename/preview.rs @@ -0,0 +1,578 @@ +//! Complete, bounded line-ordinal previews of an already prepared alias rename. +//! +//! Source text remains in the caller's immutable before/after snapshots. This +//! module owns only paths and indexes; it is not rename or commit authority. +use oa_core::source_project::SourceBundle; +use std::{fmt, ops::Range}; + +const CONTEXT: usize = 3; +// A byte-grid boundary can move back by at most three UTF-8 continuation bytes, +// so a 252-byte grid yields fragments of at most 255 bytes, independent of input. +const SEGMENT_GRID: usize = 252; + +/// Count all bounded fragments of exact source text; an empty line has one +/// empty fragment. Page these as well as lines, never mount an unbounded line. +pub fn segment_count(text: &str) -> usize { + text.len().div_ceil(SEGMENT_GRID).max(1) +} + +/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the +/// original bytes, including CRLF. No ellipsis or truncation replaces content. +pub fn text_segment(text: &str, index: usize) -> Option<&str> { + if index >= segment_count(text) { + return None; + } + let mut start = index.checked_mul(SEGMENT_GRID)?; + let mut end = start.saturating_add(SEGMENT_GRID).min(text.len()); + while !text.is_char_boundary(start) { + start -= 1; + } + while !text.is_char_boundary(end) { + end -= 1; + } + text.get(start..end) +} +const LIMITS: Limits = Limits { + files: 256, + changed_lines: 50_000, + display_bytes: 8 * 1024 * 1024, +}; + +#[derive(Clone, Copy)] +struct Limits { + files: usize, + changed_lines: usize, + display_bytes: usize, +} + +#[derive(Debug)] +pub struct Preview { + files: Vec, + changed_lines: usize, + display_bytes: usize, +} + +#[derive(Debug)] +pub struct File { + path: String, + hunks: Vec, +} + +#[derive(Debug)] +pub struct Hunk { + lines: Vec, +} + +#[derive(Debug)] +pub struct Line { + number: usize, + before: Range, + after: Range, + changed: bool, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PreviewError { + MembershipChanged, + LineCountChanged(String), + LineEndingChanged { path: String, line: usize }, + TooManyFiles { limit: usize }, + TooManyChangedLines { limit: usize }, + TooManyDisplayBytes { limit: usize }, +} + +impl fmt::Display for PreviewError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MembershipChanged => write!( + f, + "Rename preview refused: the entry or source file membership changed. No rename was applied." + ), + Self::LineCountChanged(path) => write!( + f, + "Rename preview refused: the line count changed in {path}. No rename was applied." + ), + Self::LineEndingChanged { path, line } => write!( + f, + "Rename preview refused: the line ending changed in {path}:{line}. No rename was applied." + ), + Self::TooManyFiles { limit } => write!( + f, + "Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied." + ), + Self::TooManyChangedLines { limit } => write!( + f, + "Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied." + ), + Self::TooManyDisplayBytes { limit } => write!( + f, + "Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied." + ), + } + } +} + +impl std::error::Error for PreviewError {} + +impl Preview { + pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result { + Self::with_limits(before, after, LIMITS) + } + + fn with_limits( + before: &SourceBundle, + after: &SourceBundle, + limits: Limits, + ) -> Result { + if before.entry != after.entry + || !before.files.contains_key(&before.entry) + || !before.files.keys().eq(after.files.keys()) + { + return Err(PreviewError::MembershipChanged); + } + let mut preview = Self { + files: Vec::new(), + changed_lines: 0, + display_bytes: 0, + }; + for ((path, old), (_, new)) in before.files.iter().zip(&after.files) { + if old == new { + continue; + } + if preview.files.len() == limits.files { + return Err(PreviewError::TooManyFiles { + limit: limits.files, + }); + } + // A changed source line can contain arbitrarily many renamed tokens. + // Count the line once; this count is never a semantic-reference count. + let mut changed = Vec::new(); + let mut old_lines = Lines::new(old); + let mut new_lines = Lines::new(new); + let mut count = 0; + loop { + match (old_lines.next(), new_lines.next()) { + (None, None) => break, + (Some(a), Some(b)) => { + count += 1; + if old[a.content_end..a.end] != new[b.content_end..b.end] { + return Err(PreviewError::LineEndingChanged { + path: path.clone(), + line: count, + }); + } + if old[a.start..a.end] != new[b.start..b.end] { + if preview.changed_lines == limits.changed_lines { + return Err(PreviewError::TooManyChangedLines { + limit: limits.changed_lines, + }); + } + preview.changed_lines += 1; + changed.push(count - 1); + } + } + _ => return Err(PreviewError::LineCountChanged(path.clone())), + } + } + + // Merge touching context intervals in order, without pairwise diff. + // At most 7 indexed lines per changed line are retained; unchanged + // lines outside these intervals are scanned but never indexed. + let mut ranges: Vec> = Vec::new(); + for line in changed { + let range = + line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count); + if let Some(previous) = ranges.last_mut() + && range.start <= previous.end + { + previous.end = previous.end.max(range.end); + continue; + } + ranges.push(range); + } + preview.charge(path.len(), limits)?; + let mut file = File { + path: path.clone(), + hunks: Vec::new(), + }; + let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate(); + for range in ranges { + let mut hunk = Hunk { lines: Vec::new() }; + for (ordinal, (a, b)) in pairs.by_ref() { + if ordinal < range.start { + continue; + } + // Charge exact UTF-8 bytes, including preserved endings and + // both copies of shared context, before adding the index. + // No render-time omission or shortening grants admission. + preview.charge(a.end - a.start, limits)?; + preview.charge(b.end - b.start, limits)?; + hunk.lines.push(Line { + number: ordinal + 1, + changed: old[a.start..a.end] != new[b.start..b.end], + before: a.start..a.end, + after: b.start..b.end, + }); + if ordinal + 1 == range.end { + break; + } + } + file.hunks.push(hunk); + } + preview.files.push(file); + } + Ok(preview) + } + + fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> { + let Some(total) = self.display_bytes.checked_add(bytes) else { + return Err(PreviewError::TooManyDisplayBytes { + limit: limits.display_bytes, + }); + }; + if total > limits.display_bytes { + return Err(PreviewError::TooManyDisplayBytes { + limit: limits.display_bytes, + }); + } + self.display_bytes = total; + Ok(()) + } + + /// All admitted files in source path order; suitable for a file picker/search. + pub fn files(&self) -> &[File] { + &self.files + } + + /// Changed source lines, not occurrences, declarations or semantic uses. + pub fn changed_line_count(&self) -> usize { + self.changed_lines + } + + /// Exact displayed source bytes for both sides plus each stored path once. + /// Index metadata is separately bounded by 7 lines per changed line. + pub fn display_bytes(&self) -> usize { + self.display_bytes + } + + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +impl File { + pub fn path(&self) -> &str { + &self.path + } + + /// Every hunk is present; the caller may page these indexes, never truncate. + pub fn hunks(&self) -> &[Hunk] { + &self.hunks + } +} + +impl Hunk { + /// The complete ordered line slice supports paging even a large single hunk. + pub fn lines(&self) -> &[Line] { + &self.lines + } +} + +impl Line { + /// One-based source line number, identical on both sides. + pub fn number(&self) -> usize { + self.number + } + + pub fn changed(&self) -> bool { + self.changed + } + + /// Borrow from the immutable original file; includes its exact line ending. + pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> { + original.get(self.before.clone()) + } + + /// Borrow from the immutable candidate file; includes its exact line ending. + pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> { + candidate.get(self.after.clone()) + } +} + +struct RawLine { + start: usize, + content_end: usize, + end: usize, +} + +struct Lines<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Lines<'a> { + fn new(text: &'a str) -> Self { + Self { + bytes: text.as_bytes(), + offset: 0, + } + } +} + +impl Iterator for Lines<'_> { + type Item = RawLine; + + fn next(&mut self) -> Option { + if self.offset == self.bytes.len() { + return None; + } + let start = self.offset; + // The compiler and source navigation count LF, not bare CR, as a line + // boundary. Preserve CRLF as an ending and bare CR as source content. + while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' { + self.offset += 1; + } + let mut content_end = self.offset; + if self.offset < self.bytes.len() { + if self.offset > start && self.bytes[self.offset - 1] == b'\r' { + content_end -= 1; + } + self.offset += 1; + } + Some(RawLine { + start, + content_end, + end: self.offset, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bundle(source: &str) -> SourceBundle { + SourceBundle { + entry: "救援.arcadia".into(), + files: [("救援.arcadia".into(), source.into())].into(), + } + } + + #[test] + fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() { + let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200)); + let pieces: Vec<_> = (0..segment_count(&text)) + .map(|i| text_segment(&text, i).unwrap()) + .collect(); + assert!(pieces.len() > 2); + assert!(pieces.iter().all(|part| part.len() <= 255)); + assert_eq!(pieces.concat(), text); + assert_eq!(text_segment(&text, segment_count(&text)), None); + assert_eq!(text_segment(&text, usize::MAX), None); + assert_eq!(segment_count(""), 1); + assert_eq!(text_segment("", 0), Some("")); + } + + #[test] + fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() { + let old = bundle("// 救援\r\n@a @a\ncontext\r@a"); + let new = bundle("// 救援\r\n@long @long\ncontext\r@long"); + let preview = Preview::build(&old, &new).unwrap(); + assert_eq!(preview.changed_line_count(), 2); + let file = &preview.files()[0]; + assert_eq!(file.path(), "救援.arcadia"); + assert_eq!(file.hunks().len(), 1); + let lines = file.hunks()[0].lines(); + assert_eq!( + lines.iter().map(Line::number).collect::>(), + [1, 2, 3] + ); + assert_eq!( + lines[0].before_text(&old.files[&old.entry]), + Some("// 救援\r\n") + ); + assert_eq!( + lines[1].after_text(&new.files[&new.entry]), + Some("@long @long\n") + ); + assert_eq!( + lines[2].after_text(&new.files[&new.entry]), + Some("context\r@long") + ); + assert!(lines[1].changed() && lines[2].changed()); + assert!(!lines[0].changed()); + assert_eq!( + preview.display_bytes(), + old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len() + ); + } + + #[test] + fn first_and_last_lines_form_complete_separate_hunks_and_pages() { + let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na"); + let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong"); + let preview = Preview::build(&old, &new).unwrap(); + let hunks = preview.files()[0].hunks(); + assert_eq!(hunks.len(), 2); + let page_numbers: Vec<_> = hunks + .iter() + .flat_map(|h| h.lines().chunks(2)) + .flat_map(|page| page.iter().map(Line::number)) + .collect(); + assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]); + assert_eq!( + hunks[1].lines()[3].after_text(&new.files[&new.entry]), + Some("long") + ); + } + + #[test] + fn nearby_changes_merge_and_all_changed_files_are_reachable() { + let mut old = bundle("a\n1\n2\n3\n4\n5\na\n"); + let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n"); + old.files.insert("other.arcadia".into(), "a a a".into()); + new.files.insert("other.arcadia".into(), "b b b".into()); + let preview = Preview::build(&old, &new).unwrap(); + assert_eq!(preview.changed_line_count(), 3); + assert_eq!(preview.files().len(), 2); + assert_eq!(preview.files()[0].path(), "other.arcadia"); + assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1); + assert_eq!(preview.files()[1].hunks().len(), 1); + assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7); + } + + #[test] + fn unchanged_and_empty_sources_have_no_preview() { + for source in ["", "a\r\nb\n", "a"] { + let original = bundle(source); + let preview = Preview::build(&original, &original).unwrap(); + assert!(preview.is_empty()); + assert_eq!(preview.changed_line_count(), 0); + assert_eq!(preview.display_bytes(), 0); + } + } + + #[test] + fn refuses_membership_entry_line_count_and_line_ending_changes() { + let old = bundle("a\nb"); + let mut new = old.clone(); + new.files.insert("extra.arcadia".into(), "".into()); + assert_eq!( + Preview::build(&old, &new).unwrap_err(), + PreviewError::MembershipChanged + ); + let mut new = old.clone(); + new.entry = "missing.arcadia".into(); + assert_eq!( + Preview::build(&old, &new).unwrap_err(), + PreviewError::MembershipChanged + ); + assert!(matches!( + Preview::build(&old, &bundle("a\nb\nc")), + Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_)) + )); + assert!(matches!( + Preview::build(&bundle("a\nb\n"), &bundle("a\n")), + Err(PreviewError::LineCountChanged(_)) + )); + for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] { + assert_eq!( + Preview::build(&bundle(a), &bundle(b)).unwrap_err(), + PreviewError::LineEndingChanged { + path: old.entry.clone(), + line: 1 + } + ); + } + } + + #[test] + fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() { + let old = bundle("a\ncontext\na"); + let new = bundle("bbb\ncontext\nbbb"); + let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len(); + let limits = Limits { + files: 1, + changed_lines: 2, + display_bytes: exact, + }; + assert!(Preview::with_limits(&old, &new, limits).is_ok()); + assert_eq!( + Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(), + PreviewError::TooManyFiles { limit: 0 } + ); + assert_eq!( + Preview::with_limits( + &old, + &new, + Limits { + changed_lines: 1, + ..limits + } + ) + .unwrap_err(), + PreviewError::TooManyChangedLines { limit: 1 } + ); + assert_eq!( + Preview::with_limits( + &old, + &new, + Limits { + display_bytes: exact - 1, + ..limits + } + ) + .unwrap_err(), + PreviewError::TooManyDisplayBytes { limit: exact - 1 } + ); + let long = bundle(&"a".repeat(LIMITS.display_bytes)); + assert!(matches!( + Preview::build(&bundle("b"), &long), + Err(PreviewError::TooManyDisplayBytes { .. }) + )); + } + + #[test] + fn limits_apply_to_the_whole_preview_not_each_file() { + let mut old = bundle("a"); + let mut new = bundle("b"); + old.files.insert("second.arcadia".into(), "a".into()); + new.files.insert("second.arcadia".into(), "b".into()); + let limits = Limits { + files: 2, + changed_lines: 2, + display_bytes: old.entry.len() + "second.arcadia".len() + 4, + }; + let complete = Preview::with_limits(&old, &new, limits).unwrap(); + assert_eq!(complete.files().len(), 2); + assert_eq!(complete.changed_line_count(), 2); + assert_eq!(complete.display_bytes(), limits.display_bytes); + assert!(matches!( + Preview::with_limits(&old, &new, Limits { files: 1, ..limits }), + Err(PreviewError::TooManyFiles { limit: 1 }) + )); + assert!(matches!( + Preview::with_limits( + &old, + &new, + Limits { + changed_lines: 1, + ..limits + } + ), + Err(PreviewError::TooManyChangedLines { limit: 1 }) + )); + assert!(matches!( + Preview::with_limits( + &old, + &new, + Limits { + display_bytes: limits.display_bytes - 1, + ..limits + } + ), + Err(PreviewError::TooManyDisplayBytes { .. }) + )); + } +} diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index c47a565b..0ebc252a 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -353,6 +353,13 @@ pub fn view(app: &App) -> View<'_, Message> { if app.pending.is_some() { return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into()); } + if app.source_rename.open() { + return modal_width( + root, + crate::app::source_rename::dialog(app), + (app.window_size.width - 32.).clamp(120., 760.), + ); + } root } fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> { @@ -1017,6 +1024,13 @@ fn source_files(app: &App) -> View<'_, Message> { fn source(app: &App) -> View<'_, Message> { use crate::source_navigation::Event as SourceEvent; + let (undo_label, undo_message) = match app.source_rename_undo_message() { + Some(message) => ("Undo rename", Some(message)), + None => ( + "Undo model", + (app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo), + ), + }; let navigation: View<'_, Message> = if app.source_navigation.open { column![ row![ @@ -1131,7 +1145,8 @@ fn source(app: &App) -> View<'_, Message> { .on_action(Message::SourceEdit) .key_binding({ let context = app.source_symbol_context(); - move |press| source_binding(press, &context) + let rename = app.source_rename_begin(); + move |press| source_binding(press, &context, Some(&rename)) }) .font(Font::MONOSPACE) .size(14) @@ -1159,8 +1174,8 @@ fn source(app: &App) -> View<'_, Message> { row![ action("Undo text", Message::SourceUndo), action("Redo text", Message::SourceRedo), - 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(undo_label).size(12)).padding([8,10]).style(style::nav) + .on_press_maybe(undo_message), 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))), @@ -1187,6 +1202,7 @@ fn source(app: &App) -> View<'_, Message> { row![ action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))), action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))), + action("Rename alias · F2", app.source_rename_message()), action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)), button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav) .on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))), @@ -1239,13 +1255,23 @@ fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>( .height(Fill) .into() } -fn source_binding( +pub(crate) fn source_binding( press: widget::text_editor::KeyPress, context: &crate::source_navigation::SymbolContext, + rename: Option<&crate::app::source_rename::Begin>, ) -> Option> { use crate::source_navigation::{SymbolEvent, query::Action}; use iced::keyboard::{Key, key::Named}; use widget::text_editor::{Binding, Status}; + if matches!(press.status, Status::Focused { .. }) + && press.modifiers.is_empty() + && press.key == Key::Named(Named::F2) + && let Some(begin) = rename + { + return Some(Binding::Custom(Message::SourceRename( + crate::app::source_rename::Event::Begin(begin.clone()), + ))); + } // Windows owns Alt+Space for its window menu. Iced's default binding would // otherwise insert a supplied space payload, even after that menu closes. // Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged. @@ -1287,7 +1313,7 @@ fn source_binding( } #[cfg(all(test, debug_assertions))] -mod source_tests { +pub(crate) mod source_tests { use super::*; use iced::advanced::{ Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget, @@ -1296,7 +1322,7 @@ mod source_tests { use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector}; // Measure real Iced paragraphs/editor text without constructing a window or GPU. - struct FontLayout; + pub(crate) struct FontLayout; impl renderer::Renderer for FontLayout { fn start_layer(&mut self, _: Rectangle) {} fn end_layer(&mut self) {} @@ -1626,7 +1652,7 @@ mod source_tests { .size(14) .padding(16) .height(Fill) - .key_binding(|press| source_binding(press, &context)) + .key_binding(|press| source_binding(press, &context, None)) .into(); let mut tree = core_widget::Tree::new(editor.as_widget()); let size = Size::new(600., 240.); diff --git a/docs/language.html b/docs/language.html index 4922e510..fb55b2ff 100644 --- a/docs/language.html +++ b/docs/language.html @@ -2682,6 +2682,45 @@ an Allocation or state-action relation retains that relation as the model action target. Camera movement preserves a valid marker; changing the selected record, document, accepted revision or represented context retires it. Navigation does not alter allocation ownership or the meaning of Delete.

+

Rename alias (F2 while the source editor has focus) changes the alias +under the caret or its exact selection. It requires a unique authored identity +in the complete accepted source bundle, with no pending source, property or +behavior drafts and no extra loaded detached files. Accepted but unsaved source +is eligible. The operation MUST NOT implicitly apply a draft or allocate an ID. +The engineering name and UUID remain unchanged.

+

The Source alias field does not trim or normalize the entered alias. Pasting +line breaks, tabs or other control characters is explicitly rejected as a whole: +the field and its selection remain unchanged, and any previous preview approval +is invalidated. Preview +prepares a typed alias change and displays every changed file and source line; +comments, strings, engineering data, file membership and declaration ownership +remain unchanged. Enter in the field requests Preview, never confirmation. +The preview is for this operation against accepted source, so a later Save may +also contain earlier accepted edits. A changing rename requires enough model +history capacity to retain its immediate Undo. An invalid, identical, stale or +oversized candidate cannot enable confirmation.

+

The preview MUST provide access to all admitted files, hunks, lines and long-line +segments. Refuse a candidate whose complete preview exceeds the editor's bounds: +256 changed files, 50,000 changed lines or 8 MiB of displayed source bytes +(both versions of changed/context lines and file paths). Index metadata is +separately bounded to seven retained line records per changed line. These +limits do not restrict the language itself. A changed-line count is not a count +of semantic references. Cancel, Preview and Rename alias remain reachable +outside the independently scrolling source preview.

+

Explicit Rename alias confirms the current preview once, as one accepted +model/source transaction. A source, document, recovery or replacement change +invalidates its approval even if later bytes match the old preview. Closing the +form cancels approval; a late worker result MUST NOT reopen it. Rejected, +canceled and unchanged operations preserve accepted bytes, drafts, save state, +revisions, selection and both histories. Successful rename selects the new alias +at the original occurrence rather than the first matching spelling in the file. +Editing the replacement permits a new Preview in the same form. After a source +context change, close the form and invoke Rename again on the current alias.

+

Undo rename is guarded against intervening edits and uses model history; +the persistent Undo model and Redo model controls restore the complete +accepted snapshots. Text Ctrl+Z remains Undo text. Save is separate and +uses the ordinary guarded source-save contract. An unconfirmed preview and Undo +history are not persisted on reopening.

Changing a graphical UUID must atomically update map keys and every reference. Alias changes preserve UUID identity. Deletion must account for owned elements, incident relationships, ordered occurrences, and diagram placements without diff --git a/docs/language.rst b/docs/language.rst index 02452f17..0116a562 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -2109,6 +2109,50 @@ target. Camera movement preserves a valid marker; changing the selected record, document, accepted revision or represented context retires it. Navigation does not alter allocation ownership or the meaning of Delete. +**Rename alias** (``F2`` while the source editor has focus) changes the alias +under the caret or its exact selection. It requires a unique authored identity +in the complete accepted source bundle, with no pending source, property or +behavior drafts and no extra loaded detached files. Accepted but unsaved source +is eligible. The operation MUST NOT implicitly apply a draft or allocate an ID. +The engineering name and UUID remain unchanged. + +The **Source alias** field does not trim or normalize the entered alias. Pasting +line breaks, tabs or other control characters is explicitly rejected as a whole: +the field and its selection remain unchanged, and any previous preview approval +is invalidated. **Preview** +prepares a typed alias change and displays every changed file and source line; +comments, strings, engineering data, file membership and declaration ownership +remain unchanged. Enter in the field requests Preview, never confirmation. +The preview is for this operation against accepted source, so a later Save may +also contain earlier accepted edits. A changing rename requires enough model +history capacity to retain its immediate Undo. An invalid, identical, stale or +oversized candidate cannot enable confirmation. + +The preview MUST provide access to all admitted files, hunks, lines and long-line +segments. Refuse a candidate whose complete preview exceeds the editor's bounds: +256 changed files, 50,000 changed lines or 8 MiB of displayed source bytes +(both versions of changed/context lines and file paths). Index metadata is +separately bounded to seven retained line records per changed line. These +limits do not restrict the language itself. A changed-line count is not a count +of semantic references. Cancel, Preview and Rename alias remain reachable +outside the independently scrolling source preview. + +Explicit **Rename alias** confirms the current preview once, as one accepted +model/source transaction. A source, document, recovery or replacement change +invalidates its approval even if later bytes match the old preview. Closing the +form cancels approval; a late worker result MUST NOT reopen it. Rejected, +canceled and unchanged operations preserve accepted bytes, drafts, save state, +revisions, selection and both histories. Successful rename selects the new alias +at the original occurrence rather than the first matching spelling in the file. +Editing the replacement permits a new Preview in the same form. After a source +context change, close the form and invoke Rename again on the current alias. + +**Undo rename** is guarded against intervening edits and uses model history; +the persistent **Undo model** and **Redo model** controls restore the complete +accepted snapshots. Text ``Ctrl+Z`` remains **Undo text**. Save is separate and +uses the ordinary guarded source-save contract. An unconfirmed preview and Undo +history are not persisted on reopening. + Changing a graphical UUID must atomically update map keys and every reference. Alias changes preserve UUID identity. Deletion must account for owned elements, incident relationships, ordered occurrences, and diagram placements without diff --git a/harness/experiments/navigation-rename-union-20260912/.gitattributes b/harness/experiments/navigation-rename-union-20260912/.gitattributes new file mode 100644 index 00000000..37d85e34 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/.gitattributes @@ -0,0 +1,11 @@ +# Exact archived source/provenance/receipt bytes; do not normalize line endings. +/before-details-fix/crates/oa-desktop/src/app/source_rename.rs -text whitespace=cr-at-eol +/before-details-fix/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs -text whitespace=cr-at-eol +/before-details-fix/manifest.json -text whitespace=cr-at-eol +/details-fix-hashes.json -text whitespace=cr-at-eol +/final-source-hashes.json -text whitespace=cr-at-eol +/formatting.json -text whitespace=cr-at-eol +/import-provenance.json -text whitespace=cr-at-eol +/initial-source-hashes.json -text whitespace=cr-at-eol +/pre-format-source-hashes.json -text whitespace=cr-at-eol +/artifacts.json -text whitespace=cr-at-eol diff --git a/harness/experiments/navigation-rename-union-20260912/README.md b/harness/experiments/navigation-rename-union-20260912/README.md new file mode 100644 index 00000000..ad23462a --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/README.md @@ -0,0 +1,75 @@ +# Selective navigation and Text alias rename union + +This is prepared source and static review evidence. **The combined union has not been compiled, tested or exercised natively.** Formatting and source-hash checks do not establish those results. The integration owner retains publication and runtime acceptance; peer-owned compact Text layout work is outside this change. + +## Pinned inputs and dependency boundary + +- Our base is [4db1ef543ced916179194272bf0e65dce73cb107](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/4db1ef543ced916179194272bf0e65dce73cb107), preserving the evidence bytes for navigation production [46b4f3c0d0f04050ad6d705051ee0199138e47a2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/46b4f3c0d0f04050ad6d705051ee0199138e47a2). +- Peer rename source is [cbc49a7802fb1bba0dd284fd904c700a0703ae70](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/cbc49a7802fb1bba0dd284fd904c700a0703ae70). Its production delta is [dfb6102ba5aec9e26a48cc5472b51a4b349459fd](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/dfb6102ba5aec9e26a48cc5472b51a4b349459fd) plus the mandatory `cbc49a7` scroll correction, against [be7a8cd8d7d63aa18e0196bbd36f9e646764c761](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/be7a8cd8d7d63aa18e0196bbd36f9e646764c761). Intervening `4686d05` contains evidence, not production dependencies. +- [Import provenance](import-provenance.json) records all 24 peer files and 10 corresponding baseline files. All 34 raw downloads were checked against published Gitea Git blob hashes; SHA-256 and sizes are retained. + +The union changes 27 source/documentation paths: 25 Rust files and two language documents. Document/query prerequisites were identical to the peer baseline; 14 new files were added and shared App/view/documentation hunks were merged selectively. The entire core tree and the existing `examples/release1` timing fixture match the peer. No core/schema, dependency manifests, lockfile, allocation/Delete semantics, user specification or historical evidence was replaced. Existing camera, lifecycle, repair, exact navigation focus and grapheme behavior remains in the integration base. + +## Source review findings and corrections + +These findings concern the combination of pinned branches. They do not retroactively assert defects in either branch's isolated acceptance evidence. The initial boundaries were published in [issue 17 comment 717](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/17#issuecomment-717). + +| Finding | Correction and evaluator | +| --- | --- | +| Diagram usage dispatch precedes generic modal handling, so merely adding a Rename modal gate permits queued diagram Open over Rename. | Both admissions now exclude the other chooser; source navigation and Rename retain reference chooser exclusions. Real App tests cover Rename with an existing approval and both loading/ready diagram choosers. | +| A Rename completion arriving after Close and diagram navigation can cancel the newer deferred camera/focus during outer reconciliation. Worker slots also survive document replacement. | Completion is passive in reveal reconciliation and releases only its exact slot before modal gates. Tests cover target/preview, forged/duplicate completion, first measurement, and all three real outstanding workers across reset. | +| Rejected old Close/Confirm must be inert before outer source/camera reconciliation. Captured Begin and Undo Rename have an additional queued-intent boundary. | Envelope checks run before reconciliation. Begin still requires Text; Undo Rename still requires its existing live-context predicate. Tests retain a real old approval and exercise stale Begin/Close/Confirm and queued Undo Rename after diagram navigation. Ordinary model Undo/Redo preserves the exact accepted snapshot stack. | +| Independent review found a P2 Details/Preview visit gap: a held File-navigation snap could target the same preview identity after returning from Details and erase later manual scrolling. | An actual Details visibility change rotates the visit UUID without changing file/hunk/line/segment or scheduling a scroll reset. Repeated same-value visibility is inert. The retained-widget test exercises held reset → Details → Preview → manual scroll → held reset, including both mounted and between-frame Details transitions. | + +The integration owner reported an independent static review approving the final Details correction, with both corrected pre-format hashes matching [details-fix-hashes.json](details-fix-hashes.json), and no further finding. This was source review, not test/native evidence. Formatting subsequently changes presentation only; the final manifest identifies the resulting inputs for future gates. + +The existing `preview_destination` UUID remains paired in the mounted widget and every page/approval scroll operation. The Details correction does not force a return to the top. If Details is actually mounted, the returning preview follows Iced's ordinary widget-state lifecycle; if both switches happen between frames, the retained scroll state remains subject to the existing contract. In either case, an older operation cannot address the new visit. + +## Exact source and artifact records + +- [Initial frozen union hashes](initial-source-hashes.json): the initial 27-path handoff before the Details correction. +- [Reviewed Details fix hashes](details-fix-hashes.json): the two changed files before formatting. +- [Pre-format hashes](pre-format-source-hashes.json): all 27 inputs after the accepted Details correction. +- [Final source hashes](final-source-hashes.json): all 27 inputs after bounded formatting; use these to identify the next compiler inputs. +- [Formatting receipt](formatting.json): formatter version, exact relative argument list, changed paths, check exit and checks against tracked inputs outside the owned set. +- [Raw artifact hashes](artifacts.json) and [scoped attributes](.gitattributes): exact archived bytes and text-conversion policy. + +The two independently archived pre-fix Rust files are [source_rename.rs](before-details-fix/crates/oa-desktop/src/app/source_rename.rs) and [preview_scroll_tests.rs](before-details-fix/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs). Their [original manifest](before-details-fix/manifest.json) is copied unchanged. These copies intentionally retain the reviewed old behavior and must not be compiled as current sources or reformatted. Exact `-text` entries preserve their raw bytes and the checksum-bound receipts. The current implementation is in [the App rename controller](../../../crates/oa-desktop/src/app/source_rename.rs). + +Formatting used the local `rustfmt` binary with edition 2024 and `skip_children=true` over only the 25 named Rust files. The preceding leaf-only `--check` found expected formatting differences; the subsequent all-owned-file format/check receipt records completion. No Cargo command, compiler, test, native UI or staging/publication operation was performed during this preparation. + +## Evaluators to run + +The six combined App tests use a parsed four-file Unicode/CRLF fixture with an explicitly identified distant placement. They dispatch actual App events, produce worker completions independently without polling the returned workers, and check whole model/source, accepted snapshot identity, history, semantic/visual selection and actual deferred viewport measurements. + +The four retained-widget tests execute real scroll operations on the production dialog widget tree with a CPU font/layout renderer. They do not establish native GPU paint or package behavior. + +These group filters must select **six** and **four** tests respectively; do not substitute filename filters: + +```text +cargo test --locked --offline -p oa-desktop app::source_rename::tests::navigation_union_tests:: -- --test-threads=1 +cargo test --locked --offline -p oa-desktop app::source_rename::tests::preview_scroll_tests:: -- --test-threads=1 +``` + +Exact individual filters: + +```text +app::source_rename::tests::navigation_union_tests::rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval +app::source_rename::tests::navigation_union_tests::loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin +app::source_rename::tests::navigation_union_tests::late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement +app::source_rename::tests::navigation_union_tests::stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged +app::source_rename::tests::navigation_union_tests::document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive +app::source_rename::tests::navigation_union_tests::rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history +app::source_rename::tests::preview_scroll_tests::explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not +app::source_rename::tests::preview_scroll_tests::delayed_page_and_approval_operations_cannot_reset_a_new_destination +app::source_rename::tests::preview_scroll_tests::repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations +app::source_rename::tests::preview_scroll_tests::details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll +``` + +Additional relevant groups are `source_rename`, `document::alias_rename::tests::`, `app::diagram_usages::tests::`, and `app::diagram_reveal::tests::`. The existing isolated timing filters are `accepted_release1_prepare_commit_sync_and_confirm_timing` and `accepted_release1_constructor_vs_reuse_through_next_highlight`, each run alone with `--ignored --nocapture --test-threads=1` when the integration owner schedules those trials. + +Compilation, focused/full regression gates, strict lint, matching build and native navigation/rename/Save/Undo/Redo/package acceptance remain open. The peer's published source/native/package evidence remains tied to its own identified source and executable, not this union. The existing permanent Text layout is retained; compact command/layout work remains with the peer's separate scope. + +The [prepared native acceptance workflows](native-acceptance.md) cover preview visibility/scroll, mutual modal input ownership, immediate keyboard editing, diagram navigation and complete source/disk/history restoration. They have not been executed on this union. + +Parent staging also checks added files. Its initial cached whitespace check identified original CRLF bytes in three checksum-bound JSON artifacts; exact raw-artifact attributes now allow CR at line endings while retaining ordinary whitespace checks. No archived bytes or source were changed by this correction. The earlier working-tree diff check did not cover those then-untracked artifacts. diff --git a/harness/experiments/navigation-rename-union-20260912/artifacts.json b/harness/experiments/navigation-rename-union-20260912/artifacts.json new file mode 100644 index 00000000..1dace165 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/artifacts.json @@ -0,0 +1,59 @@ +{ + "files": [ + { + "path": "before-details-fix/crates/oa-desktop/src/app/source_rename.rs", + "bytes": 45315, + "sha256": "7b13c39d4c1509d13aef2de4580b9e418cf7ac1ae87d42bd21dcd34ecc70f863", + "git_blob": "eee376e2461477a4ac123c849fb165d9e2867b2b" + }, + { + "path": "before-details-fix/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "bytes": 10937, + "sha256": "06c8dc66b696f70427a190cd174be373a8114bd2f49b6e8f1152173d59e9ec29", + "git_blob": "4641cf7f8db309b12c7bd668dd4ea771cb456fa6" + }, + { + "path": "before-details-fix/manifest.json", + "bytes": 595, + "sha256": "4ac41b15970e9525b5def9af9eb0d57eaa9d3919dd2dd6fe9c4929a60cb17aa6", + "git_blob": "4507b73945f1660a9c1e9a0ea3064d1d40b7b764" + }, + { + "path": "details-fix-hashes.json", + "bytes": 494, + "sha256": "275a692bda781a561c0a50c9af1900c21db42327366daed5ec54a6ade2c1b810", + "git_blob": "da4aa80d6ab9cb752fc398337f2e12a63a10d07c" + }, + { + "path": "final-source-hashes.json", + "bytes": 6679, + "sha256": "a1c007d3d44da805b840423ea6950811c1b43dc780c753164320105fb1296f0b", + "git_blob": "616cc048be1c7bee62bd12e73a493c2a7b57ff20" + }, + { + "path": "formatting.json", + "bytes": 1986, + "sha256": "14b0812cb176296f07fa7712dfc9fc5e5a9cc94ec10a4038961e469ab0650647", + "git_blob": "d0ca466fbaa24abf1270c56e3cfe3842458458c4" + }, + { + "path": "import-provenance.json", + "bytes": 10539, + "sha256": "9761b644ca7c5a3cbcc543834848170c3baa27c6f80093c68a8f64d0c3677b30", + "git_blob": "1fe1da89a3ddf54af931ac9f3c9e3d537fd422fd" + }, + { + "path": "initial-source-hashes.json", + "bytes": 7723, + "sha256": "fbc1109e039150265dbdc1d3c7ffda2279b7323468488732c776f67515b78e66", + "git_blob": "291589c30276eb7f7000daa8ee01f3feb613b531" + }, + { + "path": "pre-format-source-hashes.json", + "bytes": 6679, + "sha256": "d49583b87bb12655fd96c17c1629b832b6389aeddf98cc986ad97c7c19282f13", + "git_blob": "e746e7a36318fce9ddf607122006f2088343d89b" + } + ], + "note": "Raw archived inputs and receipts. This manifest and .gitattributes intentionally do not hash themselves." +} diff --git a/harness/experiments/navigation-rename-union-20260912/before-details-fix/crates/oa-desktop/src/app/source_rename.rs b/harness/experiments/navigation-rename-union-20260912/before-details-fix/crates/oa-desktop/src/app/source_rename.rs new file mode 100644 index 00000000..eee376e2 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/before-details-fix/crates/oa-desktop/src/app/source_rename.rs @@ -0,0 +1,1223 @@ +//! Editor-local, explicitly approved alias transactions. Workers own immutable +//! snapshots; only the live form can approve adoption into the current document. +use super::*; +use crate::{ + document::PreparedAliasRename, + source_navigation::{self as nav, query}, + source_rename::{origin, preview}, +}; +mod alias_input; +mod editor_patch; +const LINES_PER_PAGE: usize = 8; + +// A transient display hint from the same approved candidate. Document authority, +// model synchronization and source ownership remain with their existing paths. +pub(super) struct EditorRefresh<'a> { + path: &'a str, + before: &'a str, + after: &'a str, +} + +impl EditorRefresh<'_> { + pub(super) fn apply(self, editor: &mut text_editor::Content, path: &str, source: &str) -> bool { + self.path == path + && self.after == source + && editor_patch::replace(editor, self.before, self.after) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Begin { + pub document: Id, + pub revision: u64, + pub source: query::SnapshotKey, + pub path: String, + pub cursor: text_editor::Cursor, +} + +#[derive(Debug, Clone)] +pub enum Event { + Begin(Begin), + Input(Id, String), + PasteRejected(Id), + Preview(Id, Id), + Confirm(Id, Id, Id), + Close(Id), + Search(Id, String), + File(Id, Id, bool), + Hunk(Id, Id, bool), + Line(Id, Id, bool), + Segment(Id, Id, bool), + Details(Id, bool), + DetailSegment(Id, bool), + ReadOnly(Id, text_editor::Action), + Undo(Id), +} + +#[derive(Debug, Clone)] +struct Context { + begin: Begin, + accepted: Arc, +} + +#[derive(Debug, Clone)] +struct Job { + id: Id, + invocation: Id, + generation: Id, + context: Context, +} + +#[derive(Debug)] +struct Target { + id: Id, + alias: String, + caption: String, + origin: query::Origin, +} + +#[derive(Debug)] +struct Approval { + id: Id, + prepared: PreparedAliasRename, + preview: preview::Preview, + cursor: text_editor::Cursor, +} + +#[derive(Debug, Clone)] +enum Payload { + Target(Result, String>), + Preview(Result, String>), +} + +#[derive(Debug, Clone)] +pub struct Completion { + job: Job, + payload: Payload, +} + +struct Form { + invocation: Id, + generation: Id, + context: Context, + target: Option>, + replacement: String, + notice: String, + stale: bool, + pending: Option, + approval: Option>, + search: String, + file: usize, + hunk: usize, + line: usize, + segment: usize, + preview_destination: Id, + details: bool, + detail_segment: usize, + detail_editor: text_editor::Content, +} + +#[derive(Debug)] +struct Undo { + id: Id, + context: Context, +} + +#[derive(Default)] +pub struct State { + form: Option, + pub(super) in_flight: Option, + undo: Option, +} + +impl State { + pub fn open(&self) -> bool { + self.form.is_some() + } +} + +impl App { + pub fn source_rename_begin(&self) -> Begin { + Begin { + document: self.canvas_document, + revision: self.document.revision(), + source: self.source_key(), + path: self.source_workspace.active.clone(), + cursor: self.source_editor.cursor(), + } + } + + pub fn source_rename_message(&self) -> Message { + Message::SourceRename(Event::Begin(self.source_rename_begin())) + } + + // Called before source_interaction or any form mutation. In particular an + // old Close/Confirm cannot disturb a newer form with the same source path. + pub(super) fn source_rename_envelope(&self, event: &Event) -> bool { + if let Event::Begin(begin) = event { + return self.page == Page::Text + && !self.source_rename.open() + && *begin == self.source_rename_begin(); + } + if let Event::Undo(id) = event { + return self + .source_rename + .undo + .as_ref() + .is_some_and(|undo| { + undo.id == *id + && !self.source_rename.open() + && self.rename_undo_matches(&undo.context) + }); + } + let Some(form) = &self.source_rename.form else { + return false; + }; + match event { + Event::Preview(invocation, generation) => { + *invocation == form.invocation && *generation == form.generation + } + Event::Confirm(invocation, generation, preview) => { + *invocation == form.invocation + && *generation == form.generation + && form + .approval + .as_ref() + .is_some_and(|ready| ready.id == *preview) + } + Event::File(invocation, preview, _) + | Event::Hunk(invocation, preview, _) + | Event::Line(invocation, preview, _) + | Event::Segment(invocation, preview, _) => { + *invocation == form.invocation + && form + .approval + .as_ref() + .is_some_and(|ready| ready.id == *preview) + } + Event::Input(invocation, _) + | Event::PasteRejected(invocation) + | Event::Close(invocation) + | Event::Search(invocation, _) + | Event::Details(invocation, _) + | Event::DetailSegment(invocation, _) + | Event::ReadOnly(invocation, _) => *invocation == form.invocation, + Event::Begin(_) | Event::Undo(_) => false, + } + } + + fn rename_admission(&self) -> Result<(), String> { + if self.page != Page::Text + || self.busy + || self.pending.is_some() + || self.file_prompt.is_some() + || self.delete_pending + || self.workflow.is_some() + || self.hosted.visible + || self.model_menu.is_some() + || self.architecture.is_some() + || self.diagram_usages.is_some() + || self.reference_input_active() + || self.behavior.has_pending() + || self.draft_dirty() + { + return Err("Finish or cancel the current property, behavior or file operation before renaming. No drafts were applied.".into()); + } + let workspace = &self.source_workspace; + let accepted = self.document.bundle(); + if !workspace.applied || workspace.has_edits() { + return Err( + "Apply or revert source drafts before renaming an alias. Your drafts are retained." + .into(), + ); + } + if workspace.entry != accepted.entry + || workspace.files.len() != accepted.files.len() + || !workspace + .files + .iter() + .zip(&accepted.files) + .all(|((path, buffer), (other, text))| path == other && buffer.text == *text) + || workspace.reachable.len() != accepted.files.len() + || !workspace.reachable.iter().eq(accepted.files.keys()) + || self.source_editor.text() != workspace.text() + { + return Err("Rename requires exactly the accepted included files. Close a clean detached buffer, or reattach/revert its draft first.".into()); + } + Ok(()) + } + + fn rename_context(&self) -> Context { + Context { + begin: self.source_rename_begin(), + accepted: self.document.snapshot(), + } + } + + fn rename_context_matches(&self, context: &Context) -> bool { + context.begin == self.source_rename_begin() + && Arc::ptr_eq(&context.accepted, &self.document.snapshot()) + && self.rename_admission().is_ok() + } + + fn rename_undo_matches(&self, context: &Context) -> bool { + let mut current = self.source_rename_begin(); + current.cursor = context.begin.cursor; + context.begin == current + && Arc::ptr_eq(&context.accepted, &self.document.snapshot()) + && self.rename_admission().is_ok() + } + + pub(super) fn invalidate_source_rename(&mut self, message: &str) { + if let Some(form) = &mut self.source_rename.form { + form.stale = true; + form.pending = None; + form.approval = None; + form.notice = message.into(); + } + self.source_rename.undo = None; + } + + pub(super) fn refresh_source_rename_context(&mut self) { + let stale = self + .source_rename + .form + .as_ref() + .is_some_and(|form| !form.stale && !self.rename_context_matches(&form.context)); + if stale { + self.invalidate_source_rename("Source context changed. Your replacement is retained; close and invoke Rename again on the current source."); + } + if self + .source_rename + .undo + .as_ref() + .is_some_and(|undo| !self.rename_undo_matches(&undo.context)) + { + self.source_rename.undo = None; + } + if let Some(form) = &mut self.source_rename.form + && form.details + { + refresh_details(form); + } + } + + pub(super) fn close_source_rename(&mut self) { + if let Some(form) = self.source_rename.form.take() { + // Do not release the occupied worker. Its matching completion does. + if self.rename_context_matches(&form.context) { + self.queue_source_reveal(form.context.begin.cursor, false); + } + } + } + + pub fn source_rename_undo_message(&self) -> Option { + self.source_rename + .undo + .as_ref() + .filter(|undo| !self.source_rename.open() && self.rename_undo_matches(&undo.context)) + .map(|undo| Message::SourceRename(Event::Undo(undo.id))) + } + + pub(super) fn source_rename_event(&mut self, event: Event) -> Task { + let mut reset_preview_scroll = false; + match event { + Event::Begin(_) => return self.begin_source_rename(), + Event::Close(_) => self.close_source_rename(), + Event::Input(_, value) => { + let Some(form) = &mut self.source_rename.form else { + return Task::none(); + }; + if form.replacement != value { + form.replacement = value; + form.generation = Id::new_v4(); + form.approval = None; + form.pending = None; + if !form.stale { + form.notice = replacement_notice(form.target.as_deref(), &form.replacement) + .unwrap_or_else(|| { + "Choose Preview to review the complete change.".into() + }); + } + } + } + Event::PasteRejected(_) => { + let form = self.source_rename.form.as_mut().unwrap(); + form.generation = Id::new_v4(); + form.approval = None; + form.pending = None; + form.notice = "Paste rejected: aliases cannot contain line breaks, tabs or other control characters. The field is unchanged; paste a single-line alias and choose Preview.".into(); + if form.stale { + form.notice + .push_str(" The source context changed; close and invoke Rename again."); + } + } + Event::Preview(_, _) => return self.preview_source_rename(), + Event::Confirm(_, _, _) => self.confirm_source_rename(), + Event::Undo(_) => { + let Some(undo) = self.source_rename.undo.take() else { + return Task::none(); + }; + if !self.source_rename.open() + && self.rename_undo_matches(&undo.context) + && self.document.undo() + { + self.sync(); + self.status = + "Alias rename undone as one model change. Redo model restores it.".into(); + } + } + Event::Search(_, value) => { + let form = self.source_rename.form.as_mut().unwrap(); + if form.search == value { + return Task::none(); + } + form.search = value; + reset_preview_scroll = form.approval.is_some(); + if let Some(approval) = &form.approval + && let Some(index) = approval + .preview + .files() + .iter() + .position(|file| file.path().contains(&form.search)) + { + form.file = index; + form.hunk = 0; + form.line = 0; + form.segment = 0; + } + } + Event::File(_, _, next) + | Event::Hunk(_, _, next) + | Event::Line(_, _, next) + | Event::Segment(_, _, next) => { + let form = self.source_rename.form.as_mut().unwrap(); + let ready = form.approval.as_ref().unwrap(); + let previous_page = (form.file, form.hunk, form.line, form.segment); + match event { + Event::File(..) => { + let matching: Vec<_> = ready + .preview + .files() + .iter() + .enumerate() + .filter_map(|(i, file)| file.path().contains(&form.search).then_some(i)) + .collect(); + if let Some(position) = matching.iter().position(|i| *i == form.file) { + form.file = matching[step(position, matching.len(), next)]; + } + form.hunk = 0; + form.line = 0; + form.segment = 0; + } + Event::Hunk(..) => { + form.hunk = step( + form.hunk, + ready.preview.files()[form.file].hunks().len(), + next, + ); + form.line = 0; + form.segment = 0; + } + Event::Line(..) => { + let count = ready.preview.files()[form.file].hunks()[form.hunk] + .lines() + .len() + .div_ceil(LINES_PER_PAGE); + form.line = step(form.line / LINES_PER_PAGE, count, next) * LINES_PER_PAGE; + form.segment = 0; + } + _ => form.segment = step(form.segment, segment_total(form), next), + } + reset_preview_scroll = + previous_page != (form.file, form.hunk, form.line, form.segment); + } + Event::Details(_, value) => { + let form = self.source_rename.form.as_mut().unwrap(); + form.details = value; + form.detail_segment = 0; + refresh_details(form); + } + Event::DetailSegment(_, next) => { + let form = self.source_rename.form.as_mut().unwrap(); + form.detail_segment = step( + form.detail_segment, + preview::segment_count(&details(form)), + next, + ); + refresh_details(form); + } + Event::ReadOnly(_, action) => { + if !action.is_edit() { + self.source_rename + .form + .as_mut() + .unwrap() + .detail_editor + .perform(action); + } + } + } + if reset_preview_scroll && let Some(form) = &mut self.source_rename.form { + return reset_preview_position(form); + } + Task::none() + } + + fn begin_source_rename(&mut self) -> Task { + if let Err(error) = self.rename_admission() { + self.status = error; + return Task::none(); + } + if self.source_rename.in_flight.is_some() { + self.status = + "The previous rename task is finishing. Try Rename again when it completes.".into(); + return Task::none(); + } + let context = self.rename_context(); + let Some(offset) = nav::cursor_offset(self.source_workspace.text(), context.begin.cursor) + else { + self.status = "Place the cursor on a source alias or its @ reference.".into(); + return Task::none(); + }; + let input = match self + .with_source_input(|input| query::admit_owned(input, query::Limits::default())) + { + Ok(input) => input, + Err(error) => { + self.status = format!("Rename source admission failed: {error:?}"); + return Task::none(); + } + }; + let job = Job { + id: Id::new_v4(), + invocation: Id::new_v4(), + generation: Id::new_v4(), + context, + }; + self.source_rename.undo = None; + self.source_rename.in_flight = Some(job.id); + self.source_rename.form = Some(Form { + invocation: job.invocation, + generation: job.generation, + context: job.context.clone(), + target: None, + replacement: String::new(), + notice: "Checking the selected source alias…".into(), + stale: false, + pending: Some(job.id), + approval: None, + search: String::new(), + file: 0, + hunk: 0, + line: 0, + segment: 0, + preview_destination: Id::new_v4(), + details: false, + detail_segment: 0, + detail_editor: text_editor::Content::new(), + }); + Task::perform( + async move { + let worker = job.clone(); + let result = tokio::task::spawn_blocking(move || { + let snapshot = input + .capture() + .map_err(|error| format!("Rename source admission failed: {error:?}"))?; + resolve_target(&snapshot, &worker.context, offset).map(Arc::new) + }) + .await + .unwrap_or_else(|error| Err(format!("Rename target task failed: {error}"))); + Completion { + job, + payload: Payload::Target(result), + } + }, + Message::SourceRenameReady, + ) + } + + fn preview_source_rename(&mut self) -> Task { + let Some(form) = &self.source_rename.form else { + return Task::none(); + }; + if form.stale || !self.rename_context_matches(&form.context) { + self.invalidate_source_rename("Source context changed. Close and invoke Rename again; your replacement is retained."); + return Task::none(); + } + if self.source_rename.in_flight.is_some() { + return Task::none(); + } + let Some(target) = form.target.clone() else { + return Task::none(); + }; + if let Some(notice) = replacement_notice(Some(&target), &form.replacement) { + self.source_rename.form.as_mut().unwrap().notice = notice; + return Task::none(); + } + let request = match self + .document + .alias_rename_request(target.id, form.replacement.clone()) + { + Ok(request) => request, + Err(error) => { + self.source_rename.form.as_mut().unwrap().notice = error.to_string(); + return Task::none(); + } + }; + let job = Job { + id: Id::new_v4(), + invocation: form.invocation, + generation: form.generation, + context: form.context.clone(), + }; + self.source_rename.in_flight = Some(job.id); + let form = self.source_rename.form.as_mut().unwrap(); + form.pending = Some(job.id); + form.approval = None; + form.notice = "Preparing the complete rename preview…".into(); + Task::perform( + async move { + let path = job.context.begin.path.clone(); + let result = tokio::task::spawn_blocking(move || { + let prepared = request.prepare().map_err(|error| error.to_string())?; + prepare_approval(prepared, &target, &path).map(Arc::new) + }) + .await + .unwrap_or_else(|error| Err(format!("Rename preview task failed: {error}"))); + Completion { + job, + payload: Payload::Preview(result), + } + }, + Message::SourceRenameReady, + ) + } + + pub(super) fn source_rename_ready(&mut self, completion: Completion) -> Task { + if self.source_rename.in_flight != Some(completion.job.id) { + return Task::none(); + } + self.source_rename.in_flight = None; + let job = &completion.job; + let wanted = self.source_rename.form.as_ref().is_some_and(|form| { + form.invocation == job.invocation + && form.generation == job.generation + && form.pending == Some(job.id) + && !form.stale + }) && self.rename_context_matches(&job.context); + if !wanted { + self.refresh_source_rename_context(); + return Task::none(); + } + let form = self.source_rename.form.as_mut().unwrap(); + form.pending = None; + let mut reset_preview_scroll = false; + match completion.payload { + Payload::Target(Ok(target)) => { + form.replacement = target.alias.clone(); + form.target = Some(target); + form.notice = "No change. Enter a different source alias, then Preview.".into(); + if form.details { + refresh_details(form); + return Task::none(); + } + return nav::interaction::guarded_focus( + input_id(form.invocation), + self.source_symbols.raw_interaction.clone(), + self.source_symbols + .raw_interaction + .load(std::sync::atomic::Ordering::SeqCst), + ); + } + Payload::Preview(Ok(ready)) => { + if !ready.prepared.changed() { + form.notice = "No change. Nothing was applied.".into(); + } else { + form.notice = "Review every changed file. Rename alias adopts this preview as one model change; Save remains separate.".into(); + form.approval = Some(ready); + form.file = 0; + form.hunk = 0; + form.line = 0; + form.segment = 0; + form.search.clear(); + reset_preview_scroll = true; + } + } + Payload::Target(Err(error)) | Payload::Preview(Err(error)) => form.notice = error, + } + if form.details { + refresh_details(form); + } + if reset_preview_scroll { + return reset_preview_position(form); + } + Task::none() + } + + fn confirm_source_rename(&mut self) { + let Some(form) = &self.source_rename.form else { + return; + }; + if form.stale || !self.rename_context_matches(&form.context) { + self.invalidate_source_rename( + "This preview is stale. Close and invoke Rename on the current source.", + ); + return; + } + let Some(target) = &form.target else { return }; + let Some(approval) = &form.approval else { + return; + }; + if approval.prepared.target() != target.id + || approval.prepared.old_alias() != target.alias + || approval.prepared.new_alias() != form.replacement + || approval.prepared.revision() != form.context.begin.revision + { + return; + } + let approval = self + .source_rename + .form + .as_mut() + .unwrap() + .approval + .take() + .unwrap(); + match self.document.commit_alias_rename(&approval.prepared) { + Ok(true) => { + let path = self.source_workspace.active.clone(); + let hint = approval + .prepared + .before() + .bundle + .files + .get(&path) + .zip(approval.prepared.after().bundle.files.get(&path)) + .map(|(before, after)| EditorRefresh { + path: &path, + before, + after, + }); + self.source_rename.form = None; + self.sync_with_alias_editor(hint); + self.source_editor.move_to(approval.cursor); + self.queue_source_reveal(approval.cursor, false); + self.source_rename.undo = Some(Undo { + id: Id::new_v4(), + context: self.rename_context(), + }); + self.status = "Alias renamed. Engineering name unchanged. Undo rename / Undo model restores the whole change; Save writes accepted changes.".into(); + } + Ok(false) => { + self.source_rename.form.as_mut().unwrap().notice = + "No change. Nothing was applied.".into() + } + Err(error) => self.source_rename.form.as_mut().unwrap().notice = error.to_string(), + } + } +} + +fn replacement_notice(target: Option<&Target>, value: &str) -> Option { + let target = target?; + if value == target.alias { + Some("No change. Enter a different source alias.".into()) + } else if !valid_alias(value) { + Some("Use a letter or underscore, then ASCII letters, digits or underscores; reserved aliases are unavailable. Input is not trimmed.".into()) + } else { + None + } +} + +fn resolve_target( + snapshot: &query::Snapshot, + context: &Context, + offset: usize, +) -> Result { + let result = snapshot + .query(query::Query { + stamp: query::QueryStamp { + snapshot: context.begin.source, + serial: 0, + }, + path: &context.begin.path, + byte_offset: offset, + action: query::Action::Definition, + }) + .map_err(|error| format!("Cannot resolve this alias: {error:?}"))?; + let scope = &result.scope; + if !scope.closure_complete + || !scope.applied + || !scope.validated + || scope.detached_files != 0 + || scope.unverified_files != 0 + || result.total_definitions != 1 + || result.definitions.len() != 1 + || result.omitted_definitions != 0 + { + return Err("Rename needs one complete, validated definition in the included source. Resolve ambiguous or incomplete source first.".into()); + } + let row = &result.definitions[0]; + let query::Identity::Unique(anchor) = row.declaration.identity else { + return Err("Rename requires one unique authored UUID for this alias.".into()); + }; + let id = anchor.id(); + let location = context + .accepted + .index + .declarations + .get(&id) + .ok_or("The authored declaration does not belong to the accepted model.")?; + let accepted_alias = context + .accepted + .decoded + .project + .identities() + .into_iter() + .find_map(|(key, alias)| (key == id).then_some(alias)); + if row.membership != query::Membership::Included + || row.evidence != query::Evidence::Validated + || row.declaration.authored_id != Some(id) + || accepted_alias != Some(row.declaration.alias.as_str()) + || location.path != row.path + || row.span.start < location.span.start + || row.span.end > location.span.end + { + return Err("The source alias does not exactly match its accepted declaration.".into()); + } + let origin = result + .origin + .ok_or("Place the cursor on an alias or its @ reference.")?; + let source = context + .accepted + .bundle + .files + .get(&context.begin.path) + .ok_or("Origin file is unavailable.")?; + if !nav::exact_cursor(source, context.begin.cursor) { + return Err("The source selection changed.".into()); + } + if let Some(selection) = context.begin.cursor.selection { + let a = + nav::byte_offset(source, context.begin.cursor.position).ok_or("Invalid selection.")?; + let b = nav::byte_offset(source, selection).ok_or("Invalid selection.")?; + if a != b { + let start = a.min(b); + let end = a.max(b); + let alias_only = start >= origin.span.start && end <= origin.span.end; + let reference = origin + .marker + .is_some_and(|marker| start == marker.start && end == origin.span.end); + if !alias_only && !reference { + return Err("Select only the source alias or its complete @ reference.".into()); + } + } + } + let project = &context.accepted.decoded.project; + let placement = project + .diagrams + .values() + .find_map(|diagram| diagram.nodes.values().find(|node| node.id == id)); + let caption = if let Some(node) = placement { + format!("Placement of {}", project.named(node.element)) + } else { + format!( + "{} · {}", + row.declaration.kind, + row.declaration.name.as_deref().unwrap_or("Unnamed") + ) + }; + Ok(Target { + id, + alias: row.declaration.alias.clone(), + caption, + origin, + }) +} + +fn prepare_approval( + prepared: PreparedAliasRename, + target: &Target, + path: &str, +) -> Result { + let preview = preview::Preview::build(&prepared.before().bundle, &prepared.after().bundle) + .map_err(|error| error.to_string())?; + let old = &prepared.before().bundle.files[path]; + let new = &prepared.after().bundle.files[path]; + let span = origin::map( + old, + new, + prepared.old_alias(), + prepared.new_alias(), + target.origin.span, + target.origin.marker, + ) + .map_err(|error| error.to_string())?; + let position = |offset| { + let prefix = &new[..offset]; + text_editor::Position { + line: prefix.bytes().filter(|b| *b == b'\n').count(), + column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + } + }; + let cursor = text_editor::Cursor { + position: position(span.start), + selection: Some(position(span.end)), + }; + if !nav::exact_cursor(new, cursor) { + return Err("Cannot preserve the exact renamed occurrence.".into()); + } + Ok(Approval { + id: Id::new_v4(), + prepared, + preview, + cursor, + }) +} + +fn step(index: usize, count: usize, next: bool) -> usize { + if next { + index.saturating_add(1).min(count.saturating_sub(1)) + } else { + index.saturating_sub(1) + } +} + +fn segment_total(form: &Form) -> usize { + let Some(ready) = &form.approval else { + return 0; + }; + let file = &ready.preview.files()[form.file]; + file.hunks()[form.hunk] + .lines() + .iter() + .skip(form.line) + .take(LINES_PER_PAGE) + .map(|line| { + preview::segment_count( + line.before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap_or(""), + ) + .max(preview::segment_count( + line.after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap_or(""), + )) + }) + .max() + .unwrap_or(0) +} + +fn details(form: &Form) -> String { + let target = form + .target + .as_ref() + .map_or("Resolving alias", |target| target.caption.as_str()); + let path = form + .approval + .as_ref() + .map_or(form.context.begin.path.as_str(), |ready| { + ready.preview.files()[form.file].path() + }); + format!( + "{target}\nOrigin: {}\nDisplayed file: {path}\nSource alias: {}\nReplacement: {}\n{}", + form.context.begin.path, + form.target + .as_ref() + .map_or("", |target| target.alias.as_str()), + form.replacement, + form.notice + ) +} + +fn refresh_details(form: &mut Form) { + let full = details(form); + form.detail_segment = form + .detail_segment + .min(preview::segment_count(&full).saturating_sub(1)); + let segment = preview::text_segment(&full, form.detail_segment).unwrap_or(""); + if form.detail_editor.text() != segment { + form.detail_editor = text_editor::Content::with_text(segment); + } +} + +fn input_id(invocation: Id) -> iced::widget::Id { + iced::widget::Id::from(format!("source-rename-{invocation}")) +} + +// Each navigation gets a fresh destination even when returning to the same page +// or changing a filter that leaves the page tuple unchanged (the ABA case). +fn reset_preview_position(form: &mut Form) -> Task { + form.preview_destination = Id::new_v4(); + if !form.details + && let Some(ready) = &form.approval + { + iced::widget::operation::snap_to( + preview_scroll_id(form, ready.id), + iced::widget::scrollable::RelativeOffset::START, + ) + } else { + Task::none() + } +} + +// The operation targets this visit, not a later page, visit or approval. +// A new widget id alone does not reset Iced's retained Scrollable state. +fn preview_scroll_id(form: &Form, approval: Id) -> iced::widget::Id { + iced::widget::Id::from(format!( + "source-rename-diff-{}-{approval}-{}-{}-{}-{}-{}", + form.invocation, form.preview_destination, form.file, form.hunk, form.line, form.segment + )) +} + +/// Complete indexed preview with bounded rendering: eight admitted source rows +/// and one UTF-8 segment per side at a time. No candidate content is discarded. +pub(crate) fn dialog(app: &App) -> Element<'_, Message> { + dialog_content( + app, + &app.source_rename + .form + .as_ref() + .expect("open rename form") + .detail_editor, + ) +} + +fn dialog_content<'a, R>( + app: &'a App, + detail_editor: &'a text_editor::Content, +) -> Element<'a, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'static, +{ + use crate::views::short; + use iced::{ + Fill, Font, + widget::{button, column, container, row, scrollable, text, text_input}, + }; + let form = app.source_rename.form.as_ref().expect("open rename form"); + let invocation = form.invocation; + let message = |event| Message::SourceRename(event); + let can_preview = !form.stale + && app.source_rename.in_flight.is_none() + && form.target.is_some() + && replacement_notice(form.target.as_deref(), &form.replacement).is_none(); + let caption = form + .target + .as_ref() + .map_or("Checking source target…".into(), |target| { + short(&target.caption, 76) + }); + let mut alias = text_input("Source alias", &form.replacement) + .id(input_id(invocation)) + .padding(8) + .size(14); + if form.target.is_some() { + alias = alias + .on_input(move |value| message(Event::Input(invocation, value))) + .on_submit(message(Event::Preview(invocation, form.generation))); + } + let alias = alias_input::guard( + alias.into(), + form.target + .is_some() + .then(|| message(Event::PasteRejected(invocation))), + ); + let content: Element<'_, Message, iced::Theme, R> = if form.details { + let count = preview::segment_count(&details(form)); + column![ + label("Selectable target, full paths and feedback · all segments available"), + pager( + format!("Details {}/{}", form.detail_segment + 1, count.max(1)), + (form.detail_segment > 0).then(|| message(Event::DetailSegment(invocation, false))), + (form.detail_segment + 1 < count) + .then(|| message(Event::DetailSegment(invocation, true))) + ), + iced::widget::text_editor(detail_editor) + .font(Font::MONOSPACE) + .size(12) + .height(Fill) + .on_action(move |event| message(Event::ReadOnly(invocation, event))), + ] + .spacing(8) + .height(Fill) + .into() + } else if let Some(ready) = &form.approval { + let files = ready.preview.files(); + let file = &files[form.file]; + let hunks = file.hunks(); + let lines = hunks[form.hunk].lines(); + let segments = segment_total(form); + let matched: Vec<_> = files + .iter() + .enumerate() + .filter_map(|(i, file)| file.path().contains(&form.search).then_some(i)) + .collect(); + let selected = matched.iter().position(|i| *i == form.file); + let preview_id = ready.id; + let mut displayed = column![].spacing(8).width(Fill); + for line in lines.iter().skip(form.line).take(LINES_PER_PAGE) { + let before = line + .before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap_or(""); + let after = line + .after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap_or(""); + displayed = displayed + .push( + text(format!( + "Line {} · {}", + line.number(), + if line.changed() { "changed" } else { "context" } + )) + .size(12), + ) + .push(text("− Before").size(11)) + .push( + text( + preview::text_segment(before, form.segment) + .unwrap_or("[No further segment on this side]"), + ) + .font(Font::MONOSPACE) + .size(12) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ) + .push(text("+ After").size(11)) + .push( + text( + preview::text_segment(after, form.segment) + .unwrap_or("[No further segment on this side]"), + ) + .font(Font::MONOSPACE) + .size(12) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ); + } + column![ + label(format!("{} changed files · {} changed lines · {} preview bytes. This operation only; earlier unsaved changes are separate.", files.len(), ready.preview.changed_line_count(), ready.preview.display_bytes())), + text_input("Find changed file by path", &form.search).padding(6).size(12) + .on_input(move |value| message(Event::Search(invocation, value))), + label(if matched.is_empty() { "No paths match. The previous file remains visible; clear search to browse all changed files." } else { "All matching files are reachable with Previous / Next." }), + pager(format!("File {}/{} · {}", form.file + 1, files.len(), short(file.path(), 45)), + selected.filter(|i| *i > 0).map(|_| message(Event::File(invocation, preview_id, false))), + selected.filter(|i| i + 1 < matched.len()).map(|_| message(Event::File(invocation, preview_id, true)))), + row![ + pager(format!("Hunk {}/{}", form.hunk + 1, hunks.len()), + (form.hunk > 0).then(|| message(Event::Hunk(invocation, preview_id, false))), + (form.hunk + 1 < hunks.len()).then(|| message(Event::Hunk(invocation, preview_id, true)))), + pager(format!("Rows {}–{}/{}", form.line + 1, (form.line + LINES_PER_PAGE).min(lines.len()), lines.len()), + (form.line > 0).then(|| message(Event::Line(invocation, preview_id, false))), + (form.line + LINES_PER_PAGE < lines.len()).then(|| message(Event::Line(invocation, preview_id, true)))), + ].spacing(8), + pager(format!("Row segments {}/{} · both sides", form.segment + 1, segments.max(1)), + (form.segment > 0).then(|| message(Event::Segment(invocation, preview_id, false))), + (form.segment + 1 < segments).then(|| message(Event::Segment(invocation, preview_id, true)))), + scrollable(displayed).id(preview_scroll_id(form, preview_id)).height(Fill).width(Fill).spacing(8), + ].spacing(6).height(Fill).into() + } else { + scrollable(column![ + text("Preview changes the source alias and its typed uses across all included files.").size(13), + text("Engineering names, identities and other model fields stay unchanged. Physical files outside the loaded project are untouched.").size(13), + text("After Rename alias, use Undo rename or Undo model for the whole transaction. Ctrl+Z in Text remains Undo text. Save writes all accepted unsaved changes.").size(13), + ].spacing(12)).height(Fill).into() + }; + let confirm = form + .approval + .as_ref() + .filter(|_| !form.stale) + .map(|ready| message(Event::Confirm(invocation, form.generation, ready.id))); + container( + column![ + row![ + text("Rename alias").size(20), + container(text(caption).size(12)).width(Fill), + action( + if form.details { + "Preview pane" + } else { + "Full details" + }, + message(Event::Details(invocation, !form.details)) + ) + ] + .spacing(10), + label("Source alias · Engineering name stays unchanged"), + container(alias).id("source-rename-input"), + text(short(&form.notice, 190)).size(12), + container(content).id("source-rename-preview").height(Fill), + container( + row![ + action("Cancel", message(Event::Close(invocation))), + button(text("Preview").size(13)).padding(10).on_press_maybe( + can_preview.then(|| message(Event::Preview(invocation, form.generation))) + ), + button(text("Rename alias").size(13)) + .padding(10) + .on_press_maybe(confirm), + ] + .spacing(10) + ) + .id("source-rename-footer"), + ] + .spacing(8) + .height((app.window_size.height - 120.).clamp(200., 680.)), + ) + .width(Fill) + .into() +} + +fn pager( + label: String, + previous: Option, + next: Option, +) -> Element<'static, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'static, +{ + use iced::{ + Fill, + widget::{button, container, row, text}, + }; + row![ + button(text("Previous").size(11)) + .padding(5) + .on_press_maybe(previous), + container(text(label).size(11)).width(Fill), + button(text("Next").size(11)) + .padding(5) + .on_press_maybe(next), + ] + .spacing(6) + .align_y(iced::Center) + .width(Fill) + .into() +} + +fn action<'a, R>(label: &str, message: Message) -> iced::widget::Button<'a, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'a, +{ + iced::widget::button(iced::widget::text(label.to_owned()).size(12)) + .padding([8, 10]) + .on_press(message) + .style(crate::style::nav) +} + +fn label<'a, R>(value: impl Into) -> iced::widget::Text<'a, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'a, +{ + iced::widget::text(value.into()) + .size(11) + .color(crate::style::MUTED) +} + +#[cfg(test)] +mod tests; + +#[cfg(all(test, windows, debug_assertions))] +#[path = "source_rename_timing_tests.rs"] +mod timing_tests; diff --git a/harness/experiments/navigation-rename-union-20260912/before-details-fix/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs b/harness/experiments/navigation-rename-union-20260912/before-details-fix/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs new file mode 100644 index 00000000..4641cf7f --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/before-details-fix/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs @@ -0,0 +1,310 @@ +//! Execute the controller's scroll operations on a retained, scrolled dialog. +use super::*; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, layout, widget}; +use iced::futures::StreamExt; +use iced::{Rectangle, Size, Vector}; + +const HOST: Size = Size::new(720., 660.); + +fn paged_fixture() -> App { + let mut source = bundle(); + let name = "Long engineering name 雨 ".repeat(18); + source.files.get_mut("defs.arcadia").unwrap().replace_range( + .., + &format!( + "oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\ + oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n" + ), + ); + let mut uses = String::new(); + for index in 0..16 { + if index == 12 { + uses.push_str(&"// unchanged gap\r\n".repeat(10)); + } + uses.push_str(&format!( + "interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n" + )); + } + source.files.insert("uses.arcadia".into(), uses); + let mut app = fixture(); + app.window_size = HOST; + app.document = Document::from_bundle(source, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len(); + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + begin(&mut app); + input(&mut app, "pump_long"); + let _ = preview(&mut app); + app +} + +fn scroll_id(app: &App) -> widget::Id { + let form = app.source_rename.form.as_ref().unwrap(); + preview_scroll_id(form, form.approval.as_ref().unwrap().id) +} + +struct Geometry { + id: widget::Id, + offset: Option, +} +impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == Some(&self.id) { + self.offset = Some(offset); + } + } +} + +struct Mounted { + editor: text_editor::Content, + tree: widget::Tree, +} +impl Mounted { + fn new(app: &App) -> Self { + let editor = text_editor::Content::::new(); + let tree = widget::Tree::new(dialog_content(app, &editor).as_widget()); + Self { editor, tree } + } + fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) { + let mut view = dialog_content(app, &self.editor); + // Preserve the actual scroll state across the new page's view diff. + self.tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut self.tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, HOST), + ); + view.as_widget_mut() + .operate(&mut self.tree, Layout::new(&node), &FontLayout, operation); + } + fn offset(&mut self, app: &App) -> f32 { + let mut geometry = Geometry { + id: scroll_id(app), + offset: None, + }; + self.operate(app, &mut geometry); + geometry.offset.expect("mounted preview scrollable").y + } + fn scroll_down(&mut self, app: &App) -> f32 { + self.operate( + app, + &mut widget::operation::scrollable::scroll_to( + scroll_id(app), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(10_000.), + }, + ), + ); + let offset = self.offset(app); + assert!(offset > 10., "fixture must really be scrolled"); + offset + } + async fn execute(&mut self, app: &App, task: Task) -> usize { + let mut count = 0; + if let Some(mut actions) = iced_runtime::task::into_stream(task) { + while let Some(action) = actions.next().await { + let iced_runtime::Action::Widget(mut operation) = action else { + panic!("preview paging must only schedule a widget operation"); + }; + self.operate(app, operation.as_mut()); + count += 1; + } + } + count + } +} + +#[tokio::test] +async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + mounted.scroll_down(&app); + let old_id = scroll_id(&app); + let task = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + assert_ne!(old_id, scroll_id(&app)); + // Negative control: even after diff/layout with the new id and new file, + // Iced retains the previous offset until the returned operation executes. + assert!(mounted.offset(&app) > 10.); + assert_eq!(mounted.execute(&app, task).await, 1); + assert_eq!(mounted.offset(&app), 0.); + + for navigation in [ + Event::Line(invocation, approval, true), + Event::Segment(invocation, approval, true), + Event::Hunk(invocation, approval, true), + Event::File(invocation, approval, false), + Event::Search(invocation, "uses".into()), + Event::Search(invocation, "no-path-match".into()), + Event::Search(invocation, String::new()), + ] { + mounted.scroll_down(&app); + let task = app.update(Message::SourceRename(navigation)); + assert_eq!(mounted.execute(&app, task).await, 1); + assert_eq!(mounted.offset(&app), 0.); + assert_eq!(evidence(&app), before); + } + // Passive redraw and unchanged filter input preserve deliberate scrolling. + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::Search( + invocation, + String::new(), + ))); + assert_eq!(mounted.execute(&app, task).await, 0); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::Input( + invocation, + "pump_long".into(), + ))); + assert_eq!(mounted.execute(&app, task).await, 0); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::File( + invocation, approval, false, + ))); + assert_eq!( + mounted.execute(&app, task).await, + 0, + "clamped first-page request is a no-op" + ); + assert_eq!(mounted.offset(&app), offset); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let old_approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + let old_page_task = app.update(Message::SourceRename(Event::File( + invocation, + old_approval, + true, + ))); + let next_page_task = app.update(Message::SourceRename(Event::Line( + invocation, + old_approval, + true, + ))); + assert_eq!(mounted.execute(&app, next_page_task).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_page_task).await, 1); + assert_eq!(mounted.offset(&app), offset, "old page target is absent"); + + let old_approval_task = app.update(Message::SourceRename(Event::Search( + invocation, + "defs".into(), + ))); + input(&mut app, "pump_new"); + let _ = preview(&mut app); // Real new approval in the same invocation/page. + assert_ne!( + app.source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .id, + old_approval + ); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_approval_task).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old approval target is absent" + ); + let task = app.update(Message::SourceRename(Event::File( + invocation, + old_approval, + true, + ))); + assert_eq!( + mounted.execute(&app, task).await, + 0, + "stale envelope is rejected before scheduling" + ); + assert_eq!(mounted.offset(&app), offset); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + + let first_visit = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + let next_page = app.update(Message::SourceRename(Event::Line( + invocation, approval, true, + ))); + assert_eq!(mounted.execute(&app, next_page).await, 1); + let return_visit = app.update(Message::SourceRename(Event::Line( + invocation, approval, false, + ))); + assert_eq!(mounted.execute(&app, return_visit).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, first_visit).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old visit to the identical page is stale" + ); + + let old_filter = app.update(Message::SourceRename(Event::Search( + invocation, + "uses".into(), + ))); + let new_filter = app.update(Message::SourceRename(Event::Search( + invocation, + "us".into(), + ))); + assert_eq!(mounted.execute(&app, new_filter).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_filter).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old filter on the same page is stale" + ); + + input(&mut app, "pump_new"); + let job = start_preview(&mut app); + let completion = preview_completion(&app, job); + // No intermediate frame mounts the temporary no-approval view. The old + // Scrollable survives even though the new approval has a different id. + let ready = app.update(Message::SourceRenameReady(completion)); + assert!(mounted.offset(&app) > 10.); + assert_eq!(mounted.execute(&app, ready).await, 1); + assert_eq!(mounted.offset(&app), 0.); + assert_eq!(evidence(&app), before); +} diff --git a/harness/experiments/navigation-rename-union-20260912/before-details-fix/manifest.json b/harness/experiments/navigation-rename-union-20260912/before-details-fix/manifest.json new file mode 100644 index 00000000..4507b739 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/before-details-fix/manifest.json @@ -0,0 +1,17 @@ +{ + "base": "4db1ef543ced916179194272bf0e65dce73cb107", + "peer": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "status": "Pre-fix union; source-reviewed, uncompiled, Details delayed-scroll finding", + "files": [ + { + "path": "crates/oa-desktop/src/app/source_rename.rs", + "bytes": 45315, + "sha256": "7b13c39d4c1509d13aef2de4580b9e418cf7ac1ae87d42bd21dcd34ecc70f863" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "bytes": 10937, + "sha256": "06c8dc66b696f70427a190cd174be373a8114bd2f49b6e8f1152173d59e9ec29" + } + ] +} diff --git a/harness/experiments/navigation-rename-union-20260912/details-fix-hashes.json b/harness/experiments/navigation-rename-union-20260912/details-fix-hashes.json new file mode 100644 index 00000000..da4aa80d --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/details-fix-hashes.json @@ -0,0 +1,14 @@ +[ + { + "path": "crates/oa-desktop/src/app/source_rename.rs", + "bytes": 45705, + "sha256": "4714cde897a7a365958a6014f6a546df682a963a48c2fd9d918ddb3c5500268d", + "git_blob": "a4e41d1a3ccd8c2a37e763c3ab50ff6cac8935af" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "bytes": 13821, + "sha256": "ca8d49b5de9e4b848d73cc4c0a4884c0a006a48c3e171f32fb496eac4d6aa180", + "git_blob": "3025e756299bab0c0c48aa236e99dd9788e557ee" + } +] diff --git a/harness/experiments/navigation-rename-union-20260912/final-source-hashes.json b/harness/experiments/navigation-rename-union-20260912/final-source-hashes.json new file mode 100644 index 00000000..616cc048 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/final-source-hashes.json @@ -0,0 +1,168 @@ +{ + "base": "4db1ef543ced916179194272bf0e65dce73cb107", + "peer": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "files": [ + { + "path": "crates/oa-desktop/src/app.rs", + "bytes": 233137, + "sha256": "c4af6a3bce725ed52e1e5919a2177a12c603d9beb6f0f3485925269171bef7ac", + "git_blob": "8b1edac34f9b8b96df76d4fd759bd6aaae5c0a5e" + }, + { + "path": "crates/oa-desktop/src/app/diagram_reveal.rs", + "bytes": 5996, + "sha256": "6d94b32ae64aaebdf81b80d5b8f69a5077425d51727c736a57ffb5f334b3b76a", + "git_blob": "ab4dd765b1b03556e9e7f5abf4ca4b5ae8692c1d" + }, + { + "path": "crates/oa-desktop/src/app/diagram_usages.rs", + "bytes": 18131, + "sha256": "ae7ae6f24a845549d97715d0229861bd49dc0f82d4ce0632f1c134a73de605ee", + "git_blob": "fb17862f667b597f76d77c7c320f1b0b7935758c" + }, + { + "path": "crates/oa-desktop/src/app/source_rename.rs", + "bytes": 45621, + "sha256": "129147bd71286f482c3958a0f5011b9f9d11b6fce72b7ae843cc9e6362670721", + "git_blob": "7bcbaa0adaf143e9e37ea5071acf5924b415c0e5" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/alias_input.rs", + "bytes": 6940, + "sha256": "5888b50e4d6fabd8cd0c6d70986a791d8e95f190453ea37ebb251f2fa19cda90", + "git_blob": "f7240271ea87a957a686249bf629bc0c36e924fe" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/alias_input/tests.rs", + "bytes": 7279, + "sha256": "f9dd619c440a6d784792be5e41bad562ef312e07967d193b0e96cdc127debfb2", + "git_blob": "579880f4f6fab0949805452451b70bdf9e43ac1e" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/editor_patch.rs", + "bytes": 11423, + "sha256": "049c9774c1ca28cda01f9a8e024604bef282366aa52c53c17c1918dbd09c3441", + "git_blob": "f30a08b8e712d3f59aefa8dc478c2db8e46512d1" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs", + "bytes": 17106, + "sha256": "239bae866176409906140233d9e906ae8a6d9bf810cdb5503b95b01d5eae327e", + "git_blob": "e208548be6e06ca7011bc9ee53921c52f6631092" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs", + "bytes": 16247, + "sha256": "d45a799bd97047c6b77ff5b18334f2a71912b65d5239f2b8ce7017588ccff0f4", + "git_blob": "90b3864106072a2fb39c2e1f88a877465f2dc859" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "bytes": 14051, + "sha256": "62354e03219c75b4e24dad7bae7c31486cb4fda06059493f392e381f52700d22", + "git_blob": "4adb3bf470195047a95607ad40f098adad21da1d" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/tests.rs", + "bytes": 54012, + "sha256": "90f69af589fd1698f20ab67299785f8b6c60a99c092e615b8ddb446feff8d5f0", + "git_blob": "2b90ec6e35c0d0a10fa05268a447a0bb53131bf0" + }, + { + "path": "crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs", + "bytes": 12253, + "sha256": "dadf8ce0ec989b9a370703a330bde4b415a813273917037fc6a732b9bf2f98ce", + "git_blob": "ea37c4d44a8c54f00f9508250666b5f4a55008ce" + }, + { + "path": "crates/oa-desktop/src/app/source_rename_timing_tests.rs", + "bytes": 13912, + "sha256": "942753946d00cab9d4a19717bec5f429af58c070e2cd1b97cb4d32ed6fb93967", + "git_blob": "b7df39f71542b08ca55acc5c97d580378394f451" + }, + { + "path": "crates/oa-desktop/src/app/source_symbols.rs", + "bytes": 21084, + "sha256": "43382383bc09c496a4262e7b16a01855e533c6203d046076c16032719bf7ae14", + "git_blob": "03aaade7cc7e660ab3ded9ab9920522b08f550a0" + }, + { + "path": "crates/oa-desktop/src/document.rs", + "bytes": 32600, + "sha256": "e87d7ce456bcab1dab55249021eaa8056f41b888723d143428486e31cb9ca4b1", + "git_blob": "e68c312afbc17da193db832b022eabb6aeadaac6" + }, + { + "path": "crates/oa-desktop/src/document_alias_rename.rs", + "bytes": 8021, + "sha256": "ce64d723a51a834699ebcf74d89d406a77b6ccc5419bcd3f63cf0e27e9fd25cb", + "git_blob": "aa98e63b9c81decea42746f1766128765da47e75" + }, + { + "path": "crates/oa-desktop/src/document_alias_rename_tests.rs", + "bytes": 18027, + "sha256": "847dca1e50970d8a3d75c7e881754453e801f4f3d34f03a88410a334899783bc", + "git_blob": "5e773f46fb7ff50ebf1a43a71ee87225e8451389" + }, + { + "path": "crates/oa-desktop/src/guide.rs", + "bytes": 120562, + "sha256": "8795295d86b51c5e6b589017445886132f6349c5aef0ac3542f202c1c6afb157", + "git_blob": "50a6c6c9ac64661890062f9f88343d8d52e0cef3" + }, + { + "path": "crates/oa-desktop/src/main.rs", + "bytes": 1549, + "sha256": "2230e12ddbfda7e52fa36b04bf676ca822c7807f1d31715cd0527653679c889b", + "git_blob": "cb86659ddc6a9e18026d2fc3de5f67e77a575167" + }, + { + "path": "crates/oa-desktop/src/source_navigation/query.rs", + "bytes": 36149, + "sha256": "4f6ff7d708bb99c64b7370a1761a6099d5f97be4b319582ab4faa7bb8aa42bb9", + "git_blob": "316224b35e4def678df49de62d411076e3e979ed" + }, + { + "path": "crates/oa-desktop/src/source_navigation/query_tests.rs", + "bytes": 31577, + "sha256": "121f197dbae319f47ec59ff875733f1985c7607badb2e4c3d97ef8746d13afa9", + "git_blob": "0b6bd7e101dcc031647222c2c788e4be362b5510" + }, + { + "path": "crates/oa-desktop/src/source_rename.rs", + "bytes": 116, + "sha256": "1ead2a66d9fd7429b8cfca1338b038e7dd97e21ae76cb722f3ca4c43a520ca00", + "git_blob": "f185672c488712aa50de495561062f90c014eaaf" + }, + { + "path": "crates/oa-desktop/src/source_rename/origin.rs", + "bytes": 9275, + "sha256": "11f619310fe837f20a8d495b13e557cafe258270350815c6ac8c848f51cd76ac", + "git_blob": "3da6ccb36964f9456cbe258138838f1176bd1058" + }, + { + "path": "crates/oa-desktop/src/source_rename/preview.rs", + "bytes": 19903, + "sha256": "bf96e31986d143fce43ad1634d7028f1225aebcd9013955ca2ba18cc4fd1190d", + "git_blob": "df96bb6d43e66c9f07f94410871809b3b371e5da" + }, + { + "path": "crates/oa-desktop/src/views.rs", + "bytes": 71224, + "sha256": "a60dea8285f844ea386e3ff429fd47e559e2a288dc88d4d98eb0f95d3684f2b1", + "git_blob": "0ebc252ab42ce1281482b7d61c4f797755f20d7d" + }, + { + "path": "docs/language.html", + "bytes": 184689, + "sha256": "b0eb7199e481d8ecc8dd04bf1e1951569b6971acb0f1d1263afb40a0ec6ba14f", + "git_blob": "fb55b2ff958a785c59a671389e5a852d8a572015" + }, + { + "path": "docs/language.rst", + "bytes": 139785, + "sha256": "9009c564f20f833f7d545bd286ee2b5a18fa86fbf8c6a6e68d2ffdd287a27f0f", + "git_blob": "0116a562098e9d8a5fc885442fa57621ff63e01d" + } + ] +} diff --git a/harness/experiments/navigation-rename-union-20260912/formatting.json b/harness/experiments/navigation-rename-union-20260912/formatting.json new file mode 100644 index 00000000..d0ca466f --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/formatting.json @@ -0,0 +1,49 @@ +{ + "formatter": "rustfmt 1.9.0-stable (48a229ceae 2026-09-01)", + "command": [ + "rustfmt", + "--edition", + "2024", + "--config", + "skip_children=true", + "crates/oa-desktop/src/app.rs", + "crates/oa-desktop/src/app/diagram_reveal.rs", + "crates/oa-desktop/src/app/diagram_usages.rs", + "crates/oa-desktop/src/app/source_rename.rs", + "crates/oa-desktop/src/app/source_rename/alias_input.rs", + "crates/oa-desktop/src/app/source_rename/alias_input/tests.rs", + "crates/oa-desktop/src/app/source_rename/editor_patch.rs", + "crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs", + "crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs", + "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "crates/oa-desktop/src/app/source_rename/tests.rs", + "crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs", + "crates/oa-desktop/src/app/source_rename_timing_tests.rs", + "crates/oa-desktop/src/app/source_symbols.rs", + "crates/oa-desktop/src/document.rs", + "crates/oa-desktop/src/document_alias_rename.rs", + "crates/oa-desktop/src/document_alias_rename_tests.rs", + "crates/oa-desktop/src/guide.rs", + "crates/oa-desktop/src/main.rs", + "crates/oa-desktop/src/source_navigation/query.rs", + "crates/oa-desktop/src/source_navigation/query_tests.rs", + "crates/oa-desktop/src/source_rename.rs", + "crates/oa-desktop/src/source_rename/origin.rs", + "crates/oa-desktop/src/source_rename/preview.rs", + "crates/oa-desktop/src/views.rs" + ], + "rust_inputs": 25, + "owned_inputs": 27, + "formatted_paths": [ + "crates/oa-desktop/src/app/source_rename.rs", + "crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs", + "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs" + ], + "outside_inputs_checked": 312, + "outside_inputs_unchanged": true, + "format_exit": 0, + "check_exit": 0, + "stdout": "", + "stderr": "", + "compilation_or_tests_run": false +} diff --git a/harness/experiments/navigation-rename-union-20260912/import-provenance.json b/harness/experiments/navigation-rename-union-20260912/import-provenance.json new file mode 100644 index 00000000..1fe1da89 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/import-provenance.json @@ -0,0 +1,274 @@ +[ + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app.rs", + "bytes": 217694, + "git_blob": "b9d14f11bd36173053974f619ea6e616db8e5dc4", + "sha256": "d2e13b4527eec6c0920668d312d647806c762ed0a2f3ad9595a1243d9993fc14" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename.rs", + "bytes": 45005, + "git_blob": "8c0da85bc6f3a306931280452447eba962d7e66d", + "sha256": "b3402c661330da1bc50a6135ffbefbd7ab559c364e07f5c52f23ab9e3e7f6e74" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename/alias_input.rs", + "bytes": 6940, + "git_blob": "f7240271ea87a957a686249bf629bc0c36e924fe", + "sha256": "5888b50e4d6fabd8cd0c6d70986a791d8e95f190453ea37ebb251f2fa19cda90" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename/alias_input/tests.rs", + "bytes": 7279, + "git_blob": "579880f4f6fab0949805452451b70bdf9e43ac1e", + "sha256": "f9dd619c440a6d784792be5e41bad562ef312e07967d193b0e96cdc127debfb2" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename/editor_patch.rs", + "bytes": 11423, + "git_blob": "f30a08b8e712d3f59aefa8dc478c2db8e46512d1", + "sha256": "049c9774c1ca28cda01f9a8e024604bef282366aa52c53c17c1918dbd09c3441" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs", + "bytes": 16247, + "git_blob": "90b3864106072a2fb39c2e1f88a877465f2dc859", + "sha256": "d45a799bd97047c6b77ff5b18334f2a71912b65d5239f2b8ce7017588ccff0f4" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "bytes": 10937, + "git_blob": "4641cf7f8db309b12c7bd668dd4ea771cb456fa6", + "sha256": "06c8dc66b696f70427a190cd174be373a8114bd2f49b6e8f1152173d59e9ec29" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename/tests.rs", + "bytes": 53945, + "git_blob": "e0dfe43bcd255be1b0787e804ac6eb025c55be9e", + "sha256": "a55b5b28c6f4149c957bd9a369e67a8c10054a023b5dd2f9e5f90d441a923206" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs", + "bytes": 12253, + "git_blob": "ea37c4d44a8c54f00f9508250666b5f4a55008ce", + "sha256": "dadf8ce0ec989b9a370703a330bde4b415a813273917037fc6a732b9bf2f98ce" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_rename_timing_tests.rs", + "bytes": 13912, + "git_blob": "b7df39f71542b08ca55acc5c97d580378394f451", + "sha256": "942753946d00cab9d4a19717bec5f429af58c070e2cd1b97cb4d32ed6fb93967" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/app/source_symbols.rs", + "bytes": 18691, + "git_blob": "d992b59adf759076b057b7f242c440bacaa54c1a", + "sha256": "77cb97ec909bcaf3d952e0c6673171fd81e7ca31568248994c8c6b150e2c7f4e" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/document.rs", + "bytes": 32600, + "git_blob": "e68c312afbc17da193db832b022eabb6aeadaac6", + "sha256": "e87d7ce456bcab1dab55249021eaa8056f41b888723d143428486e31cb9ca4b1" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/document_alias_rename.rs", + "bytes": 8021, + "git_blob": "aa98e63b9c81decea42746f1766128765da47e75", + "sha256": "ce64d723a51a834699ebcf74d89d406a77b6ccc5419bcd3f63cf0e27e9fd25cb" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/document_alias_rename_tests.rs", + "bytes": 18027, + "git_blob": "5e773f46fb7ff50ebf1a43a71ee87225e8451389", + "sha256": "847dca1e50970d8a3d75c7e881754453e801f4f3d34f03a88410a334899783bc" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/guide.rs", + "bytes": 116153, + "git_blob": "d0dfce65a3c6cf418f434cfa7293e08b47a60f67", + "sha256": "9a5a0608d87e93eebd5f1c72db021f4f05391362223e0f3b2991e8b24d259f6b" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/main.rs", + "bytes": 1530, + "git_blob": "4f0505451ec37d593a5caa590efd7e7c1971d40d", + "sha256": "ab842902972443da5f55d3ea263fca72d557be2e80886368371ebec6cbc5b1ff" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/source_navigation/query.rs", + "bytes": 36149, + "git_blob": "316224b35e4def678df49de62d411076e3e979ed", + "sha256": "4f6ff7d708bb99c64b7370a1761a6099d5f97be4b319582ab4faa7bb8aa42bb9" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/source_navigation/query_tests.rs", + "bytes": 31577, + "git_blob": "0b6bd7e101dcc031647222c2c788e4be362b5510", + "sha256": "121f197dbae319f47ec59ff875733f1985c7607badb2e4c3d97ef8746d13afa9" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/source_rename.rs", + "bytes": 116, + "git_blob": "f185672c488712aa50de495561062f90c014eaaf", + "sha256": "1ead2a66d9fd7429b8cfca1338b038e7dd97e21ae76cb722f3ca4c43a520ca00" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/source_rename/origin.rs", + "bytes": 9275, + "git_blob": "3da6ccb36964f9456cbe258138838f1176bd1058", + "sha256": "11f619310fe837f20a8d495b13e557cafe258270350815c6ac8c848f51cd76ac" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/source_rename/preview.rs", + "bytes": 19903, + "git_blob": "df96bb6d43e66c9f07f94410871809b3b371e5da", + "sha256": "bf96e31986d143fce43ad1634d7028f1225aebcd9013955ca2ba18cc4fd1190d" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "crates/oa-desktop/src/views.rs", + "bytes": 68431, + "git_blob": "bedd5388cabbce972d9268ef86ca85257f34d043", + "sha256": "df415535c675f9f3aeac8a48ba53c547fe06f369ba74c585a664b898760c9d22" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "docs/language.html", + "bytes": 179620, + "git_blob": "3b7d79c0f42c70b83ebca676d1c7cb30d13b0d7a", + "sha256": "f8a9abc811047005bc616f03852b8ca2e7e784cd15eb4cd9a28cd750af62fe6b" + }, + { + "side": "peer", + "pin": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "path": "docs/language.rst", + "bytes": 134869, + "git_blob": "c1ca91062bf5581a33ef7bb91ab82b4b746f0f25", + "sha256": "d2ccbb28923ea82824c5fb3a9f28f718e23bbfb695a3fb9d0ba569b8310be3c0" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/app.rs", + "bytes": 215385, + "git_blob": "11030066dd8973d3a603c1343b6b2651f9841b04", + "sha256": "4a41b062da233a72f1b04866898eeddb3f79882918a67f5f09469fe4eadd9929" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/app/source_symbols.rs", + "bytes": 18627, + "git_blob": "20d0ce55afeb7df57a95e4c1b8096b130dc89a0b", + "sha256": "99370865b548b28f32f4c6ce7e9f8eeaa0c80268a644f2ebcc4bd1e11695e59c" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/document.rs", + "bytes": 31807, + "git_blob": "8369645d832852e912bb79bef06090593fe70ede", + "sha256": "7c87280b5b8d112c34865253f8ba0f80d43bc9897771943b15dc7b5608eb17a4" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/guide.rs", + "bytes": 114509, + "git_blob": "3cb4cbce0669440ce038f8c1488ecd1c81b85a10", + "sha256": "9a6e14ea72c29f4b826e007a4a9836c1c526bd574fc14dd3476ca00a1d80b1c9" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/main.rs", + "bytes": 1511, + "git_blob": "43ab49ecb98e0a8f402af9fa85d8573483463b25", + "sha256": "cb0a919fd0df0924591283da5b55d64d525d8461640441509350be840235e51a" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/source_navigation/query.rs", + "bytes": 35200, + "git_blob": "3044fff36e5392fd9cf9b016c62c63d4b34a5769", + "sha256": "aa8e8e9d446b57c30520c4b3ca1ae9fe524cecb2f4f4f4b229d930ea0377ecca" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/source_navigation/query_tests.rs", + "bytes": 29720, + "git_blob": "e0c9cde6216202ca4bf6e31319bdb664c928d2c3", + "sha256": "a4ecc95a49d0a9325cbf1548cf95a8d367f758f8eb1f44346a2b71ef046f6668" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "crates/oa-desktop/src/views.rs", + "bytes": 67434, + "git_blob": "e91892e3216150f98539cc71c582720307689c85", + "sha256": "e5ed7640fbd2653983c418b88fd2e3612b91f8f02fa2f50f1b5dfd31aab773b2" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "docs/language.html", + "bytes": 176533, + "git_blob": "a9d889f56550eb1152310a17810c69ecbcdb55a2", + "sha256": "9660320375b5cb593169433d9b059c2be46be032a0e44fa5220b436beb3f622b" + }, + { + "side": "base", + "pin": "be7a8cd8d7d63aa18e0196bbd36f9e646764c761", + "path": "docs/language.rst", + "bytes": 131984, + "git_blob": "9ea3a7f6572d46483cde621179b912ee306071ea", + "sha256": "894a1a77f97d1aa94c11762fe4e84b4d95e181a77c630324c16c307637ed27e1" + } +] diff --git a/harness/experiments/navigation-rename-union-20260912/initial-source-hashes.json b/harness/experiments/navigation-rename-union-20260912/initial-source-hashes.json new file mode 100644 index 00000000..291589c3 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/initial-source-hashes.json @@ -0,0 +1,195 @@ +{ + "base": "4db1ef543ced916179194272bf0e65dce73cb107", + "peer": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "files": [ + { + "path": "crates/oa-desktop/src/app.rs", + "bytes": 233137, + "sha256": "c4af6a3bce725ed52e1e5919a2177a12c603d9beb6f0f3485925269171bef7ac", + "git_blob": "8b1edac34f9b8b96df76d4fd759bd6aaae5c0a5e", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/app/diagram_reveal.rs", + "bytes": 5996, + "sha256": "6d94b32ae64aaebdf81b80d5b8f69a5077425d51727c736a57ffb5f334b3b76a", + "git_blob": "ab4dd765b1b03556e9e7f5abf4ca4b5ae8692c1d", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/app/diagram_usages.rs", + "bytes": 18131, + "sha256": "ae7ae6f24a845549d97715d0229861bd49dc0f82d4ce0632f1c134a73de605ee", + "git_blob": "fb17862f667b597f76d77c7c320f1b0b7935758c", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/app/source_rename.rs", + "bytes": 45315, + "sha256": "7b13c39d4c1509d13aef2de4580b9e418cf7ac1ae87d42bd21dcd34ecc70f863", + "git_blob": "eee376e2461477a4ac123c849fb165d9e2867b2b", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/app/source_rename/alias_input.rs", + "bytes": 6940, + "sha256": "5888b50e4d6fabd8cd0c6d70986a791d8e95f190453ea37ebb251f2fa19cda90", + "git_blob": "f7240271ea87a957a686249bf629bc0c36e924fe", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/app/source_rename/alias_input/tests.rs", + "bytes": 7279, + "sha256": "f9dd619c440a6d784792be5e41bad562ef312e07967d193b0e96cdc127debfb2", + "git_blob": "579880f4f6fab0949805452451b70bdf9e43ac1e", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/app/source_rename/editor_patch.rs", + "bytes": 11423, + "sha256": "049c9774c1ca28cda01f9a8e024604bef282366aa52c53c17c1918dbd09c3441", + "git_blob": "f30a08b8e712d3f59aefa8dc478c2db8e46512d1", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs", + "bytes": 16422, + "sha256": "27e4529125974c832cf35e699c69bb1855f94f3bc6cd5455fde94408906c2579", + "git_blob": "dba110c2244a0921fde002af67c20d2050faf999", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs", + "bytes": 16247, + "sha256": "d45a799bd97047c6b77ff5b18334f2a71912b65d5239f2b8ce7017588ccff0f4", + "git_blob": "90b3864106072a2fb39c2e1f88a877465f2dc859", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "bytes": 10937, + "sha256": "06c8dc66b696f70427a190cd174be373a8114bd2f49b6e8f1152173d59e9ec29", + "git_blob": "4641cf7f8db309b12c7bd668dd4ea771cb456fa6", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/app/source_rename/tests.rs", + "bytes": 54012, + "sha256": "90f69af589fd1698f20ab67299785f8b6c60a99c092e615b8ddb446feff8d5f0", + "git_blob": "2b90ec6e35c0d0a10fa05268a447a0bb53131bf0", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs", + "bytes": 12253, + "sha256": "dadf8ce0ec989b9a370703a330bde4b415a813273917037fc6a732b9bf2f98ce", + "git_blob": "ea37c4d44a8c54f00f9508250666b5f4a55008ce", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/app/source_rename_timing_tests.rs", + "bytes": 13912, + "sha256": "942753946d00cab9d4a19717bec5f429af58c070e2cd1b97cb4d32ed6fb93967", + "git_blob": "b7df39f71542b08ca55acc5c97d580378394f451", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/app/source_symbols.rs", + "bytes": 21084, + "sha256": "43382383bc09c496a4262e7b16a01855e533c6203d046076c16032719bf7ae14", + "git_blob": "03aaade7cc7e660ab3ded9ab9920522b08f550a0", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/document.rs", + "bytes": 32600, + "sha256": "e87d7ce456bcab1dab55249021eaa8056f41b888723d143428486e31cb9ca4b1", + "git_blob": "e68c312afbc17da193db832b022eabb6aeadaac6", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/document_alias_rename.rs", + "bytes": 8021, + "sha256": "ce64d723a51a834699ebcf74d89d406a77b6ccc5419bcd3f63cf0e27e9fd25cb", + "git_blob": "aa98e63b9c81decea42746f1766128765da47e75", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/document_alias_rename_tests.rs", + "bytes": 18027, + "sha256": "847dca1e50970d8a3d75c7e881754453e801f4f3d34f03a88410a334899783bc", + "git_blob": "5e773f46fb7ff50ebf1a43a71ee87225e8451389", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/guide.rs", + "bytes": 120562, + "sha256": "8795295d86b51c5e6b589017445886132f6349c5aef0ac3542f202c1c6afb157", + "git_blob": "50a6c6c9ac64661890062f9f88343d8d52e0cef3", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/main.rs", + "bytes": 1549, + "sha256": "2230e12ddbfda7e52fa36b04bf676ca822c7807f1d31715cd0527653679c889b", + "git_blob": "cb86659ddc6a9e18026d2fc3de5f67e77a575167", + "equals_peer_raw": false + }, + { + "path": "crates/oa-desktop/src/source_navigation/query.rs", + "bytes": 36149, + "sha256": "4f6ff7d708bb99c64b7370a1761a6099d5f97be4b319582ab4faa7bb8aa42bb9", + "git_blob": "316224b35e4def678df49de62d411076e3e979ed", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/source_navigation/query_tests.rs", + "bytes": 31577, + "sha256": "121f197dbae319f47ec59ff875733f1985c7607badb2e4c3d97ef8746d13afa9", + "git_blob": "0b6bd7e101dcc031647222c2c788e4be362b5510", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/source_rename.rs", + "bytes": 116, + "sha256": "1ead2a66d9fd7429b8cfca1338b038e7dd97e21ae76cb722f3ca4c43a520ca00", + "git_blob": "f185672c488712aa50de495561062f90c014eaaf", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/source_rename/origin.rs", + "bytes": 9275, + "sha256": "11f619310fe837f20a8d495b13e557cafe258270350815c6ac8c848f51cd76ac", + "git_blob": "3da6ccb36964f9456cbe258138838f1176bd1058", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/source_rename/preview.rs", + "bytes": 19903, + "sha256": "bf96e31986d143fce43ad1634d7028f1225aebcd9013955ca2ba18cc4fd1190d", + "git_blob": "df96bb6d43e66c9f07f94410871809b3b371e5da", + "equals_peer_raw": true + }, + { + "path": "crates/oa-desktop/src/views.rs", + "bytes": 71224, + "sha256": "a60dea8285f844ea386e3ff429fd47e559e2a288dc88d4d98eb0f95d3684f2b1", + "git_blob": "0ebc252ab42ce1281482b7d61c4f797755f20d7d", + "equals_peer_raw": false + }, + { + "path": "docs/language.html", + "bytes": 184689, + "sha256": "b0eb7199e481d8ecc8dd04bf1e1951569b6971acb0f1d1263afb40a0ec6ba14f", + "git_blob": "fb55b2ff958a785c59a671389e5a852d8a572015", + "equals_peer_raw": false + }, + { + "path": "docs/language.rst", + "bytes": 139785, + "sha256": "9009c564f20f833f7d545bd286ee2b5a18fa86fbf8c6a6e68d2ffdd287a27f0f", + "git_blob": "0116a562098e9d8a5fc885442fa57621ff63e01d", + "equals_peer_raw": false + } + ] +} diff --git a/harness/experiments/navigation-rename-union-20260912/native-acceptance.md b/harness/experiments/navigation-rename-union-20260912/native-acceptance.md new file mode 100644 index 00000000..93c29067 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/native-acceptance.md @@ -0,0 +1,14 @@ +# Combined native acceptance — prepared, not executed + +Use a fresh private copy of [examples/source-project](../../../examples/source-project/project.arcadia), the identified union executable and an isolated profile. Record the executable hash and initial hashes of all six `.arcadia` files. Existing peer native evidence does not cover this combined source. + +Target the second `@assess_report` reference in `views.arcadia` (line 61 in the prepared fixture), and rename it to `assess_report_union`. The independent accepted-source oracle is six alias-token substitutions across two files: four in `sa/reporting.arcadia` and two in `views.arcadia`. Names, UUIDs, geometry, comments and the other four files must remain identical. Confirmation alone must leave all disk files unchanged. + +1. **Preview visibility and scroll.** Focus the target, invoke F2, enter the replacement and Preview. On a page with actual overflow, scroll its body, switch Full details → Preview pane normally and rapidly, then scroll deliberately again and allow redraw/idle. Approval and selected file/hunk/row/segment remain unchanged; later scrolling stays put. The alias input and footer remain reachable. Cancel changes neither accepted source nor disk. Do not require the old scroll offset to survive an actually mounted Details replacement. The deterministic held-operation race is checked by the retained-widget test; native toggling alone cannot establish its timing. +2. **One modal input owner.** With Rename open, try F12 and the underlying Show in diagrams control where reachable, then Escape once. Open Show in diagrams and try F2 and the underlying Rename control while the chooser is ready, and while loading if that state is observable. Only the current modal owns input; no second dialog, hidden navigation or source edit occurs. Escape dismisses one owner, and a fresh Rename works afterward. A canceled lookup must not reopen its chooser when it completes. +3. **Confirm and immediate edit.** Rename from the same second reference. Confirm must focus the editor and select exactly that renamed alias. Without clicking, type `assess_report_probe`: only the selected occurrence becomes an unapplied text draft. Ctrl+Z restores `assess_report_union` without undoing the accepted rename. Disk remains baseline. +4. **Diagram usage and model history.** From the restored renamed reference, Show in diagrams and visit each destination, returning to Text between visits. Expected destinations are **SA — system boundary and functions**, placement `sa_assessment_view` (`00000000-0000-4000-8000-000000000714`), and **SA — functional containment**, placement `assessment_view` (`00000000-0000-4000-8000-000000000723`). Each reveals the visible **Assess report** representation with a readable marker after layout settles. Navigation adds no history entry. One Undo model restores all six old tokens; one Redo model restores all six new tokens. Before Save disk stays baseline; after Save and native reopen, exactly the two expected files contain exactly the six substitutions. + +If the example has no genuinely scrollable preview at the chosen window size, materialize the existing `paged_fixture()` in [preview_scroll_tests.rs](../../../crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs) as a separate native fixture. Preserve its exact generation inputs; a non-overflowing preview cannot establish scroll acceptance. + +Record actual observations, source/disk/history oracles, logs, window size and clean closure against the matching executable. Compilation and screenshots alone do not establish these outcomes. Linux remains user-paused. diff --git a/harness/experiments/navigation-rename-union-20260912/pre-format-source-hashes.json b/harness/experiments/navigation-rename-union-20260912/pre-format-source-hashes.json new file mode 100644 index 00000000..e746e7a3 --- /dev/null +++ b/harness/experiments/navigation-rename-union-20260912/pre-format-source-hashes.json @@ -0,0 +1,168 @@ +{ + "base": "4db1ef543ced916179194272bf0e65dce73cb107", + "peer": "cbc49a7802fb1bba0dd284fd904c700a0703ae70", + "files": [ + { + "path": "crates/oa-desktop/src/app.rs", + "bytes": 233137, + "sha256": "c4af6a3bce725ed52e1e5919a2177a12c603d9beb6f0f3485925269171bef7ac", + "git_blob": "8b1edac34f9b8b96df76d4fd759bd6aaae5c0a5e" + }, + { + "path": "crates/oa-desktop/src/app/diagram_reveal.rs", + "bytes": 5996, + "sha256": "6d94b32ae64aaebdf81b80d5b8f69a5077425d51727c736a57ffb5f334b3b76a", + "git_blob": "ab4dd765b1b03556e9e7f5abf4ca4b5ae8692c1d" + }, + { + "path": "crates/oa-desktop/src/app/diagram_usages.rs", + "bytes": 18131, + "sha256": "ae7ae6f24a845549d97715d0229861bd49dc0f82d4ce0632f1c134a73de605ee", + "git_blob": "fb17862f667b597f76d77c7c320f1b0b7935758c" + }, + { + "path": "crates/oa-desktop/src/app/source_rename.rs", + "bytes": 45705, + "sha256": "4714cde897a7a365958a6014f6a546df682a963a48c2fd9d918ddb3c5500268d", + "git_blob": "a4e41d1a3ccd8c2a37e763c3ab50ff6cac8935af" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/alias_input.rs", + "bytes": 6940, + "sha256": "5888b50e4d6fabd8cd0c6d70986a791d8e95f190453ea37ebb251f2fa19cda90", + "git_blob": "f7240271ea87a957a686249bf629bc0c36e924fe" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/alias_input/tests.rs", + "bytes": 7279, + "sha256": "f9dd619c440a6d784792be5e41bad562ef312e07967d193b0e96cdc127debfb2", + "git_blob": "579880f4f6fab0949805452451b70bdf9e43ac1e" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/editor_patch.rs", + "bytes": 11423, + "sha256": "049c9774c1ca28cda01f9a8e024604bef282366aa52c53c17c1918dbd09c3441", + "git_blob": "f30a08b8e712d3f59aefa8dc478c2db8e46512d1" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs", + "bytes": 16422, + "sha256": "27e4529125974c832cf35e699c69bb1855f94f3bc6cd5455fde94408906c2579", + "git_blob": "dba110c2244a0921fde002af67c20d2050faf999" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs", + "bytes": 16247, + "sha256": "d45a799bd97047c6b77ff5b18334f2a71912b65d5239f2b8ce7017588ccff0f4", + "git_blob": "90b3864106072a2fb39c2e1f88a877465f2dc859" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs", + "bytes": 13821, + "sha256": "ca8d49b5de9e4b848d73cc4c0a4884c0a006a48c3e171f32fb496eac4d6aa180", + "git_blob": "3025e756299bab0c0c48aa236e99dd9788e557ee" + }, + { + "path": "crates/oa-desktop/src/app/source_rename/tests.rs", + "bytes": 54012, + "sha256": "90f69af589fd1698f20ab67299785f8b6c60a99c092e615b8ddb446feff8d5f0", + "git_blob": "2b90ec6e35c0d0a10fa05268a447a0bb53131bf0" + }, + { + "path": "crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs", + "bytes": 12253, + "sha256": "dadf8ce0ec989b9a370703a330bde4b415a813273917037fc6a732b9bf2f98ce", + "git_blob": "ea37c4d44a8c54f00f9508250666b5f4a55008ce" + }, + { + "path": "crates/oa-desktop/src/app/source_rename_timing_tests.rs", + "bytes": 13912, + "sha256": "942753946d00cab9d4a19717bec5f429af58c070e2cd1b97cb4d32ed6fb93967", + "git_blob": "b7df39f71542b08ca55acc5c97d580378394f451" + }, + { + "path": "crates/oa-desktop/src/app/source_symbols.rs", + "bytes": 21084, + "sha256": "43382383bc09c496a4262e7b16a01855e533c6203d046076c16032719bf7ae14", + "git_blob": "03aaade7cc7e660ab3ded9ab9920522b08f550a0" + }, + { + "path": "crates/oa-desktop/src/document.rs", + "bytes": 32600, + "sha256": "e87d7ce456bcab1dab55249021eaa8056f41b888723d143428486e31cb9ca4b1", + "git_blob": "e68c312afbc17da193db832b022eabb6aeadaac6" + }, + { + "path": "crates/oa-desktop/src/document_alias_rename.rs", + "bytes": 8021, + "sha256": "ce64d723a51a834699ebcf74d89d406a77b6ccc5419bcd3f63cf0e27e9fd25cb", + "git_blob": "aa98e63b9c81decea42746f1766128765da47e75" + }, + { + "path": "crates/oa-desktop/src/document_alias_rename_tests.rs", + "bytes": 18027, + "sha256": "847dca1e50970d8a3d75c7e881754453e801f4f3d34f03a88410a334899783bc", + "git_blob": "5e773f46fb7ff50ebf1a43a71ee87225e8451389" + }, + { + "path": "crates/oa-desktop/src/guide.rs", + "bytes": 120562, + "sha256": "8795295d86b51c5e6b589017445886132f6349c5aef0ac3542f202c1c6afb157", + "git_blob": "50a6c6c9ac64661890062f9f88343d8d52e0cef3" + }, + { + "path": "crates/oa-desktop/src/main.rs", + "bytes": 1549, + "sha256": "2230e12ddbfda7e52fa36b04bf676ca822c7807f1d31715cd0527653679c889b", + "git_blob": "cb86659ddc6a9e18026d2fc3de5f67e77a575167" + }, + { + "path": "crates/oa-desktop/src/source_navigation/query.rs", + "bytes": 36149, + "sha256": "4f6ff7d708bb99c64b7370a1761a6099d5f97be4b319582ab4faa7bb8aa42bb9", + "git_blob": "316224b35e4def678df49de62d411076e3e979ed" + }, + { + "path": "crates/oa-desktop/src/source_navigation/query_tests.rs", + "bytes": 31577, + "sha256": "121f197dbae319f47ec59ff875733f1985c7607badb2e4c3d97ef8746d13afa9", + "git_blob": "0b6bd7e101dcc031647222c2c788e4be362b5510" + }, + { + "path": "crates/oa-desktop/src/source_rename.rs", + "bytes": 116, + "sha256": "1ead2a66d9fd7429b8cfca1338b038e7dd97e21ae76cb722f3ca4c43a520ca00", + "git_blob": "f185672c488712aa50de495561062f90c014eaaf" + }, + { + "path": "crates/oa-desktop/src/source_rename/origin.rs", + "bytes": 9275, + "sha256": "11f619310fe837f20a8d495b13e557cafe258270350815c6ac8c848f51cd76ac", + "git_blob": "3da6ccb36964f9456cbe258138838f1176bd1058" + }, + { + "path": "crates/oa-desktop/src/source_rename/preview.rs", + "bytes": 19903, + "sha256": "bf96e31986d143fce43ad1634d7028f1225aebcd9013955ca2ba18cc4fd1190d", + "git_blob": "df96bb6d43e66c9f07f94410871809b3b371e5da" + }, + { + "path": "crates/oa-desktop/src/views.rs", + "bytes": 71224, + "sha256": "a60dea8285f844ea386e3ff429fd47e559e2a288dc88d4d98eb0f95d3684f2b1", + "git_blob": "0ebc252ab42ce1281482b7d61c4f797755f20d7d" + }, + { + "path": "docs/language.html", + "bytes": 184689, + "sha256": "b0eb7199e481d8ecc8dd04bf1e1951569b6971acb0f1d1263afb40a0ec6ba14f", + "git_blob": "fb55b2ff958a785c59a671389e5a852d8a572015" + }, + { + "path": "docs/language.rst", + "bytes": 139785, + "sha256": "9009c564f20f833f7d545bd286ee2b5a18fa86fbf8c6a6e68d2ffdd287a27f0f", + "git_blob": "0116a562098e9d8a5fc885442fa57621ff63e01d" + } + ] +}