Combine Text alias rename with guarded diagram navigation

This commit is contained in:
2026-09-12 17:27:08 -07:00
parent 4db1ef543c
commit 208268c8aa
40 changed files with 9766 additions and 19 deletions
+60 -4
View File
@@ -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<crate::diagram_usage::Panel>,
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
source_diagram_job: Option<Id>,
@@ -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<source_rename::EditorRefresh<'_>>) {
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<source_rename::EditorRefresh<'_>>,
) {
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<source_rename::EditorRefresh<'_>>) {
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<Message> {
// 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);
}
@@ -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(..))
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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<Message>,
) -> Element<'a, Message, Theme, R> {
Element::new(AliasInput { input, rejected })
}
struct AliasInput<'a, Message, Theme, R> {
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
}
#[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<String>,
original: &'a mut dyn Clipboard,
}
impl Clipboard for CapturedClipboard<'_> {
fn read(&self, kind: clipboard::Kind) -> Option<String> {
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<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
for AliasInput<'_, Message, Theme, R>
{
fn size(&self) -> Size<Length> {
self.input.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.input.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
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::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused();
let state = tree.state.downcast_mut::<State>();
// 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::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused()
{
tree.state.downcast_mut::<State>().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<overlay::Element<'a, Message, Theme, R>> {
self.input.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -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<usize>,
}
impl Clipboard for ClipboardText {
fn read(&self, _: clipboard::Kind) -> Option<String> {
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<<FontLayout as text::Renderer>::Paragraph>;
struct Mounted {
input: Element<'static, Signal, iced::Theme, FontLayout>,
tree: widget::Tree,
node: layout::Node,
clipboard: ClipboardText,
messages: Vec<Signal>,
}
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::<InputState>();
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::<InputState>()
}
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::<InputState>()
.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())]
);
}
@@ -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<R: Renderer>(
editor: &mut text_editor::Content<R>,
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<R: Renderer>(
editor: &mut text_editor::Content<R>,
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<Vec<(&str, text_editor::LineEnding)>> {
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::<FontLayout>::with_text(before);
assert!(
replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), after);
let expected = text_editor::Content::<FontLayout>::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::<FontLayout>::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::<FontLayout>::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<Vec<usize>> = const { RefCell::new(Vec::new()) };
}
struct Tracked(crate::highlight::ArcadiaHighlighter);
impl Highlighter for Tracked {
type Settings = Arc<str>;
type Highlight = oa_core::language::TokenClass;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, 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<FontLayout>,
source: Arc<str>,
) -> Element<'_, (), iced::Theme, FontLayout> {
text_editor(editor)
.font(Font::MONOSPACE)
.size(14)
.height(Length::Fill)
.highlight_with::<Tracked>(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::<FontLayout>::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);
}
}
@@ -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<Id>,
diagram: Option<Id>,
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<Id>,
}
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());
}
}
@@ -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<usize>,
bounds: Rectangle,
clip: Rectangle,
}
struct GlyphRecorder {
wanted: BTreeSet<String>,
transforms: Vec<Transformation>,
clips: Vec<Rectangle>,
glyphs: Vec<Glyph>,
quads: Vec<Rectangle>,
}
impl GlyphRecorder {
fn new(wanted: BTreeSet<String>, 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<Background>) {
self.quads.push(quad.bounds * self.transform());
}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for GlyphRecorder {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::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::<Vec<_>>()
.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<widget::Id>,
bounds: Option<Rectangle>,
content: Option<Rectangle>,
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::<String, usize>::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::<GlyphRecorder>::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
);
}
}
@@ -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<Vector>,
}
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<FontLayout>,
tree: widget::Tree,
}
impl Mounted {
fn new(app: &App) -> Self {
let editor = text_editor::Content::<FontLayout>::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<Message>) -> 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -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<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for ObservedFontLayout {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::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<ObservedFontLayout>;
const HOST: Size = Size::new(640., 350.);
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
text_editor(editor)
.on_action(|_| ())
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Length::Fill)
.highlight_with::<highlight::ArcadiaHighlighter>(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::<text_editor::State<highlight::ArcadiaHighlighter>>()
.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<str> = 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();
}
@@ -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::<usize>(),
"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<Duration>>);
impl Samples {
fn time<T>(&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();
}
+3 -2
View File
@@ -52,7 +52,7 @@ impl App {
}
}
fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
pub(super) fn with_source_input<T>(&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()
+17 -1
View File
@@ -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<CompiledSourceProject>,
bytes: usize,
@@ -91,6 +95,8 @@ impl From<Vec<SourceDiagnostic>> for DocumentError {
}
pub struct Document {
// Distinguishes equal accepted snapshots belonging to different documents.
alias_owner: Arc<()>,
current: Arc<CompiledSourceProject>,
past: Vec<HistoryEntry>,
future: Vec<HistoryEntry>,
@@ -121,6 +127,7 @@ impl Document {
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
let current_bytes = estimated_bytes(&current);
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<CompiledSourceProject>, 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();
@@ -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<CompiledSourceProject>,
revision: u64,
limits: ProjectLimits,
target: Id,
old_alias: String,
new_alias: String,
}
#[derive(Debug)]
struct PreparedData {
request: AliasRenameRequest,
after: Arc<CompiledSourceProject>,
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<PreparedData>);
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<AliasRenameRequest, DocumentError> {
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<bool, DocumentError> {
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<PreparedAliasRename, DocumentError> {
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;
@@ -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<CompiledSourceProject>,
saved: Option<Arc<SourceBundle>>,
past: Vec<(Arc<CompiledSourceProject>, usize)>,
future: Vec<(Arc<CompiledSourceProject>, 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);
}
+10 -1
View File
@@ -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.",
+1
View File
@@ -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;
@@ -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<Span>,
}
#[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<Origin>,
pub alias: Option<String>,
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,
@@ -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();
+3
View File
@@ -0,0 +1,3 @@
//! Pure preview and exact origin mapping for the guarded alias-rename controller.
pub mod origin;
pub mod preview;
@@ -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<Span>,
) -> Result<Span, OriginError> {
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<Option<Option<Span>>> {
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()
);
}
}
@@ -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<File>,
changed_lines: usize,
display_bytes: usize,
}
#[derive(Debug)]
pub struct File {
path: String,
hunks: Vec<Hunk>,
}
#[derive(Debug)]
pub struct Hunk {
lines: Vec<Line>,
}
#[derive(Debug)]
pub struct Line {
number: usize,
before: Range<usize>,
after: Range<usize>,
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, PreviewError> {
Self::with_limits(before, after, LIMITS)
}
fn with_limits(
before: &SourceBundle,
after: &SourceBundle,
limits: Limits,
) -> Result<Self, PreviewError> {
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<Range<usize>> = 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<Self::Item> {
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::<Vec<_>>(),
[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 { .. })
));
}
}
+33 -7
View File
@@ -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<widget::text_editor::Binding<Message>> {
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.);
+39
View File
@@ -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.</p>
<p><strong>Rename alias</strong> (<span class="docutils literal">F2</span> 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.</p>
<p>The <strong>Source alias</strong> 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. <strong>Preview</strong>
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.</p>
<p>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.</p>
<p>Explicit <strong>Rename alias</strong> 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.</p>
<p><strong>Undo rename</strong> is guarded against intervening edits and uses model history;
the persistent <strong>Undo model</strong> and <strong>Redo model</strong> controls restore the complete
accepted snapshots. Text <span class="docutils literal">Ctrl+Z</span> remains <strong>Undo text</strong>. Save is separate and
uses the ordinary guarded source-save contract. An unconfirmed preview and Undo
history are not persisted on reopening.</p>
<p>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
+44
View File
@@ -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
@@ -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
@@ -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.
@@ -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."
}
@@ -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<Vector>,
}
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<FontLayout>,
tree: widget::Tree,
}
impl Mounted {
fn new(app: &App) -> Self {
let editor = text_editor::Content::<FontLayout>::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<Message>) -> 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);
}
@@ -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"
}
]
}
@@ -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"
}
]
@@ -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"
}
]
}
@@ -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
}
@@ -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"
}
]
@@ -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
}
]
}
@@ -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.
@@ -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"
}
]
}