From a17ca5d0667cfcbf868326f11ded4d26af3e539b Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 12 Sep 2026 18:50:11 -0700 Subject: [PATCH] Combine source navigation and alias rename with canvas ownership guards --- Cargo.lock | 1 + crates/oa-core/src/language.rs | 187 +- crates/oa-core/src/language_outline_tests.rs | 386 ++++ crates/oa-desktop/Cargo.toml | 1 + crates/oa-desktop/src/app.rs | 340 ++- crates/oa-desktop/src/app/allocation_drag.rs | 7 +- .../src/app/architecture_authoring/tests.rs | 1 + .../src/app/architecture_authoring_tests.rs | 2 + .../src/app/diagram_removal_tests.rs | 40 +- .../diagram_removal_tests/navigation_union.rs | 146 ++ crates/oa-desktop/src/app/diagram_reveal.rs | 146 ++ .../src/app/diagram_reveal/tests.rs | 403 ++++ crates/oa-desktop/src/app/diagram_usages.rs | 446 ++++ .../src/app/diagram_usages/tests.rs | 1403 ++++++++++++ crates/oa-desktop/src/app/hosted.rs | 16 + crates/oa-desktop/src/app/source_rename.rs | 1226 ++++++++++ .../src/app/source_rename/alias_input.rs | 219 ++ .../app/source_rename/alias_input/tests.rs | 220 ++ .../src/app/source_rename/editor_patch.rs | 303 +++ .../source_rename/navigation_union_tests.rs | 450 ++++ .../navigation_union_tests/palette_union.rs | 72 + .../app/source_rename/preview_glyph_tests.rs | 449 ++++ .../app/source_rename/preview_scroll_tests.rs | 385 ++++ .../oa-desktop/src/app/source_rename/tests.rs | 1427 ++++++++++++ .../app/source_rename_layout_timing_tests.rs | 313 +++ .../src/app/source_rename_timing_tests.rs | 347 +++ crates/oa-desktop/src/app/source_symbols.rs | 508 ++++ .../src/app/source_symbols/tests.rs | 1613 +++++++++++++ crates/oa-desktop/src/behavior.rs | 23 +- .../src/behavior/canvas_workspace.rs | 1 + crates/oa-desktop/src/behavior/scenario.rs | 1 + .../oa-desktop/src/behavior/scenario/usage.rs | 207 ++ .../src/behavior/scenario/usage/tests.rs | 276 +++ .../oa-desktop/src/behavior/state_canvas.rs | 359 +++ crates/oa-desktop/src/behavior/state_view.rs | 3 + crates/oa-desktop/src/behavior/topology.rs | 175 +- .../src/behavior/topology/usages.rs | 274 +++ .../src/behavior/topology/usages/tests.rs | 439 ++++ .../oa-desktop/src/behavior/topology/view.rs | 26 + crates/oa-desktop/src/diagram.rs | 20 + .../oa-desktop/src/diagram/lifecycle_tests.rs | 1 + .../oa-desktop/src/diagram/pointer_tests.rs | 1 + .../oa-desktop/src/diagram/port_hit_tests.rs | 1 + crates/oa-desktop/src/diagram/port_labels.rs | 1 + crates/oa-desktop/src/diagram_usage.rs | 472 ++++ .../src/diagram_usage/coverage_tests.rs | 386 ++++ crates/oa-desktop/src/diagram_usage/tests.rs | 196 ++ crates/oa-desktop/src/diagram_usage/view.rs | 220 ++ crates/oa-desktop/src/document.rs | 18 +- .../oa-desktop/src/document_alias_rename.rs | 233 ++ .../src/document_alias_rename_tests.rs | 459 ++++ crates/oa-desktop/src/guide.rs | 23 +- crates/oa-desktop/src/main.rs | 2 + crates/oa-desktop/src/model_menu.rs | 5 + crates/oa-desktop/src/navigation.rs | 45 +- crates/oa-desktop/src/source_navigation.rs | 5 + .../src/source_navigation/controller.rs | 126 + .../src/source_navigation/interaction.rs | 284 +++ .../source_navigation/interaction/tests.rs | 190 ++ .../oa-desktop/src/source_navigation/query.rs | 1131 +++++++++ .../src/source_navigation/query_tests.rs | 1048 +++++++++ .../src/source_navigation/symbols.rs | 2036 +++++++++++++++++ crates/oa-desktop/src/source_rename.rs | 3 + crates/oa-desktop/src/source_rename/origin.rs | 278 +++ .../oa-desktop/src/source_rename/preview.rs | 578 +++++ crates/oa-desktop/src/source_workspace.rs | 221 +- crates/oa-desktop/src/tests.rs | 33 +- crates/oa-desktop/src/views.rs | 610 ++++- crates/oa-desktop/src/views/source_symbols.rs | 260 +++ docs/language.html | 79 + docs/language.rst | 89 + ...lease-navigation-palette-union-20260913.md | 58 + 72 files changed, 21764 insertions(+), 189 deletions(-) create mode 100644 crates/oa-core/src/language_outline_tests.rs create mode 100644 crates/oa-desktop/src/app/diagram_removal_tests/navigation_union.rs create mode 100644 crates/oa-desktop/src/app/diagram_reveal.rs create mode 100644 crates/oa-desktop/src/app/diagram_reveal/tests.rs create mode 100644 crates/oa-desktop/src/app/diagram_usages.rs create mode 100644 crates/oa-desktop/src/app/diagram_usages/tests.rs create mode 100644 crates/oa-desktop/src/app/source_rename.rs create mode 100644 crates/oa-desktop/src/app/source_rename/alias_input.rs create mode 100644 crates/oa-desktop/src/app/source_rename/alias_input/tests.rs create mode 100644 crates/oa-desktop/src/app/source_rename/editor_patch.rs create mode 100644 crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs create mode 100644 crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs create mode 100644 crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs create mode 100644 crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs create mode 100644 crates/oa-desktop/src/app/source_rename/tests.rs create mode 100644 crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs create mode 100644 crates/oa-desktop/src/app/source_rename_timing_tests.rs create mode 100644 crates/oa-desktop/src/app/source_symbols.rs create mode 100644 crates/oa-desktop/src/app/source_symbols/tests.rs create mode 100644 crates/oa-desktop/src/behavior/scenario/usage.rs create mode 100644 crates/oa-desktop/src/behavior/scenario/usage/tests.rs create mode 100644 crates/oa-desktop/src/behavior/topology/usages.rs create mode 100644 crates/oa-desktop/src/behavior/topology/usages/tests.rs create mode 100644 crates/oa-desktop/src/diagram_usage.rs create mode 100644 crates/oa-desktop/src/diagram_usage/coverage_tests.rs create mode 100644 crates/oa-desktop/src/diagram_usage/tests.rs create mode 100644 crates/oa-desktop/src/diagram_usage/view.rs create mode 100644 crates/oa-desktop/src/document_alias_rename.rs create mode 100644 crates/oa-desktop/src/document_alias_rename_tests.rs create mode 100644 crates/oa-desktop/src/source_navigation/controller.rs create mode 100644 crates/oa-desktop/src/source_navigation/interaction.rs create mode 100644 crates/oa-desktop/src/source_navigation/interaction/tests.rs create mode 100644 crates/oa-desktop/src/source_navigation/query.rs create mode 100644 crates/oa-desktop/src/source_navigation/query_tests.rs create mode 100644 crates/oa-desktop/src/source_navigation/symbols.rs create mode 100644 crates/oa-desktop/src/source_rename.rs create mode 100644 crates/oa-desktop/src/source_rename/origin.rs create mode 100644 crates/oa-desktop/src/source_rename/preview.rs create mode 100644 crates/oa-desktop/src/views/source_symbols.rs create mode 100644 harness/release-navigation-palette-union-20260913.md diff --git a/Cargo.lock b/Cargo.lock index 9ac4d639..8719ac1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2198,6 +2198,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "unicode-segmentation", ] [[package]] diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 37d42e7f..24e3f891 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -142,12 +142,87 @@ pub struct Analysis { pub declarations: Vec, } +/// Check the decoded spelling required by declaration-header UUID clauses. +/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser: +/// callers must additionally parse the UUID. Uppercase hex and JSON escapes +/// that decode to this spelling remain accepted. URN/braced/compact forms do not. +pub fn has_uuid_header_shape(decoded: &str) -> bool { + decoded.len() == 36 + && [8, 13, 18, 23] + .into_iter() + .all(|index| decoded.as_bytes()[index] == b'-') +} + +/// Budgets for one original source buffer. Policy is caller-owned; these +/// limits do not change the grammar or perform model admission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutlineLimits { + pub max_source_bytes: usize, + pub max_tokens: usize, + pub max_declarations: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutlineResource { + SourceBytes, + Tokens, + Declarations, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutlineLimit { + pub resource: OutlineResource, + pub limit: usize, + pub observed_at_least: usize, + /// Original byte offset where admission stopped; never a rewritten offset. + pub offset: usize, +} + +/// Roles discovered by the existing tolerant declaration walk. Keeping the +/// wrapper preserves DeclarationSpan and Analysis as existing public APIs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutlineDeclaration { + pub declaration: DeclarationSpan, + pub kind_span: Span, + pub alias_span: Span, + /// Whole quoted header ID token, even if it is not a valid UUID. In that + /// case declaration.id is None. Missing/non-string ID values have no span. + pub id_literal_span: Option, +} + +/// A full-file tolerant scan, not a successful parse or validated model. Invalid +/// text remains available. Lexical errors and unmatched braces are facts only: +/// their absence does not establish syntactic or semantic validity. +/// +/// A budget failure returns Err and no partial Outline. Include reachability, +/// project completeness and editor revision are intentionally not inferred here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outline<'a> { + pub source: &'a str, + pub tokens: Vec, + pub declarations: Vec, + pub has_lexical_errors: bool, + pub has_unmatched_braces: bool, +} + /// Tolerant highlighting tokens cover the whole input, including incomplete /// strings and unexpected characters. No model is created or modified. pub fn tokenize(source: &str) -> Vec { + tokenize_with_limit(source, usize::MAX).expect("unlimited token scan") +} + +fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result, OutlineLimit> { let mut tokens = Vec::new(); let mut cursor = 0; while cursor < source.len() { + if tokens.len() == max_tokens { + return Err(OutlineLimit { + resource: OutlineResource::Tokens, + limit: max_tokens, + observed_at_least: tokens.len().saturating_add(1), + offset: cursor, + }); + } let start = cursor; let ch = source[cursor..].chars().next().expect("in bounds"); let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') { @@ -224,7 +299,7 @@ pub fn tokenize(source: &str) -> Vec { span: Span { start, end: cursor }, }); } - tokens + Ok(tokens) } pub fn lint(source: &str) -> Vec { @@ -1230,11 +1305,7 @@ impl Parser<'_> { self.cursor += 1; let offset = self.current().span.start; let value = self.quoted()?; - if value.len() != 36 - || ![8, 13, 18, 23] - .into_iter() - .all(|i| value.as_bytes()[i] == b'-') - { + if !has_uuid_header_shape(&value) { return Err(Error { code: "invalid_source", offset, @@ -1962,8 +2033,49 @@ fn take_required_id(fields: &mut Map, key: &str) -> Result Analysis { let tokens = tokenize(source); + let (declarations, _) = + declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk"); + Analysis { + tokens, + diagnostics: lint(source), + declarations: declarations.into_iter().map(|d| d.declaration).collect(), + } +} + +/// Read exact current text without lint, parsing a model, stabilization or I/O. +/// Source bytes are admitted before token allocation; token/declaration limits +/// are checked before lexing the next token or materializing the next record. +pub fn outline(source: &str, limits: OutlineLimits) -> Result, OutlineLimit> { + if source.len() > limits.max_source_bytes { + return Err(OutlineLimit { + resource: OutlineResource::SourceBytes, + limit: limits.max_source_bytes, + observed_at_least: source.len(), + offset: 0, + }); + } + let tokens = tokenize_with_limit(source, limits.max_tokens)?; + let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error); + let (declarations, has_unmatched_braces) = + declaration_outline(source, &tokens, limits.max_declarations)?; + Ok(Outline { + source, + tokens, + declarations, + has_lexical_errors, + has_unmatched_braces, + }) +} + +fn declaration_outline( + source: &str, + tokens: &[TokenSpan], + max_declarations: usize, +) -> Result<(Vec, bool), OutlineLimit> { let significant = tokens .iter() .filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment)) @@ -1972,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis { // again would make tolerant navigation quadratic on deeply nested drafts. let mut body_ends = vec![None; significant.len()]; let mut bodies = Vec::new(); + let mut unmatched_close = false; for (index, token) in significant.iter().enumerate() { match &source[token.span.start..token.span.end] { "{" => bodies.push(index), "}" => { if let Some(open) = bodies.pop() { body_ends[open] = Some(token.span.end); + } else { + unmatched_close = true; } } _ => {} } } + let has_unmatched_braces = unmatched_close || !bodies.is_empty(); let mut declarations = Vec::new(); for (i, t) in significant.iter().enumerate() { let word = &source[t.span.start..t.span.end]; @@ -2007,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis { } j += 1; } + if declarations.len() == max_declarations { + return Err(OutlineLimit { + resource: OutlineResource::Declarations, + limit: max_declarations, + observed_at_least: declarations.len().saturating_add(1), + offset: t.span.start, + }); + } let mut id = None; + let mut id_literal_span = None; if significant .get(j) .is_some_and(|t| &source[t.span.start..t.span.end] == "id") @@ -2015,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis { if let Some(value) = significant.get(j + 1) && value.class == TokenClass::String { + id_literal_span = Some(value.span); let text = serde_json::from_str::(&source[value.span.start..value.span.end]) .unwrap(); - id = Id::parse_str(&text).ok(); + if has_uuid_header_shape(&text) { + id = Id::parse_str(&text).ok(); + } } j += 2; } - let mut end = significant - .get(j) - .map_or(alias_token.span.end, |t| t.span.end); + // A recognized header ID at EOF must remain inside the whole span. + // Keep the old next-token/body behavior; only the EOF fallback changes. + let header_end = significant + .get(j.saturating_sub(1)) + .or_else(|| significant.last()) + .map_or(alias_token.span.end, |token| token.span.end); + let mut end = significant.get(j).map_or(header_end, |t| t.span.end); if significant .get(j) .is_some_and(|t| &source[t.span.start..t.span.end] == "{") { end = body_ends[j].unwrap_or(source.len()); } - declarations.push(DeclarationSpan { - alias: alias.into(), - id, - kind: word.into(), - span: Span { - start: t.span.start, - end, + declarations.push(OutlineDeclaration { + declaration: DeclarationSpan { + alias: alias.into(), + id, + kind: word.into(), + span: Span { + start: t.span.start, + end, + }, }, + kind_span: t.span, + alias_span: alias_token.span, + id_literal_span, }); } - Analysis { - tokens, - diagnostics: lint(source), - declarations, - } + Ok((declarations, has_unmatched_braces)) } +#[cfg(test)] +#[path = "language_outline_tests.rs"] +mod outline_tests; + #[cfg(test)] #[path = "language_nesting_tests.rs"] mod nesting_tests; @@ -2442,11 +2579,7 @@ pub(crate) mod legacy { let value = self.quoted()?; // Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN, // braces, or compact alternatives. Uppercase hexadecimal is accepted. - if value.len() != 36 - || ![8, 13, 18, 23] - .into_iter() - .all(|index| value.as_bytes()[index] == b'-') - { + if !super::has_uuid_header_shape(&value) { return Err(Error { code: "invalid_source", offset, diff --git a/crates/oa-core/src/language_outline_tests.rs b/crates/oa-core/src/language_outline_tests.rs new file mode 100644 index 00000000..a29f180f --- /dev/null +++ b/crates/oa-core/src/language_outline_tests.rs @@ -0,0 +1,386 @@ +//! Prepared outline tests. Explicit compatibility tests additionally exercise +//! the existing parsers/analyze; the outline entry itself never calls them. +use super::*; + +fn limits() -> OutlineLimits { + OutlineLimits { + max_source_bytes: 1024 * 1024, + max_tokens: 100_000, + max_declarations: 1_000, + } +} + +fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration { + result + .declarations + .iter() + .find(|d| d.declaration.alias == alias) + .unwrap() +} + +fn text(source: &str, span: Span) -> &str { + &source[span.start..span.end] +} + +#[test] +fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() { + let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;"; + let before = source.as_bytes().to_vec(); + let result = outline(source, limits()).unwrap(); + assert_eq!(result.source.as_bytes(), before); + assert_eq!(result.source.as_ptr(), source.as_ptr()); + let d = declaration(&result, "responder"); + assert_eq!(text(source, d.kind_span), "oa_actor"); + assert_eq!(text(source, d.alias_span), "responder"); + assert_eq!( + text(source, d.id_literal_span.unwrap()), + "\"\\u00300000000-0000-0000-0000-000000000001\"" + ); + assert_eq!(d.declaration.id, Some(Id::from_u128(1))); + assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap()); + assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2); + let mut end = 0; + for token in &result.tokens { + assert_eq!(token.span.start, end); + assert!(token.span.start < token.span.end); + assert!(source.is_char_boundary(token.span.start)); + assert!(source.is_char_boundary(token.span.end)); + end = token.span.end; + } + assert_eq!(end, source.len()); + assert!(!result.has_lexical_errors); + assert!(!result.has_unmatched_braces); +} + +#[test] +fn closed_id_at_eof_is_inside_the_declaration_extent() { + for source in [ + "oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"", + "node placed id \"00000000-0000-0000-0000-000000000001\"", + ] { + let result = outline(source, limits()).unwrap(); + let d = &result.declarations[0]; + let literal = d.id_literal_span.unwrap(); + assert_eq!(d.declaration.id, Some(Id::from_u128(1))); + assert_eq!(d.declaration.span.end, source.len()); + assert!(d.declaration.span.start <= literal.start); + assert!(literal.end <= d.declaration.span.end); + } + for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] { + let result = outline(source, limits()).unwrap(); + let d = &result.declarations[0]; + assert_eq!(d.declaration.span.end, source.len()); + assert_eq!(d.declaration.id, None); + assert_eq!(d.id_literal_span, None); + } +} + +#[test] +fn missing_bad_and_historical_ids_never_allocate_authored_identity() { + let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }"; + let first = outline(source, limits()).unwrap(); + let second = outline(source, limits()).unwrap(); + assert_eq!(first, second); + assert!( + first + .declarations + .iter() + .all(|d| d.declaration.id.is_none()) + ); + assert_eq!(declaration(&first, "missing").id_literal_span, None); + assert_eq!(declaration(&first, "historical").id_literal_span, None); + assert_eq!( + text(source, declaration(&first, "bad").id_literal_span.unwrap()), + "\"not-a-uuid\"" + ); + // Balanced tokens are not a successful syntax/semantic validation claim. + assert!(!first.has_lexical_errors); + assert!(!first.has_unmatched_braces); +} + +#[test] +fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() { + let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊"; + let result = outline(source, limits()).unwrap(); + assert_eq!(result.source, source); + assert_eq!(result.tokens.last().unwrap().span.end, source.len()); + assert!(result.has_lexical_errors); + assert!(result.has_unmatched_braces); + let ready = declaration(&result, "ready"); + assert_eq!( + text(source, ready.declaration.span), + "oa_actor ready \"Ready\" { description \"} stays text\"; }" + ); + assert_eq!( + declaration(&result, "outer").declaration.span.end, + source.len() + ); + assert_eq!( + declaration(&result, "pending").declaration.span.end, + source.len() + ); + let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap(); + assert!(unmatched_close.has_unmatched_braces); + assert!(!unmatched_close.has_lexical_errors); +} + +#[test] +fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() { + let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\""; + let result = outline(source, limits()).unwrap(); + assert_eq!(result.declarations.len(), 1); + assert!(!result.has_lexical_errors); + assert!(!result.has_unmatched_braces); + // There is deliberately no `valid` flag: the known-complete fact is scanning + // these bytes, not the missing body becoming legal or the draft being applied. + assert_eq!(result.source, source); +} + +#[test] +fn keyword_alias_and_collisions_keep_every_source_ordered_header() { + let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}"; + let result = outline(source, limits()).unwrap(); + assert_eq!(result.declarations.len(), 5); + let duplicates: Vec<_> = result + .declarations + .iter() + .filter(|d| d.declaration.alias == "oa_activity") + .collect(); + assert_eq!(duplicates.len(), 2); + assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start); + assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id); + assert!( + result + .tokens + .iter() + .any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword) + ); + assert_eq!( + text(source, declaration(&result, "placement").kind_span), + "node" + ); + assert_eq!(declaration(&result, "placement").declaration.id, None); +} + +#[test] +fn bounds_refuse_before_returning_a_partial_outline() { + let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}"; + let admitted = outline(source, limits()).unwrap(); + let exact = OutlineLimits { + max_source_bytes: source.len(), + max_tokens: admitted.tokens.len(), + max_declarations: 2, + }; + assert_eq!(outline(source, exact).unwrap(), admitted); + for resource in [ + OutlineResource::SourceBytes, + OutlineResource::Tokens, + OutlineResource::Declarations, + ] { + let mut lower = exact; + match resource { + OutlineResource::SourceBytes => lower.max_source_bytes -= 1, + OutlineResource::Tokens => lower.max_tokens -= 1, + OutlineResource::Declarations => lower.max_declarations -= 1, + } + let error = outline(source, lower).unwrap_err(); + assert_eq!(error.resource, resource); + assert!(error.observed_at_least > error.limit); + assert!(source.is_char_boundary(error.offset)); + if resource == OutlineResource::Declarations { + assert_eq!(error.offset, source.find("oa_actor b").unwrap()); + } + } + let empty = outline( + "", + OutlineLimits { + max_source_bytes: 0, + max_tokens: 0, + max_declarations: 0, + }, + ) + .unwrap(); + assert!(empty.tokens.is_empty() && empty.declarations.is_empty()); + let unicode = "// 🌊\r\noa_actor a \"A\" {}"; + let error = outline( + unicode, + OutlineLimits { + max_source_bytes: 4, + ..limits() + }, + ) + .unwrap_err(); + assert_eq!(error.resource, OutlineResource::SourceBytes); + assert_eq!(error.offset, 0); + let error = outline( + unicode, + OutlineLimits { + max_tokens: 1, + ..limits() + }, + ) + .unwrap_err(); + assert_eq!(error.resource, OutlineResource::Tokens); + assert_eq!(error.offset, unicode.find('\n').unwrap()); +} + +#[test] +fn outline_ranges_follow_current_bytes_without_identity_stabilization() { + let old = "oa_actor a \"A\" {}\nsource @a;"; + let current = format!("// 航海 🚒\r\n{old}"); + let old_outline = outline(old, limits()).unwrap(); + let current_outline = outline(¤t, limits()).unwrap(); + let shift = current.len() - old.len(); + let old_role = &old_outline.declarations[0]; + let current_role = ¤t_outline.declarations[0]; + assert_eq!( + current_role.alias_span.start, + old_role.alias_span.start + shift + ); + assert_eq!( + current_role.kind_span.start, + old_role.kind_span.start + shift + ); + assert_eq!( + current_role.declaration.span.end, + old_role.declaration.span.end + shift + ); + assert_eq!(current_outline.source, current); + assert_eq!(old_outline.source, old); + assert_eq!(current_role.declaration.id, None); + assert!(!current_outline.source.contains(" id ")); + // Workspace revision/stale-handle rejection belongs to the query/adapter, + // not a claim that an old owned span can be applied to any other string. +} + +#[test] +fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() { + let mut source = String::new(); + for index in 0..600 { + source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n")); + } + source.push_str(&"}\n".repeat(600)); + let result = outline(&source, limits()).unwrap(); + assert_eq!(result.declarations.len(), 600); + assert!(!result.has_unmatched_braces); + assert_eq!( + result.declarations[0].declaration.span.end, + source.len() - 1 + ); + let error = outline( + &source, + OutlineLimits { + max_declarations: 599, + ..limits() + }, + ) + .unwrap_err(); + assert_eq!(error.resource, OutlineResource::Declarations); + assert_eq!(error.observed_at_least, 600); + assert_eq!(error.offset, source.find("oa_activity work_599").unwrap()); +} + +#[test] +fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() { + for raw in [ + "00000000000000000000000000000001", + "{00000000-0000-0000-0000-000000000001}", + "urn:uuid:00000000-0000-0000-0000-000000000001", + ] { + assert!( + Id::parse_str(raw).is_ok(), + "The UUID crate alone is too permissive" + ); + assert!(!has_uuid_header_shape(raw)); + let literal = format!("\"{raw}\""); + let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}"); + let before = modern.clone(); + let result = outline(&modern, limits()).unwrap(); + let d = declaration(&result, "model"); + assert_eq!(d.declaration.id, None); + assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal); + assert_eq!(modern, before); + let errors = parse(&modern).unwrap_err(); + assert!( + errors + .iter() + .any(|error| error.message.contains("8-4-4-4-12") + && error.span.start == modern.find(&literal).unwrap()) + ); + // Intentional compatibility correction: old analyze yielded Some(Id(1)) + // here despite lint rejecting the header. It now shares outline's None. + let analysis = analyze(&modern); + assert_eq!( + analysis + .declarations + .iter() + .find(|d| d.alias == "model") + .unwrap() + .id, + None + ); + assert!( + analysis + .diagnostics + .iter() + .any(|error| error.message.contains("8-4-4-4-12")) + ); + let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}"); + let legacy_errors = legacy::parse(&legacy_source).unwrap_err(); + assert!( + legacy_errors + .iter() + .any(|error| error.message.contains("8-4-4-4-12")) + ); + } +} + +#[test] +fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() { + let expected = Id::from_u128(0xabcdef); + let canonical = expected.to_string(); + let uppercase = canonical.to_uppercase(); + assert_ne!(uppercase, canonical); + for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] { + let literal = format!("\"{raw}\""); + let decoded: String = serde_json::from_str(&literal).unwrap(); + assert!(has_uuid_header_shape(&decoded)); + let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}"); + let result = outline(&modern, limits()).unwrap(); + let d = declaration(&result, "model"); + assert_eq!(d.declaration.id, Some(expected)); + assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal); + assert_eq!(result.source.as_bytes(), modern.as_bytes()); + assert_eq!(parse(&modern).unwrap().id, expected); + let analysis = analyze(&modern); + assert_eq!(analysis.declarations[0].id, Some(expected)); + assert!( + !analysis + .diagnostics + .iter() + .any(|error| error.severity == crate::validation::Severity::Error) + ); + let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}"); + assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected); + } +} + +#[test] +fn header_shape_still_requires_valid_hexadecimal_uuid_data() { + let raw = "00000000-0000-0000-0000-00000000000Z"; + assert!(has_uuid_header_shape(raw)); + assert!(Id::parse_str(raw).is_err()); + let literal = format!("\"{raw}\""); + let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}"); + let result = outline(&source, limits()).unwrap(); + let d = declaration(&result, "model"); + assert_eq!(d.declaration.id, None); + assert_eq!(text(&source, d.id_literal_span.unwrap()), literal); + assert!( + parse(&source) + .unwrap_err() + .iter() + .any(|error| error.message == "Invalid UUID identity.") + ); + assert_eq!(analyze(&source).declarations[0].id, None); +} diff --git a/crates/oa-desktop/Cargo.toml b/crates/oa-desktop/Cargo.toml index 2eed9a7c..13f5f911 100644 --- a/crates/oa-desktop/Cargo.toml +++ b/crates/oa-desktop/Cargo.toml @@ -18,6 +18,7 @@ serde_json.workspace = true reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["rt", "time"] } tempfile = "3" +unicode-segmentation = "1.13.3" [dev-dependencies] iced_runtime = "0.14" diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 9b83bb7e..312ced28 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -10,12 +10,16 @@ mod diagram_focus_tests; pub(crate) mod diagram_removal; #[cfg(test)] mod diagram_removal_tests; +mod diagram_reveal; #[cfg(test)] mod diagram_selection_tests; +mod diagram_usages; 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; #[cfg(test)] @@ -438,9 +442,20 @@ pub enum Message { Linted(u64, Result, String>), SourceDiagnostic(usize), SourceRelated(SourceLocation), - SourceViewport, + SourceViewport(Option), + SourceSymbols( + crate::source_navigation::SymbolContext, + 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), SourceForSelection, SourceNavigation(crate::source_navigation::Event), + SourceFindClose(crate::source_navigation::SymbolContext, Id), Save(bool), SavePath(Option), Saved(Result, SaveFailure>), @@ -520,6 +535,18 @@ pub struct App { pub source_jump: Option, source_select_line: bool, pub source_navigation: crate::source_navigation::State, + source_find_instance: Id, + pub source_symbols: crate::source_navigation::SymbolState, + pub source_rename: source_rename::State, + pub(crate) diagram_usages: Option, + pub(crate) diagram_navigation_focus: Option, + source_diagram_job: Option, + diagram_lookup_in_flight: Option, + pending_diagram_reveal: Option<( + crate::diagram_usage::Context, + crate::diagram_usage::Usage, + u64, + )>, pub model_issues: Vec, pub migration: Option, source_version: u64, @@ -630,6 +657,14 @@ impl App { source_jump: None, source_select_line: false, 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, + diagram_lookup_in_flight: None, + pending_diagram_reveal: None, model_issues, migration: None, source_version: 0, @@ -676,18 +711,37 @@ impl App { || self.behavior.has_pending() } fn sync_source(&mut self) { + self.sync_source_with_alias_editor(None); + } + fn sync_source_with_alias_editor(&mut self, hint: Option>) { self.source_workspace.accept(self.document.bundle()); - self.show_source_file(false); + self.show_source_file_with_alias_editor(false, hint); self.source_diagnostics = self.document.compiled().diagnostics.clone(); self.source_version += 1; self.lint_requested = None; } fn show_source_file(&mut self, restore_cursor: bool) { + self.show_source_file_with_alias_editor(restore_cursor, None); + } + fn show_source_file_with_alias_editor( + &mut self, + restore_cursor: bool, + hint: Option>, + ) { + self.source_symbols.reveal = None; self.source_tree.reveal(&self.source_workspace.active); self.source_select_line = false; let source = self.source_workspace.text().to_owned(); self.source_navigation.refresh(&source); - self.source_editor = text_editor::Content::with_text(&source); + if !hint.is_some_and(|hint| { + hint.apply( + &mut self.source_editor, + &self.source_workspace.active, + &source, + ) + }) { + self.source_editor = text_editor::Content::with_text(&source); + } self.source_snapshot = Arc::from(source); self.source_jump = restore_cursor .then(|| { @@ -697,6 +751,9 @@ impl App { .map(|b| b.cursor) }) .flatten(); + if let Some(cursor) = self.source_jump { + self.queue_source_reveal(cursor, false); + } } fn selected_architecture_placement(&self) -> Option { self.active_diagram @@ -729,6 +786,9 @@ impl App { } } fn sync(&mut self) { + self.sync_with_alias_editor(None); + } + fn sync_with_alias_editor(&mut self, hint: Option>) { self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.model_issues = validate(self.project()); self.normalize_creation(); @@ -758,7 +818,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) { @@ -950,6 +1010,9 @@ impl App { let canvas_size = self.canvas_size; 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); hosted.leave_document(); @@ -961,6 +1024,9 @@ impl App { self.canvas_size = canvas_size; 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; self.status = "Project ready".into(); @@ -1061,16 +1127,57 @@ impl App { crate::model_menu::popup(self), ) .into(); - crate::behavior::scenario::focus::root( - behavior::canvas_workspace::focus_root( - crate::hosted::view::overlay(self, self.reference_overlay(base)), - behavior::canvas_workspace::Scope::input_owner(self), + crate::source_navigation::interaction::observe( + crate::behavior::scenario::focus::root( + behavior::canvas_workspace::focus_root( + crate::hosted::view::overlay( + self, + self.reference_overlay(crate::diagram_usage::view::overlay(self, base)), + ), + behavior::canvas_workspace::Scope::input_owner(self), + ), + self.scenario_focus_owner(), ), - self.scenario_focus_owner(), + self.source_symbols.raw_interaction.clone(), ) } pub fn subscription(&self) -> Subscription { Subscription::batch([ + self.diagram_usages + .as_ref() + .map_or_else(Subscription::none, |panel| { + iced::event::listen_with(|event, status, _| { + let iced::Event::Keyboard(keyboard::Event::KeyPressed { + key, + modifiers, + .. + }) = event + else { + return None; + }; + if !modifiers.is_empty() { + return None; + } + match key { + keyboard::Key::Named(keyboard::key::Named::ArrowUp) => Some(-1i8), + keyboard::Key::Named(keyboard::key::Named::ArrowDown) => Some(1i8), + keyboard::Key::Named(keyboard::key::Named::Enter) + if status == iced::event::Status::Ignored => + { + Some(0i8) + } + _ => None, + } + }) + .with(panel.token) + .map(|(token, step)| { + Message::DiagramUsage(if step == 0 { + crate::diagram_usage::Event::Activate(token) + } else { + crate::diagram_usage::Event::Move(token, step) + }) + }) + }), iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick), window::close_requests().map(|id| Message::Request(Pending::Close(id))), window::resize_events().map(|(_, size)| Message::WindowResized(size)), @@ -1082,8 +1189,17 @@ impl App { ) .then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)) }), + // Use one captured-Escape bridge: a focused Find input consumes + // Escape, while modal priority remains in the ordinary handler. behavior::creation::subscription( - self.behavior.creation.has_pending() || self.behavior.source_active(), + self.behavior.creation.has_pending() + || self.diagram_usages.is_some() + || self.behavior.source_active() + || (self.page == Page::Text + && (self.source_symbols.open + || self.source_navigation.open + || self.source_rename.open()) + && self.architecture.is_none()), ) .map(|()| Message::Escape), iced::event::listen_with(|event, status, _| { @@ -1133,6 +1249,31 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + // A queued toolbar from a previous input owner cannot retire a newer + // navigation destination or allocation capability before dispatch. + if let Message::BehaviorControl(intent) = &message + && behavior::canvas_workspace::Scope::input_owner(self) != Some(intent.scope) + { + return Task::none(); + } + // 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 + // dimensions or invalidate the exact pending destination epoch. + if self.pending_diagram_reveal.is_some() + && Self::is_diagram_viewport(&message) + && usage_viewport.is_none() + { + return Task::none(); + } + self.reconcile_pending_diagram_reveal(&message); self.allocation_before(&message); if let Message::WorkDrag(event) = message { return self.allocation_event(event); @@ -1151,6 +1292,19 @@ impl App { if self.allocation_boundary() != allocation_before { self.allocation_retire(); } + if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal { + *epoch = self.canvas_epoch; + } + if let Some(surface) = usage_viewport { + self.finish_diagram_reveal(surface); + } + if self + .diagram_navigation_focus + .as_ref() + .is_some_and(|focus| self.diagram_focus(focus.usage.surface).is_none()) + { + self.diagram_navigation_focus = None; + } if !camera && self.camera_boundary() != before { self.camera_epoch = self.camera_epoch.wrapping_add(1); self.camera_boundary_epoch = self.canvas_epoch; @@ -1227,6 +1381,78 @@ impl App { ) } fn update_inner(&mut self, message: Message) -> Task { + let message = match message { + Message::SourceFindClose(context, instance) => { + if context != self.source_symbol_context() + || instance != self.source_find_instance + || !self.source_navigation.open + || !self.source_navigation_available() + { + return Task::none(); + } + // Only the checked envelope may enter the legacy Close handler. + Message::SourceNavigation(crate::source_navigation::Event::Close) + } + Message::SourceNavigation(crate::source_navigation::Event::Close) => { + return Task::none(); + } + message => message, + }; + if let Message::SourceSymbols(context, _) = &message + && (context.session != self.source_workspace.session() + || context.path != self.source_workspace.active) + { + return Task::none(); + } + // Internal results and geometry do not constitute a new user intent. + // Process query completion even behind a modal to release its worker slot; + // its handler independently refuses navigation outside the live editor. + 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); + } + if !matches!( + &message, + Message::CheckpointTick + | Message::CheckpointWritten(..) + | Message::Lint(_) + | Message::Linted(..) + | Message::SourceViewport(_) + | Message::WindowResized(_) + | Message::CanvasResized(_) + | Message::DiagramRevealViewport(..) + ) { + self.source_interaction(); + } + if let Message::DiagramUsage(event) = message { + return self.diagram_usage_event(event); + } + if let Some(panel) = &self.diagram_usages { + if matches!(&message, Message::Request(Pending::Close(_))) { + self.diagram_usages = None; + return self.update(message); + } + if matches!(&message, Message::Escape) { + return self.diagram_usage_event(crate::diagram_usage::Event::Close(panel.token)); + } + if !matches!( + &message, + Message::CheckpointTick + | Message::CheckpointWritten(..) + | Message::Lint(_) + | Message::Linted(..) + | Message::WindowResized(_) + | Message::CanvasResized(_) + | Message::SourceViewport(_) + ) { + return Task::none(); + } + } if let Some(task) = self.reference_message(&message) { return task; } @@ -1294,7 +1520,7 @@ impl App { | Message::CanvasResized(_) | Message::Lint(_) | Message::Linted(_, _) - | Message::SourceViewport + | Message::SourceViewport(_) | Message::Request(Pending::Close(_)) ) { @@ -1340,7 +1566,7 @@ impl App { | Message::Exported(_) | Message::WindowResized(_) | Message::CanvasResized(_) - | Message::SourceViewport + | Message::SourceViewport(_) ) { return Task::none(); @@ -1380,7 +1606,7 @@ impl App { | Message::CheckpointLoaded(..) | Message::WindowResized(_) | Message::CanvasResized(_) - | Message::SourceViewport + | Message::SourceViewport(_) | Message::Lint(_) | Message::Linted(_, _) | Message::Saved(_) @@ -1410,6 +1636,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(_) @@ -1454,6 +1692,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 { @@ -2001,6 +2240,8 @@ impl App { return self.update(Message::Add(child)); } Message::Escape => { + let restore_source_focus = self.source_navigation_available() + && (self.source_navigation.open || self.source_symbols.open); if self.behavior.source_back() { self.status = "Returned to the unchanged parent form; no source was created.".into(); @@ -2017,6 +2258,12 @@ impl App { self.status = "Drawing cancelled · Select / move is active".into(); } self.source_navigation.open = false; + self.source_symbols.open = false; + self.source_symbols.results = None; + self.source_symbols.notice = None; + if restore_source_focus { + self.restore_source_editor_focus(); + } self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.selected_bend = None; } @@ -3061,6 +3308,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"); @@ -3074,6 +3322,9 @@ impl App { if self.path.is_none() || self.path.as_ref() == Some(&receipt.target) { + if self.path.as_ref() != Some(&receipt.target) { + self.source_workspace.reset_navigation_session(); + } self.path = Some(receipt.target); self.document.bind_repair_baseline(disk.reachable_bundle()); self.disk_project = Some(disk); @@ -3142,13 +3393,30 @@ impl App { } } Message::SourceRelated(location) => self.jump_to_source(location), - Message::SourceViewport => { - if let Some(cursor) = self.source_jump.take() { - self.source_editor.move_to(cursor); - if self.source_select_line && cursor.selection.is_none() { - self.source_editor.perform(text_editor::Action::SelectLine); - } - return iced::widget::operation::focus("source-editor"); + 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); + } + Message::DiagramUsage(event) => return self.diagram_usage_event(event), + Message::DiagramUsagesReady(completion) => { + return self.diagram_usages_ready(completion); + } + Message::DiagramRevealViewport(context, epoch, size) => { + if self + .pending_diagram_reveal + .as_ref() + .is_some_and(|(c, _, e)| *c == context && *e == epoch) + && self + .diagram_reveal_viewport(&Message::DiagramRevealViewport( + context, epoch, size, + )) + .is_some() + { + return self.handle(Message::CanvasResized(size)); } } Message::ApplySource => { @@ -3195,6 +3463,7 @@ impl App { return Task::none(); } self.page = Page::Text; + self.source_find_instance = Id::new_v4(); self.source_navigation.open = true; self.source_navigation.refresh(&self.source_editor.text()); return iced::widget::operation::focus(if go_to_line { @@ -3203,13 +3472,22 @@ impl App { "source-find" }); } - Event::Close => self.source_navigation.open = false, + Event::Close => { + let was_open = self.source_navigation.open; + self.source_navigation.open = false; + if was_open { + self.restore_source_editor_focus(); + } + } Event::Query(query) => { self.source_navigation.query = query; self.source_navigation.refresh(&self.source_editor.text()); } Event::Next(backwards) if self.page == Page::Text => { self.source_jump = self.source_navigation.next(backwards); + if let Some(cursor) = self.source_jump { + self.queue_source_reveal(cursor, false); + } if self.source_jump.is_none() { self.status = "No matching source text.".into(); } @@ -3220,8 +3498,7 @@ impl App { .destination(self.source_editor.line_count()) { Ok(cursor) => { - self.source_jump = Some(cursor); - self.source_select_line = true; + self.queue_source_reveal(cursor, true); } Err(error) => self.status = error, }, @@ -3304,6 +3581,9 @@ impl App { } self.sync_source(); } + if self.path.as_ref() != Some(&receipt.path) { + self.source_workspace.reset_navigation_session(); + } self.disk_project = Some(disk); self.path = Some(receipt.path); self.hosted.session = None; @@ -3864,7 +4144,7 @@ impl App { self.path = Some(path); self.document.bind_repair_baseline(disk.reachable_bundle()); self.source_workspace = Workspace::new(&disk.bundle, false); - self.source_workspace.reachable = disk.reachable.clone(); + self.source_workspace.set_reachable(disk.reachable.clone()); self.show_source_file(false); self.source_diagnostics = error.diagnostics().to_vec(); self.source_version += 1; @@ -3916,11 +4196,13 @@ impl App { let column = prefix .rfind('\n') .map_or(offset, |start| offset - start - 1); - self.source_jump = Some(text_editor::Cursor { - position: text_editor::Position { line, column }, - selection: None, - }); - self.source_select_line = true; + self.queue_source_reveal( + text_editor::Cursor { + position: text_editor::Position { line, column }, + selection: None, + }, + true, + ); } fn create_source_file(&mut self) -> Result<(), String> { diff --git a/crates/oa-desktop/src/app/allocation_drag.rs b/crates/oa-desktop/src/app/allocation_drag.rs index 2447e25e..e4191c11 100644 --- a/crates/oa-desktop/src/app/allocation_drag.rs +++ b/crates/oa-desktop/src/app/allocation_drag.rs @@ -372,6 +372,7 @@ impl App { && self.pending.is_none() && self.file_prompt.is_none() && !self.delete_pending + && self.diagram_usages.is_none() } pub fn allocation_capability(&self) -> Capability<'_> { Capability { @@ -403,7 +404,11 @@ impl App { | Message::Linted(..) | Message::CanvasResized(_) | Message::WindowResized(_) - | Message::SourceViewport + | Message::SourceViewport(_) + | Message::SourceSymbolsReady(_) + | Message::SourceRenameReady(_) + | Message::DiagramUsagesReady(_) + | Message::DiagramRevealViewport(..) ) { self.allocation_drag.retire(); } diff --git a/crates/oa-desktop/src/app/architecture_authoring/tests.rs b/crates/oa-desktop/src/app/architecture_authoring/tests.rs index 564c2dc2..acd9c475 100644 --- a/crates/oa-desktop/src/app/architecture_authoring/tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring/tests.rs @@ -70,6 +70,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() { view_index: None, diagram: app.active_diagram.unwrap(), selected: element, + navigation_focus: None, zoom: app.zoom, pan: app.pan, context: app.route_context().unwrap(), diff --git a/crates/oa-desktop/src/app/architecture_authoring_tests.rs b/crates/oa-desktop/src/app/architecture_authoring_tests.rs index 2cd1ea84..90f9e9b2 100644 --- a/crates/oa-desktop/src/app/architecture_authoring_tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring_tests.rs @@ -225,6 +225,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() { view_index: None, diagram: app.active_diagram.unwrap(), selected: app.selected, + navigation_focus: None, zoom: app.zoom, pan: app.pan, context: app.route_context().unwrap(), @@ -300,6 +301,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() { view_index: None, diagram: app.active_diagram.unwrap(), selected: element, + navigation_focus: None, zoom: app.zoom, pan: app.pan, context: app.route_context().unwrap(), diff --git a/crates/oa-desktop/src/app/diagram_removal_tests.rs b/crates/oa-desktop/src/app/diagram_removal_tests.rs index 7ef73306..72e9ead0 100644 --- a/crates/oa-desktop/src/app/diagram_removal_tests.rs +++ b/crates/oa-desktop/src/app/diagram_removal_tests.rs @@ -4,6 +4,8 @@ use crate::behavior::canvas_workspace::tests::{Mounted, key}; use iced::advanced::widget::{Operation, operation}; use iced::{Event as Input, Point, Rectangle, Size, Vector, keyboard, mouse}; +mod navigation_union; + #[tokio::test] async fn mounted_state_action_and_internal_rows_delete_relations_and_preserve_reused_work() { use crate::behavior::state_canvas::visible_row_relation; @@ -360,7 +362,13 @@ async fn mounted_oa_sa_delete_hides_exact_placement_and_round_trips_source_and_m assert_eq!(f.app.document.bundle(), &before.bundle); delete(&mut mounted, &mut f.app, true).await; assert_eq!( - f.app.delete_target.as_ref().unwrap().snapshot.request.object, + f.app + .delete_target + .as_ref() + .unwrap() + .snapshot + .request + .object, f.object ); assert_eq!(f.app.document.bundle(), &before.bundle); @@ -707,13 +715,15 @@ async fn mounted_inspector_can_take_focus_during_passive_save() { } mounted.perform(&mut f.app, task).await; let geometry = mounted.geometry(&f.app); - assert!(geometry.focused.iter().any(|(_, bounds, focused)| { - *bounds == field && *focused - })); + assert!( + geometry + .focused + .iter() + .any(|(_, bounds, focused)| { *bounds == field && *focused }) + ); assert!(!geometry.focused.iter().any(|(id, _, focused)| { *focused - && id.as_ref() - == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner")) + && id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner")) })); let before = f.app.document.bundle().clone(); let messages = mounted @@ -764,7 +774,9 @@ async fn mounted_passive_save_retires_held_canvas_drag_but_allows_a_fresh_drag() mounted .batch( &mut f.app, - &[Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))], + &[Input::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + ))], mouse::Cursor::Available(moved), ) .await; @@ -1049,11 +1061,15 @@ async fn mounted_state_delete_uses_state_placement_and_preserves_architecture_pl assert!(crate::behavior::state_edit::unplaced(app.project(), diagram).is_empty()); let owner = app.state_context().unwrap(); let focused = |mounted: &mut Mounted, app: &App| { - mounted.geometry(app).focused.iter().any(|(id, _, focused)| { - *focused - && id.as_ref() - == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner")) - }) + mounted + .geometry(app) + .focused + .iter() + .any(|(id, _, focused)| { + *focused + && id.as_ref() + == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner")) + }) }; assert!(focused(&mut mounted, &app)); let messages = delete(&mut mounted, &mut app, false).await; diff --git a/crates/oa-desktop/src/app/diagram_removal_tests/navigation_union.rs b/crates/oa-desktop/src/app/diagram_removal_tests/navigation_union.rs new file mode 100644 index 00000000..c2078c31 --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_removal_tests/navigation_union.rs @@ -0,0 +1,146 @@ +//! Navigate first, then remove through the mounted root canvas and history. +use super::*; +use crate::diagram_usage::{self as usage, Target}; + +async fn navigate_then_remove(project: Project, subject: Id, owner: Id, row: bool) { + let mut app = App::with_project(project); + app.window_size = Size::new(2000., 1400.); + let before = app.document.snapshot(); + let message = app.diagram_usage_message(subject); + let _ = app.update(message); + let panel = app.diagram_usages.as_ref().unwrap(); + let token = panel.token; + let context = panel.context; + // Complete the same query independently without polling its worker Task. + let process = app.behavior.topology.usage_snapshot().into_query_state(); + let completion = usage::Completion { + token, + context, + result: Ok(usage::collect( + app.project(), + subject, + &process, + &behavior::scenario::State::default(), + )), + }; + let _ = app.update(Message::DiagramUsagesReady(completion)); + let found = app + .diagram_usages + .as_ref() + .unwrap() + .usages + .iter() + .find(|usage| { + if row { + matches!(usage.target, Target::StateRow { relation, .. } if relation == subject) + } else { + matches!(usage.target, Target::Port { port, .. } if port == subject) + } + }) + .unwrap() + .clone(); + let _ = app.update(Message::DiagramUsage(usage::Event::Choose( + token, + found.clone(), + ))); + assert_eq!(app.selected, subject); + // refresh_draft normalizes the owner's presentation placement; navigation + // must not leave it armed as a removal target. + assert!(app.selected_node.is_none()); + assert!(app.behavior.diagram.placement.is_none()); + assert_eq!( + app.diagram_focus(found.surface).unwrap().target, + found.target + ); + + let mut mounted = Mounted::new(&mut app).await; + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(app.pending_diagram_reveal.is_none()); + for model in [false, true] { + mounted + .perform( + &mut app, + iced::widget::operation::focus(iced::advanced::widget::Id::from( + "diagram-keyboard-owner", + )), + ) + .await; + let messages = delete(&mut mounted, &mut app, model).await; + let request = messages + .iter() + .find_map(|message| match message { + Message::DiagramRemove(intent) + | Message::StateDiagram(behavior::state_edit::Event::Remove(intent)) => { + Some(intent) + } + _ => None, + }) + .expect("actual focused canvas emitted a removal intent"); + assert_eq!(request.object, subject); + assert_eq!(request.placement, None); + assert_eq!(request.model, model); + assert_eq!( + app.delete_target.as_ref().unwrap().snapshot.request.object, + subject + ); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + if !model { + let task = app.update(Message::CancelDelete); + mounted.perform(&mut app, task).await; + } + } + let token = app.delete_target.as_ref().unwrap().snapshot.token; + let task = app.update(Message::ConfirmDelete(token)); + mounted.perform(&mut app, task).await; + let expected = execute_batch(&before.decoded.project, &[Command::Delete(subject)]).unwrap(); + assert_eq!(app.project(), &expected); + assert!(app.project().contains(owner)); + consistent(&app); + let task = app.update(Message::Undo); + mounted.perform(&mut app, task).await; + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); +} + +#[tokio::test] +async fn port_navigation_then_canvas_delete_preserves_owner_and_restores_exact_history() { + let mut project = Project::empty(); + let owner = project.add(Kind::Function, "Compute", 1200., 1500.); + let port = Id::new_v4(); + let element = oa_core::ports::make_port( + &project, + port, + owner, + "Input", + oa_core::PortContract::FunctionInput { + incoming_items: vec![], + }, + ) + .unwrap(); + project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap(); + navigate_then_remove(project, port, owner, false).await; +} + +#[tokio::test] +async fn state_row_navigation_then_canvas_delete_preserves_work_and_restores_exact_history() { + let mut project = Project::empty(); + let machine = project + .add_element(Kind::StateMachine, Layer::Sa, "Modes", None) + .unwrap(); + let region = project + .add_element(Kind::Region, Layer::Sa, "Main", Some(machine)) + .unwrap(); + let state = project + .add_element(Kind::State, Layer::Sa, "Running", Some(region)) + .unwrap(); + let work = project + .add_element(Kind::Function, Layer::Sa, "Monitor", None) + .unwrap(); + let relation = project.relate(RelationKind::StateAction, "Entry", state, work); + let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap(); + project = execute_batch(&project, &[plan.command]).unwrap(); + navigate_then_remove(project, relation, work, true).await; +} diff --git a/crates/oa-desktop/src/app/diagram_reveal.rs b/crates/oa-desktop/src/app/diagram_reveal.rs new file mode 100644 index 00000000..f0836867 --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_reveal.rs @@ -0,0 +1,146 @@ +//! Complete a navigation camera after the destination reports its real viewport. +use super::*; +use crate::diagram_usage::{Context, Surface}; +#[cfg(test)] +mod tests; + +impl App { + pub(super) fn is_diagram_viewport(message: &Message) -> bool { + matches!( + message, + Message::CanvasResized(_) + | Message::DiagramRevealViewport(..) + | Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..)) + | Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..)) + | Message::StateDiagram(behavior::state_edit::Event::Viewport(..)) + ) + } + pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> { + self.pending_diagram_reveal + .as_ref() + .filter(|(_, u, _)| u.surface == Surface::Saved(diagram)) + .map(|(context, _, epoch)| (*context, *epoch)) + } + + pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option { + let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?; + if context.document != self.canvas_document + || context.revision != self.document.revision() + || *epoch != self.canvas_epoch + { + return None; + } + let (surface, size, valid) = match message { + Message::DiagramRevealViewport(c, e, size) => ( + usage.surface, + *size, + c == context + && e == epoch + && self.page == Page::Diagram + && Some(usage.surface.id()) == self.active_diagram, + ), + Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => ( + Surface::Process(c.owner), + *size, + c.document == context.document + && c.revision == context.revision + && c.epoch == *epoch, + ), + Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => ( + Surface::Scenario(c.scenario), + *size, + c.document == context.document + && c.revision == context.revision + && c.epoch == *epoch, + ), + Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => ( + Surface::Saved(c.diagram), + *size, + c.document == context.document + && c.revision == context.revision + && c.epoch == *epoch, + ), + _ => return None, + }; + (valid + && surface == usage.surface + && size.width.is_finite() + && size.height.is_finite() + && size.width > 0. + && size.height > 0.) + .then_some(surface) + } + + pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) { + if !matches!( + message, + Message::CheckpointTick + | Message::CheckpointWritten(..) + | Message::Lint(_) + | Message::Linted(..) + | Message::SourceViewport(_) + | Message::WindowResized(_) + | Message::CanvasResized(_) + | Message::DiagramRevealViewport(..) + | Message::DiagramUsage(_) + | Message::DiagramUsagesReady(_) + | Message::SourceSymbolsReady(_) + | Message::SourceRenameReady(_) + | Message::WorkDrag(allocation_drag::Event::KeyboardBoundary) + | Message::WorkDrag(allocation_drag::Event::Unfocused) + | Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..)) + | Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..)) + | Message::StateDiagram(behavior::state_edit::Event::Viewport(..)) + ) { + self.pending_diagram_reveal = None; + if !Self::is_navigation_camera(message) { + self.diagram_navigation_focus = None; + } + } + } + + fn is_navigation_camera(message: &Message) -> bool { + if let Message::BehaviorControl(intent) = message { + return Self::is_navigation_camera(&intent.message); + } + matches!( + message, + Message::Pan(_) + | Message::Zoom(_) + | Message::Fit + | Message::TopologyCanvas( + behavior::topology::CanvasEvent::Camera(..) + | behavior::topology::CanvasEvent::Zoom(..) + | behavior::topology::CanvasEvent::Scrolled(..) + ) + | Message::ScenarioCanvas( + behavior::scenario::CanvasEvent::Camera(..) + | behavior::scenario::CanvasEvent::Zoom(..) + | behavior::scenario::CanvasEvent::Scrolled(..) + ) + | Message::StateDiagram( + behavior::state_edit::Event::Pan(..) + | behavior::state_edit::Event::Zoom(..) + | behavior::state_edit::Event::Fit(..) + ) + ) + } + + pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) { + let Some((context, usage, _)) = self.pending_diagram_reveal.take() else { + return; + }; + if context.document == self.canvas_document + && context.revision == self.document.revision() + && usage.surface == surface + && !self.draft_dirty() + && !self.source_dirty() + && !self.behavior.has_pending() + { + // The normal viewport handler has now stored the measured size. + // Target identity was validated for this revision before navigation. + self.reveal_diagram_usage(&usage); + } + self.pending_diagram_reveal = None; + } +} diff --git a/crates/oa-desktop/src/app/diagram_reveal/tests.rs b/crates/oa-desktop/src/app/diagram_reveal/tests.rs new file mode 100644 index 00000000..2e27aa75 --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_reveal/tests.rs @@ -0,0 +1,403 @@ +//! Independent first-layout navigation oracles, through the real App boundary. +use super::*; +use crate::diagram_usage::{self as usage, Target, Usage}; +use iced::{Size, Vector}; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug)] +enum Canvas { + Architecture, + Process, + Scenario, + State, +} +const CANVASES: [Canvas; 4] = [ + Canvas::Architecture, + Canvas::Process, + Canvas::Scenario, + Canvas::State, +]; +const WIDE: Size = Size::new(1200., 700.); +const ACTUAL: Size = Size::new(500., 300.); + +fn send(app: &mut App, message: Message) { + let _ = app.update(message); +} + +fn fixture(canvas: Canvas) -> (App, Usage) { + let mut p = Project::empty(); + let work = p + .add_element(Kind::Activity, Layer::Oa, "Respond", None) + .unwrap(); + let (surface, subject) = match canvas { + Canvas::Architecture => { + let diagram = p.ensure_diagram(Layer::Oa); + p.place( + diagram, + work, + Position { + x: 12_000., + y: 9_000., + }, + ); + (Surface::Saved(diagram), work) + } + Canvas::Process | Canvas::Scenario => { + let kind = if matches!(canvas, Canvas::Process) { + Kind::Process + } else { + Kind::Scenario + }; + let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap(); + let occurrence = p + .add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner)) + .unwrap(); + *p.elements + .get_mut(&occurrence) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + precondition: String::new(), + postcondition: String::new(), + }; + let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap(); + if !steps.contains(&occurrence) { + steps.push(occurrence); + } + ( + if matches!(canvas, Canvas::Process) { + Surface::Process(owner) + } else { + Surface::Scenario(owner) + }, + occurrence, + ) + } + Canvas::State => { + let machine = p + .add_element(Kind::StateMachine, Layer::Oa, "Modes", None) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Oa, "Main", Some(machine)) + .unwrap(); + let state = p + .add_element(Kind::State, Layer::Oa, "Ready", Some(region)) + .unwrap(); + let diagram = Id::new_v4(); + p.diagrams.insert( + diagram, + Diagram { + id: diagram, + alias: "ready_view".into(), + name: "Ready view".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::StateMachine { machine }, + nodes: BTreeMap::new(), + routes: vec![], + view_options: Default::default(), + }, + ); + p.place( + diagram, + state, + Position { + x: 8_000., + y: 11_000., + }, + ); + (Surface::Saved(diagram), state) + } + }; + let mut app = App::with_project(p); + let found = usage::collect( + app.project(), + subject, + &app.behavior.topology, + &app.behavior.scenario, + ) + .into_iter() + .find(|u| u.surface == surface && u.target != Target::Whole) + .unwrap(); + app.canvas_size = Some(WIDE); + app.behavior.diagram.viewport = Some(WIDE); + if let Surface::Process(owner) = surface { + app.behavior.topology.enter(app.canvas_document, owner); + app.behavior.topology.viewport(WIDE); + } + if let Surface::Scenario(owner) = surface { + app.behavior.scenario.enter(app.canvas_document, owner); + app.behavior.scenario.viewport(WIDE); + } + app.page = Page::Text; + (app, found) +} + +fn measurement( + canvas: Canvas, + context: Context, + epoch: u64, + surface: Surface, + size: Size, +) -> Message { + match canvas { + Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size), + Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport( + behavior::topology::Context { + document: context.document, + owner: surface.id(), + revision: context.revision, + epoch, + }, + size, + )), + Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport( + behavior::scenario::Context { + document: context.document, + scenario: surface.id(), + revision: context.revision, + epoch, + }, + size, + )), + Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport( + behavior::state_edit::Context { + document: context.document, + diagram: surface.id(), + revision: context.revision, + epoch, + }, + size, + )), + } +} + +fn stamp(app: &App) -> (Context, u64) { + let (context, _, epoch) = app + .pending_diagram_reveal + .as_ref() + .expect("reveal awaits measured destination"); + (*context, *epoch) +} + +fn assert_centered(app: &App, canvas: Canvas, target: &Usage) { + // All independently authored fixture targets fit ACTUAL at the readable cap. + // This oracle does not call the production camera/reveal implementation. + let zoom = 1.25; + let pan = Vector::new( + 250. - target.bounds.center_x() * zoom, + 150. - target.bounds.center_y() * zoom, + ); + match canvas { + Canvas::Architecture => { + assert_eq!(app.canvas_size, Some(ACTUAL)); + assert_eq!(app.zoom, zoom); + assert_eq!(app.pan, pan); + assert!(!app.fit_to_view); + } + Canvas::State => { + assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL)); + assert_eq!(app.behavior.diagram.zoom, zoom); + assert_eq!(app.behavior.diagram.pan, pan); + assert!(!app.behavior.diagram.fit); + } + Canvas::Process => { + let mut expected = behavior::topology::State::default(); + expected.enter(app.canvas_document, target.surface.id()); + expected.viewport(ACTUAL); + expected.zoom(Some(zoom)); + expected.camera(behavior::canvas_workspace::Event::Pan(pan)); + assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary()); + } + Canvas::Scenario => { + let mut expected = behavior::scenario::State::default(); + expected.enter(app.canvas_document, target.surface.id()); + expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select); + expected.viewport(ACTUAL); + expected.zoom(Some(zoom)); + expected.camera(behavior::canvas_workspace::Event::Pan(pan)); + assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary()); + } + } +} + +#[test] +fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() { + for canvas in CANVASES { + let (mut app, target) = fixture(canvas); + let accepted = app.document.snapshot(); + let drafts = app.source_workspace.bundle(); + let revision = app.document.revision(); + let history = ( + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty(), + ); + assert!(app.reveal_diagram_usage(&target)); + let (context, epoch) = stamp(&app); + send( + &mut app, + measurement(canvas, context, epoch, target.surface, ACTUAL), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert_centered(&app, canvas, &target); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), drafts); + assert_eq!(app.document.revision(), revision); + assert_eq!( + ( + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty() + ), + history + ); + // The exact delivered callback is a replay after the one-shot is consumed. + let camera = app.camera_boundary(); + send( + &mut app, + measurement(canvas, context, epoch, target.surface, ACTUAL), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert!(app.camera_boundary() == camera); + assert_centered(&app, canvas, &target); + } +} + +#[test] +fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() { + for canvas in CANVASES { + for invalid in 0..6 { + let (mut app, target) = fixture(canvas); + assert!(app.reveal_diagram_usage(&target)); + let (context, epoch) = stamp(&app); + let camera = app.camera_boundary(); + let mut stale = context; + let mut wrong_epoch = epoch; + let mut size = Size::new(111., 122.); + match invalid { + 0 => stale.document = Id::new_v4(), + 1 => stale.revision = stale.revision.wrapping_add(1), + 2 => wrong_epoch = wrong_epoch.wrapping_add(1), + 3 => size.width = f32::NAN, + 4 => size.height = f32::INFINITY, + _ => size.width = 0., + } + send( + &mut app, + measurement(canvas, stale, wrong_epoch, target.surface, size), + ); + assert!( + app.pending_diagram_reveal.is_some(), + "{canvas:?} invalid case {invalid}" + ); + assert_eq!(stamp(&app), (context, epoch)); + assert_eq!(app.canvas_epoch, epoch); + assert!( + app.camera_boundary() == camera, + "{canvas:?} invalid case {invalid}" + ); + send( + &mut app, + measurement(canvas, context, epoch, target.surface, ACTUAL), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert_centered(&app, canvas, &target); + } + } +} + +#[test] +fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() { + for canvas in CANVASES { + let (mut app, target) = fixture(canvas); + let accepted = app.document.snapshot(); + assert!(app.reveal_diagram_usage(&target)); + let (context, old_epoch) = stamp(&app); + send(&mut app, Message::WindowResized(Size::new(1100., 700.))); + let (current, epoch) = stamp(&app); + assert_eq!(current, context); + assert_eq!(epoch, app.canvas_epoch); + if matches!(canvas, Canvas::Process | Canvas::Scenario) { + assert_ne!(epoch, old_epoch); + let camera = app.camera_boundary(); + send( + &mut app, + measurement(canvas, context, old_epoch, target.surface, ACTUAL), + ); + assert_eq!(stamp(&app), (current, epoch)); + assert!(app.camera_boundary() == camera); + } + send( + &mut app, + measurement(canvas, current, epoch, target.surface, ACTUAL), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert_centered(&app, canvas, &target); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() { + for canvas in CANVASES { + for action in 0..3 { + let (mut app, target) = fixture(canvas); + let accepted = app.document.snapshot(); + assert!(app.reveal_diagram_usage(&target)); + let (context, epoch) = stamp(&app); + let event = match action { + 0 => match canvas { + Canvas::Architecture => Message::Pan(Vector::new(31., -17.)), + Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan( + app.state_context().unwrap(), + Vector::new(31., -17.), + )), + Canvas::Process => { + Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera( + app.topology_context().unwrap(), + app.camera_epoch, + behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)), + )) + } + Canvas::Scenario => { + Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera( + app.scenario_context().unwrap(), + app.camera_epoch, + behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)), + )) + } + }, + 1 => Message::Field("/name".into(), "Pending title edit".into()), + _ => Message::Page(Page::Catalog), + }; + send(&mut app, event); + assert!( + app.pending_diagram_reveal.is_none(), + "{canvas:?} action {action}" + ); + let camera = app.camera_boundary(); + let page = app.page; + // A stale layout callback at the already retained size cannot + // restore the old navigation camera after deliberate user input. + send( + &mut app, + measurement(canvas, context, epoch, target.surface, WIDE), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert!( + app.camera_boundary() == camera, + "{canvas:?} action {action}" + ); + assert_eq!(app.page, page); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + if action == 1 { + assert!(app.draft_dirty()); + } + } + } +} diff --git a/crates/oa-desktop/src/app/diagram_usages.rs b/crates/oa-desktop/src/app/diagram_usages.rs new file mode 100644 index 00000000..fb17862f --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_usages.rs @@ -0,0 +1,446 @@ +//! Navigation-only adapter: validates destinations before changing presentation. +use super::*; +use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage}; +#[cfg(test)] +mod tests; + +impl App { + pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message { + Message::DiagramUsage(Event::Open(Context { + document: self.canvas_document, + revision: self.document.revision(), + subject, + })) + } + + fn usage_navigation_available(&self) -> bool { + !self.busy + && !self.source_rename.open() + && self.pending.is_none() + && self.file_prompt.is_none() + && !self.delete_pending + && self.workflow.is_none() + && !self.hosted.visible + && self.architecture.is_none() + && !self.reference_input_active() + && !self.behavior.has_pending() + && !self.draft_dirty() + && !self.source_dirty() + } + + /// Admit at most one expensive lookup, including one accepted Project clone. + /// A replacement panel waits for the previous worker's exact completion. + fn start_diagram_lookup(&mut self) -> Task { + let Some(panel) = self.diagram_usages.as_ref() else { + return Task::none(); + }; + if !panel.loading || self.diagram_lookup_in_flight.is_some() { + return Task::none(); + } + let (token, context) = (panel.token, panel.context); + if context.document != self.canvas_document + || context.revision != self.document.revision() + || !self.usage_navigation_available() + { + let panel = self.diagram_usages.as_mut().unwrap(); + panel.loading = false; + panel.notice = Some( + "The model or pending edits changed. Reopen diagram navigation when ready.".into(), + ); + return Task::none(); + } + let project = self.project().clone(); + let presentation = self.behavior.topology.usage_snapshot(); + self.diagram_lookup_in_flight = Some(token); + Task::perform( + async move { + let result = tokio::task::spawn_blocking(move || { + let process = presentation.into_query_state(); + let scenario = behavior::scenario::State::default(); + usage::collect(&project, context.subject, &process, &scenario) + }) + .await + .map_err(|error| format!("Diagram lookup failed: {error}. Close and try again.")); + usage::Completion { + token, + context, + result, + } + }, + Message::DiagramUsagesReady, + ) + } + + fn refresh_diagram_lookup(&mut self, notice: &str) -> Task { + let Some(panel) = self.diagram_usages.as_mut() else { + return Task::none(); + }; + panel.token = Id::new_v4(); + panel.context.revision = self.document.revision(); + panel.usages.clear(); + panel.loading = true; + panel.page = 0; + panel.cursor = 0; + panel.destination = None; + panel.notice = Some(notice.into()); + self.start_diagram_lookup() + } + + /// Must run before modal/input dispatch. A closed or replaced panel still + /// owns its worker slot until this exact completion arrives. + pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task { + if self.diagram_lookup_in_flight != Some(completion.token) { + return Task::none(); + } + self.diagram_lookup_in_flight = None; + let Some(panel) = self.diagram_usages.as_ref() else { + return Task::none(); + }; + if panel.context.document != self.canvas_document { + self.diagram_usages = None; + return Task::none(); + } + if panel.context.revision != self.document.revision() { + return self + .refresh_diagram_lookup("The model changed. Review the refreshed destinations."); + } + if panel.token != completion.token { + // A newer Open/refresh waited without cloning or spawning a second job. + return self.start_diagram_lookup(); + } + let panel = self.diagram_usages.as_mut().unwrap(); + if panel.context != completion.context || !panel.loading { + panel.loading = false; + panel.notice = Some( + "This lookup no longer matches the requested model. Close and try again.".into(), + ); + return Task::none(); + } + panel.loading = false; + match completion.result { + Ok(usages) => panel.usages = usages, + Err(error) => panel.notice = Some(error), + } + Task::none() + } + + /// Selection validates one destination only; opening the chooser does the + /// whole-project scan on its worker, never on the GUI thread. + fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec { + match surface { + Surface::Saved(diagram) => { + usage::saved_usages_for(self.project(), subject, Some(diagram)) + } + Surface::Process(owner) => { + self.behavior + .topology + .diagram_usages(self.project(), owner, subject) + } + Surface::Scenario(owner) => { + self.behavior + .scenario + .diagram_usages(self.project(), owner, subject) + } + } + } + + pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task { + if let Event::Open(context) = event { + if context.document != self.canvas_document + || context.revision != self.document.revision() + || !self.project().contains(context.subject) + { + return Task::none(); + } + if !self.usage_navigation_available() { + self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into(); + return Task::none(); + } + let mut panel = Panel::new(context, Vec::new()); + panel.loading = true; + let filter_id = format!("diagram-usage-filter-{}", panel.token); + self.diagram_usages = Some(panel); + self.model_menu = None; + // Only an accepted new chooser supersedes the previous reveal. + // Rejected/stale panel envelopes leave its first measurement intact. + self.pending_diagram_reveal = None; + self.diagram_navigation_focus = None; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + return Task::batch([ + self.start_diagram_lookup(), + iced::widget::operation::focus(filter_id), + ]); + } + let token = match &event { + Event::Open(_) => unreachable!(), + Event::Close(t) + | Event::Search(t, _) + | Event::Page(t, _) + | Event::Move(t, _) + | Event::Activate(t) + | Event::Destination(t, _) + | Event::Choose(t, _) => *t, + }; + let Some(panel) = &self.diagram_usages else { + return Task::none(); + }; + if panel.token != token { + return Task::none(); + } + if matches!(event, Event::Close(_)) { + self.diagram_usages = None; + if self.page == Page::Text { + self.restore_source_editor_focus(); + } + return Task::none(); + } + if panel.context.document != self.canvas_document { + self.diagram_usages = None; + return Task::none(); + } + if panel.context.revision != self.document.revision() { + return self + .refresh_diagram_lookup("The model changed. Review the refreshed destinations."); + } + if panel.loading { + // Search and Close remain useful while loading; queued activation + // must not perform a synchronous fallback or choose an old row. + if let Event::Search(_, query) = event { + self.diagram_usages.as_mut().unwrap().search = query; + } + return Task::none(); + } + match event { + Event::Move(_, direction) => { + let panel = self.diagram_usages.as_ref().unwrap(); + let count = panel.choices(self.project()).len(); + let panel = self.diagram_usages.as_mut().unwrap(); + if count > 0 { + panel.cursor = if direction < 0 { + (panel.cursor + count - 1) % count + } else { + (panel.cursor + 1) % count + }; + panel.page = panel.cursor / 6; + } + } + Event::Activate(_) => { + let panel = self.diagram_usages.as_ref().unwrap(); + if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() { + return self.diagram_usage_event(event); + } + } + Event::Search(_, query) => { + let panel = self.diagram_usages.as_mut().unwrap(); + panel.search = query; + panel.page = 0; + panel.cursor = 0; + } + Event::Page(_, page) => { + let panel = self.diagram_usages.as_mut().unwrap(); + panel.page = page; + panel.cursor = page.saturating_mul(6); + } + Event::Destination(_, surface) => { + let panel = self.diagram_usages.as_mut().unwrap(); + if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) { + panel.destination = surface; + panel.page = 0; + panel.cursor = 0; + } + } + Event::Choose(_, requested) => { + if !self.usage_navigation_available() { + return Task::none(); + } + let subject = self.diagram_usages.as_ref().unwrap().context.subject; + let fresh = self + .current_surface_usages(subject, requested.surface) + .into_iter() + .find(|u| u.subject == requested.subject && u.target == requested.target); + let Some(fresh) = fresh else { + return self.refresh_diagram_lookup( + "This representation changed. Review the refreshed destinations.", + ); + }; + if self.reveal_diagram_usage(&fresh) { + self.diagram_usages = None; + self.status = format!( + "{} · {} in {}", + self.project().named(subject), + fresh.role, + self.project().named(fresh.surface.id()) + ); + return Task::batch(["model-explorer", "model-browser", "inspector"].map( + |id| { + iced::widget::operation::snap_to( + id, + iced::widget::scrollable::RelativeOffset::START, + ) + }, + )); + } + } + Event::Open(_) | Event::Close(_) => unreachable!(), + } + Task::none() + } + + pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool { + if !self.usage_navigation_available() { + return false; + } + let previous_layer = self.layer; + let p = self.document.project(); + let (selected, placement) = match &usage.target { + Target::Whole => (usage.surface.id(), None), + Target::Placement(id) => { + let Some(node) = p + .diagrams + .get(&usage.surface.id()) + .and_then(|d| d.nodes.get(id)) + else { + return false; + }; + (node.element, Some(*id)) + } + Target::Port { port, placement } => (*port, Some(*placement)), + Target::StateRow { + placement, + relation, + } => (*relation, Some(*placement)), + Target::Edge(id) + | Target::ProcessLink(id) + | Target::ScenarioObject(id) + | Target::AllocationGrouping { relation: id, .. } + | Target::ProcessPreviewLink { relation: id, .. } => (*id, None), + Target::ProcessOccurrence(endpoint) => ( + endpoint + .references + .first() + .copied() + .unwrap_or(endpoint.occurrence), + None, + ), + }; + match usage.surface { + Surface::Saved(id) => { + let Some(diagram) = p.diagrams.get(&id) else { + return false; + }; + self.layer = diagram.layer; + self.active_diagram = Some(id); + if let Some(machine) = diagram.projection.state_machine() { + let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new( + (self.window_size.width - 610.).max(280.), + (self.window_size.height - 320.).max(240.), + )); + let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport); + self.behavior.diagram = behavior::state_view::State { + diagram: Some(id), + placement, + viewport: Some(viewport), + zoom, + pan, + fit: false, + ..Default::default() + }; + self.behavior.active_owner = Some(machine); + self.page = Page::Behavior; + } else { + let viewport = self.canvas_size.unwrap_or(iced::Size::new( + (self.window_size.width - 550.).max(280.), + (self.window_size.height - 284.).max(240.), + )); + (self.zoom, self.pan) = + usage::camera_for(&usage.target, usage.bounds, viewport); + self.fit_to_view = false; + self.page = Page::Diagram; + } + } + Surface::Process(owner) => { + if !self.behavior.topology.reveal_usage( + p, + self.canvas_document, + owner, + &usage.target, + ) { + return false; + } + self.layer = p.elements[&owner].layer(); + self.behavior.active_owner = Some(owner); + self.page = Page::Behavior; + } + Surface::Scenario(owner) => { + if !self.behavior.scenario.reveal_usage( + p, + self.canvas_document, + owner, + &usage.target, + ) { + return false; + } + self.layer = p.elements[&owner].layer(); + self.behavior.active_owner = Some(owner); + self.page = Page::Behavior; + } + } + self.selected = selected; + self.selected_node = placement; + self.model_menu = None; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.selected_bend = None; + self.connection = None; + self.last_workspace_page = self.page; + if previous_layer != self.layer { + self.data_state = Default::default(); + self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); + self.explorer_focus = None; + self.explorer_group = None; + self.explorer_page = 0; + } + self.normalize_creation(); + self.reveal_explorer_selection(); + self.refresh_draft(); + self.diagram_navigation_focus = Some(usage::Focus { + context: Context { + document: self.canvas_document, + revision: self.document.revision(), + subject: usage.subject, + }, + usage: usage.clone(), + selected, + }); + self.pending_diagram_reveal = Some(( + Context { + document: self.canvas_document, + revision: self.document.revision(), + subject: usage.subject, + }, + usage.clone(), + self.canvas_epoch, + )); + true + } + + /// Validated presentation identity. Edit selection and Delete capture remain + /// semantic; a port owner or allocation work box is only a visual marker. + pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> { + let focus = self.diagram_navigation_focus.as_ref()?; + let current_surface = match surface { + Surface::Saved(id) => { + self.active_diagram == Some(id) + && matches!(self.page, Page::Diagram | Page::Behavior) + } + Surface::Process(owner) | Surface::Scenario(owner) => { + self.page == Page::Behavior && self.behavior.active_owner == Some(owner) + } + }; + (current_surface + && focus.usage.surface == surface + && focus.context.document == self.canvas_document + && focus.context.revision == self.document.revision() + && focus.selected == self.selected) + .then_some(&focus.usage) + } +} diff --git a/crates/oa-desktop/src/app/diagram_usages/tests.rs b/crates/oa-desktop/src/app/diagram_usages/tests.rs new file mode 100644 index 00000000..5f107d7b --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_usages/tests.rs @@ -0,0 +1,1403 @@ +use super::*; +use crate::source_navigation::{self as nav, query}; +use oa_core::source_project::SourceBundle; + +fn send(app: &mut App, message: Message) { + // Do not poll returned workers: completion below uses an independent Snapshot. + let _ = app.update(message); +} + +fn lookup_completion(app: &App) -> usage::Completion { + let panel = app.diagram_usages.as_ref().expect("lookup panel"); + assert!(panel.loading); + assert_eq!(app.diagram_lookup_in_flight, Some(panel.token)); + let project = app.project().clone(); + let process = app.behavior.topology.usage_snapshot().into_query_state(); + let scenario = behavior::scenario::State::default(); + usage::Completion { + token: panel.token, + context: panel.context, + result: Ok(usage::collect( + &project, + panel.context.subject, + &process, + &scenario, + )), + } +} + +fn finish_lookup(app: &mut App) { + let completion = lookup_completion(app); + send(app, Message::DiagramUsagesReady(completion)); + assert!(app.diagram_lookup_in_flight.is_none()); + assert!(!app.diagram_usages.as_ref().unwrap().loading); +} + +#[derive(Debug, PartialEq)] +struct AuthoredState { + project: Project, + accepted: SourceBundle, + drafts: SourceBundle, + revision: u64, + document_dirty: bool, + source_dirty: bool, + dirty: bool, + undo: bool, + redo: bool, +} + +fn authored(app: &App) -> AuthoredState { + AuthoredState { + project: app.project().clone(), + accepted: app.document.bundle().clone(), + drafts: app.source_workspace.bundle(), + revision: app.document.revision(), + document_dirty: app.document.dirty(), + source_dirty: app.source_dirty(), + dirty: app.dirty(), + undo: app.document.can_undo(), + redo: app.document.can_redo(), + } +} + +fn location(app: &App) -> (Page, Layer, Id, Option, Option, f32, iced::Vector) { + ( + app.page, + app.layer, + app.selected, + app.selected_node, + app.active_diagram, + app.zoom, + app.pan, + ) +} + +fn saved_fixture() -> (App, [Id; 3], Id, Id) { + let mut p = Project::empty(); + let first = p.add(Kind::Activity, "Repeated display name", 100_000., -50_000.); + let second = p.add(Kind::Activity, "Repeated display name", 100_420., -50_000.); + let unplaced = p + .add_element(Kind::Activity, Layer::Oa, "Repeated display name", None) + .unwrap(); + let relation = p.relate(RelationKind::Interaction, "Precise handoff", first, second); + let diagram = p.ensure_diagram(Layer::Oa); + let mut app = App::with_project(p); + app.canvas_size = Some(iced::Size::new(800., 500.)); + app.pan = iced::Vector::new(77., 33.); + app.zoom = 0.8; + (app, [first, second, unplaced], relation, diagram) +} + +fn open(app: &mut App, subject: Id) -> (Id, Vec) { + let message = app.diagram_usage_message(subject); + send(app, message); + assert!(app.diagram_usages.as_ref().unwrap().loading); + assert!(app.diagram_usages.as_ref().unwrap().usages.is_empty()); + finish_lookup(app); + let panel = app + .diagram_usages + .as_ref() + .expect("navigation panel opened"); + assert_eq!(panel.context.subject, subject); + (panel.token, panel.usages.clone()) +} + +fn choose(app: &mut App, token: Id, usage: Usage) { + send(app, Message::DiagramUsage(Event::Choose(token, usage))); +} + +fn assert_centered(app: &App, bounds: iced::Rectangle) { + let viewport = app.canvas_size.unwrap(); + assert!((bounds.center_x() * app.zoom + app.pan.x - viewport.width / 2.).abs() < 0.05); + assert!((bounds.center_y() * app.zoom + app.pan.y - viewport.height / 2.).abs() < 0.05); + assert!((0.5..=1.25).contains(&app.zoom)); + assert!(!app.fit_to_view); +} + +#[test] +fn saved_node_and_edge_navigation_preserve_whole_sources_and_actual_history() { + for edge in [false, true] { + let (mut app, elements, relation, diagram) = saved_fixture(); + let original = app.document.snapshot(); + let project = app.project().id; + app.document + .execute(Command::Rename(project, "First accepted edit".into())) + .unwrap(); + let first = app.document.snapshot(); + app.document + .execute(Command::Rename(project, "Redo remains available".into())) + .unwrap(); + let second = app.document.snapshot(); + assert!(app.document.undo()); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + assert!(before.undo && before.redo); + let subject = if edge { relation } else { elements[0] }; + let (token, rows) = open(&mut app, subject); + assert_eq!(authored(&app), before); + let row = rows + .into_iter() + .find(|row| row.surface == Surface::Saved(diagram)) + .unwrap(); + let placement = match row.target { + Target::Placement(node) if !edge => Some(node), + Target::Edge(id) if edge && id == relation => None, + _ => panic!("wrong represented target"), + }; + let bounds = row.bounds; + choose(&mut app, token, row); + assert!(app.diagram_usages.is_none()); + assert_eq!(app.page, Page::Diagram); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!(app.selected, subject); + assert_eq!(app.selected_node, placement); + assert_centered(&app, bounds); + assert_eq!(authored(&app), before); + assert!(Arc::ptr_eq(&first, &app.document.snapshot())); + assert!(app.document.undo()); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.document.bundle(), &original.bundle); + assert!(app.document.redo()); + assert_eq!(app.document.bundle(), &first.bundle); + assert!(app.document.redo()); + assert_eq!(app.project(), &second.decoded.project); + assert_eq!(app.document.bundle(), &second.bundle); + } +} + +#[test] +fn port_navigation_selects_port_and_exact_visible_owner_without_editing_geometry() { + let mut p = Project::empty(); + let owner = p.add(Kind::Function, "Compute", 1200., 1500.); + let port = Id::new_v4(); + let element = oa_core::ports::make_port( + &p, + port, + owner, + "Input", + oa_core::PortContract::FunctionInput { + incoming_items: vec![], + }, + ) + .unwrap(); + p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap(); + let mut app = App::with_project(p); + app.canvas_size = Some(iced::Size::new(750., 480.)); + let before = authored(&app); + let (token, rows) = open(&mut app, port); + assert_eq!(rows.len(), 1); + let row = rows[0].clone(); + let Target::Port { + port: found, + placement, + } = row.target + else { + panic!("port representation"); + }; + assert_eq!(found, port); + assert_eq!( + app.project().diagrams[&row.surface.id()].nodes[&placement].element, + owner + ); + let bounds = row.bounds; + choose(&mut app, token, row); + assert_eq!(app.selected, port); + assert_eq!( + app.selected_node, None, + "visual owner is not an edit/Delete selection" + ); + let focus = app + .diagram_focus(Surface::Saved(app.active_diagram.unwrap())) + .unwrap(); + assert_eq!(focus.target, Target::Port { port, placement }); + assert_eq!(focus.bounds, bounds); + assert_eq!(app.layer, Layer::Sa); + assert_centered(&app, bounds); + assert_eq!(authored(&app), before); +} + +#[test] +fn allocation_navigation_keeps_relation_selection_and_exact_visual_work_lifetime() { + let mut p = Project::empty(); + let performer = p.add(Kind::Entity, "Responsible team", 1200., 1500.); + let work = p.add(Kind::Activity, "Assigned activity", 1500., 1650.); + let relation = p.relate(RelationKind::Allocation, "Responsibility", performer, work); + let diagram = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&diagram).unwrap().projection = oa_core::ProjectionKind::Allocation; + let work_placement = p.diagrams[&diagram] + .nodes + .values() + .find(|n| n.element == work) + .unwrap() + .id; + let performer_placement = p.diagrams[&diagram] + .nodes + .values() + .find(|n| n.element == performer) + .unwrap() + .id; + let mut app = App::with_project(p); + app.canvas_size = Some(iced::Size::new(800., 500.)); + let before = authored(&app); + let (old_token, rows) = open(&mut app, relation); + assert_eq!(rows.len(), 1); + let row = rows[0].clone(); + let expected = Target::AllocationGrouping { + relation, + work_placement, + performer_placement, + }; + assert_eq!(row.target, expected); + choose(&mut app, old_token, row.clone()); + assert_eq!( + app.selected, relation, + "later semantic actions must still target the Allocation" + ); + assert_eq!( + app.selected_node, None, + "focus must not arm work-placement editing" + ); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().target, + expected + ); + assert_centered(&app, row.bounds); + app.refresh_draft(); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().bounds, + row.bounds + ); + let old_epoch = app.canvas_epoch; + let (token, _) = open(&mut app, relation); + choose(&mut app, token, row.clone()); + assert_ne!( + app.canvas_epoch, old_epoch, + "equal-valued navigation retires gesture context" + ); + let epoch = app.canvas_epoch; + choose(&mut app, old_token, row.clone()); + assert_eq!(app.canvas_epoch, epoch); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().target, + expected + ); + for event in [ + Event::Close(old_token), + Event::Search(old_token, "obsolete filter".into()), + ] { + send(&mut app, Message::DiagramUsage(event)); + assert!( + app.pending_diagram_reveal.is_some(), + "stale panel events cannot cancel measured reveal" + ); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().target, + expected + ); + } + let (context, _, stamp) = app.pending_diagram_reveal.as_ref().unwrap(); + let measured = Message::DiagramRevealViewport(*context, *stamp, iced::Size::new(900., 560.)); + send(&mut app, measured); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().target, + expected + ); + assert_centered(&app, row.bounds); + assert_eq!(authored(&app), before); + for camera in [ + Message::Pan(iced::Vector::new(40., -20.)), + Message::Zoom(1.1), + Message::Fit, + ] { + send(&mut app, camera); + assert_eq!(app.selected, relation); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().target, + expected + ); + assert_eq!(authored(&app), before); + } + send(&mut app, Message::Select(work)); + assert!(app.diagram_focus(Surface::Saved(diagram)).is_none()); + let (token, _) = open(&mut app, relation); + choose(&mut app, token, row); + app.document + .execute(Command::Rename(work, "Accepted revision change".into())) + .unwrap(); + assert!(app.diagram_focus(Surface::Saved(diagram)).is_none()); +} + +#[test] +fn state_row_navigation_has_exact_visual_row_without_retargeting_the_selected_relation() { + let mut p = Project::empty(); + let machine = p + .add_element(Kind::StateMachine, Layer::Sa, "Modes", None) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Sa, "Main", Some(machine)) + .unwrap(); + let state = p + .add_element(Kind::State, Layer::Sa, "Running", Some(region)) + .unwrap(); + let work = p + .add_element(Kind::Function, Layer::Sa, "Start monitoring", None) + .unwrap(); + let relation = p.relate(RelationKind::StateAction, "Entry", state, work); + let diagram = p.ensure_diagram(Layer::Sa); + p.diagrams.get_mut(&diagram).unwrap().projection = + oa_core::ProjectionKind::StateMachine { machine }; + let placement = p.place(diagram, state, Position { x: 1800., y: 2400. }); + let mut app = App::with_project(p); + let before = authored(&app); + let (token, rows) = open(&mut app, relation); + let row = rows + .into_iter() + .find(|row| { + row.target + == (Target::StateRow { + placement, + relation, + }) + }) + .unwrap(); + choose(&mut app, token, row.clone()); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.behavior.active_owner, Some(machine)); + assert_eq!(app.selected, relation); + assert_eq!(app.selected_node, None); + let focus = app.diagram_focus(Surface::Saved(diagram)).unwrap(); + assert_eq!( + focus.target, + Target::StateRow { + placement, + relation + } + ); + assert_eq!(focus.bounds, row.bounds); + app.refresh_draft(); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().bounds, + row.bounds + ); + assert_eq!(authored(&app), before); + let context = app.state_context().unwrap(); + send( + &mut app, + Message::StateDiagram(behavior::state_edit::Event::Pan( + context, + iced::Vector::new(35., 20.), + )), + ); + assert_eq!(app.selected, relation); + assert_eq!( + app.diagram_focus(Surface::Saved(diagram)).unwrap().target, + row.target + ); + assert_eq!(authored(&app), before); + app.canvas_document = Id::new_v4(); + assert!(app.diagram_focus(Surface::Saved(diagram)).is_none()); +} + +#[test] +fn accepted_new_chooser_retires_old_measurement_without_blocking_future_viewports() { + let (mut app, elements, _, diagram) = saved_fixture(); + let (token, rows) = open(&mut app, elements[0]); + choose(&mut app, token, rows[0].clone()); + assert!(app.pending_diagram_reveal.is_some()); + let (new_token, _) = open(&mut app, elements[1]); + assert!(app.pending_diagram_reveal.is_none()); + assert!(app.diagram_focus(Surface::Saved(diagram)).is_none()); + send(&mut app, Message::DiagramUsage(Event::Close(new_token))); + let size = iced::Size::new(945., 615.); + send(&mut app, Message::CanvasResized(size)); + assert_eq!(app.canvas_size, Some(size)); +} + +#[test] +fn tree_origin_captures_subject_and_never_falls_back_to_current_selection_or_name() { + let (mut app, elements, _, _) = saved_fixture(); + let captured = app.diagram_usage_message(elements[0]); + send(&mut app, Message::Select(elements[1])); + let before = authored(&app); + send(&mut app, captured); + finish_lookup(&mut app); + let panel = app.diagram_usages.as_ref().unwrap(); + assert_eq!(panel.context.subject, elements[0]); + assert!(panel.usages.iter().all(|row| row.subject == elements[0])); + assert_eq!(app.selected, elements[1]); + let token = panel.token; + send(&mut app, Message::DiagramUsage(Event::Close(token))); + let (_, rows) = open(&mut app, elements[2]); + assert!(rows.is_empty(), "an unplaced homonym has no representation"); + assert_eq!(authored(&app), before); +} + +#[test] +fn old_panel_tokens_and_forged_targets_cannot_redirect_selection() { + let (mut app, elements, _, _) = saved_fixture(); + let (old_token, old_rows) = open(&mut app, elements[0]); + let (token, rows) = open(&mut app, elements[1]); + assert_ne!(token, old_token); + let before = authored(&app); + let prior = location(&app); + choose(&mut app, old_token, old_rows[0].clone()); + assert_eq!(app.diagram_usages.as_ref().unwrap().token, token); + assert_eq!(location(&app), prior); + // Even a current panel token cannot authorize another subject's valid row. + choose(&mut app, token, old_rows[0].clone()); + assert_eq!(location(&app), prior); + let refreshed = app.diagram_usages.as_ref().unwrap().token; + assert_ne!(refreshed, token); + let mut forged = rows[0].clone(); + forged.target = old_rows[0].target.clone(); + choose(&mut app, refreshed, forged); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); +} + +#[test] +fn revision_retarget_and_document_change_make_old_destinations_inert() { + let (mut app, elements, _, diagram) = saved_fixture(); + let (token, rows) = open(&mut app, elements[0]); + let old_row = rows[0].clone(); + let Target::Placement(node) = old_row.target else { + panic!("node target"); + }; + let mut replacement = app.project().diagrams[&diagram].nodes[&node].clone(); + replacement.element = elements[2]; + app.document + .execute(Command::EditNode { + diagram, + original: node, + node: replacement, + }) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + let prior = location(&app); + choose(&mut app, token, old_row.clone()); + let panel = app.diagram_usages.as_ref().unwrap(); + assert_ne!(panel.token, token); + assert_eq!(panel.context.revision, app.document.revision()); + assert!(panel.usages.is_empty()); + assert!(panel.notice.is_some()); + let fresh_token = panel.token; + assert!( + panel.loading, + "revision refresh starts a worker, never activates the old event" + ); + finish_lookup(&mut app); + choose(&mut app, fresh_token, old_row); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); + let token = app.diagram_usages.as_ref().unwrap().token; + app.canvas_document = Id::new_v4(); + send( + &mut app, + Message::DiagramUsage(Event::Search(token, "stale".into())), + ); + assert!(app.diagram_usages.is_none()); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); +} + +#[test] +fn stale_open_context_cannot_replace_a_panel() { + let (mut app, elements, _, _) = saved_fixture(); + let (token, _) = open(&mut app, elements[0]); + let context = app.diagram_usages.as_ref().unwrap().context; + for stale in [ + Context { + document: Id::new_v4(), + ..context + }, + Context { + revision: context.revision.wrapping_add(1), + ..context + }, + Context { + subject: Id::new_v4(), + ..context + }, + ] { + send(&mut app, Message::DiagramUsage(Event::Open(stale))); + assert_eq!(app.diagram_usages.as_ref().unwrap().token, token); + } +} + +#[test] +fn pending_source_behavior_and_modal_inputs_block_open_and_choose_without_loss() { + for opened in [false, true] { + for block in 0..8 { + let (mut app, elements, _, _) = saved_fixture(); + let choice = opened.then(|| { + let (token, rows) = open(&mut app, elements[0]); + (token, rows[0].clone()) + }); + match block { + 0 => app.busy = true, + 1 => app.hosted.visible = true, + 2 => app.pending = Some(Pending::Walkthrough), + 3 => { + app.file_prompt = Some(FilePrompt { + operation: FileOperation::Save, + path: "keep-this-path.arcadia".into(), + overwrite: false, + }) + } + 4 => { + let cursor = app.source_editor.cursor(); + app.source_workspace + .edit("? retain this invalid source draft".into(), cursor); + } + 5 => { + let project = app.project().clone(); + assert!( + app.behavior + .creation + .update(&project, behavior::creation::Event::Open(Layer::Oa)) + .unwrap() + .is_none() + ); + } + 6 => app.delete_pending = true, + 7 => { + app.draft + .as_mut() + .unwrap() + .input("/name", "Unapplied inspector name".into()); + assert!(app.draft_dirty()); + } + _ => unreachable!(), + } + let before = authored(&app); + let draft_before = app + .draft + .as_ref() + .map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap()); + let prior = location(&app); + if let Some((token, row)) = choice { + choose(&mut app, token, row); + } else { + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + } + assert_eq!(authored(&app), before, "opened={opened}, block={block}"); + assert_eq!(location(&app), prior); + assert_eq!( + app.draft + .as_ref() + .map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap()), + draft_before + ); + assert_eq!(app.diagram_usages.is_some(), opened); + if block == 3 { + assert_eq!( + app.file_prompt.as_ref().unwrap().path, + "keep-this-path.arcadia" + ); + } + if block == 5 { + assert!(app.behavior.has_pending()); + } + } + } +} + +fn caret_on_alias(app: &mut App, subject: Id) { + let alias = usage::alias(app.project(), subject).to_owned(); + let (path, span) = app + .source_workspace + .files + .iter() + .find_map(|(path, buffer)| { + let outline = language::outline( + &buffer.text, + language::OutlineLimits { + max_source_bytes: usize::MAX, + max_tokens: usize::MAX, + max_declarations: usize::MAX, + }, + ) + .unwrap(); + outline + .declarations + .iter() + .find(|declaration| { + declaration.declaration.id == Some(subject) + && declaration.declaration.alias == alias + }) + .map(|declaration| (path.clone(), declaration.alias_span)) + }) + .unwrap(); + app.page = Page::Text; + send(app, Message::SourceFile(path)); + if let Some(ticket) = app.source_reveal_ticket() { + send(app, Message::SourceViewport(Some(ticket))); + } + let text = app.source_workspace.text(); + let prefix = &text[..span.start]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count(); + let column = prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()); + app.source_editor.move_to(text_editor::Cursor { + position: text_editor::Position { line, column }, + selection: None, + }); +} + +fn begin_caret(app: &mut App) -> Option { + let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages); + send(app, message); + app.source_symbols.request.clone() +} + +fn complete_caret(app: &App, request: nav::Request) -> nav::Completion { + let drafts = app.source_workspace.bundle(); + let accepted = app.document.bundle(); + let files: Vec<_> = drafts + .files + .iter() + .map(|(path, text)| query::DraftFile { path, text }) + .collect(); + let validated: Vec<_> = accepted + .files + .iter() + .map(|(path, text)| query::DraftFile { path, text }) + .collect(); + let snapshot = Arc::new( + query::capture( + query::Input { + key: request.stamp.snapshot, + entry: &drafts.entry, + files: &files, + applied: app.source_workspace.applied, + validation: Some(query::ValidatedSources { + entry: &accepted.entry, + files: &validated, + }), + }, + query::Limits::default(), + ) + .unwrap(), + ); + let results = Arc::new( + snapshot + .query(query::Query { + stamp: request.stamp, + path: &request.path, + byte_offset: request.offset, + action: request.action, + }) + .unwrap(), + ); + nav::Completion { + request, + result: Ok((snapshot, results)), + } +} + +#[test] +fn caret_uses_exact_verified_alias_identity_and_preserves_source_cursor() { + let (mut app, elements, _, _) = saved_fixture(); + send(&mut app, Message::Select(elements[1])); + caret_on_alias(&mut app, elements[0]); + let before = authored(&app); + let cursor = app.source_editor.cursor(); + let source = app.source_workspace.active.clone(); + let request = begin_caret(&mut app).unwrap(); + let ready = complete_caret(&app, request); + let (_, results) = ready.result.as_ref().unwrap(); + assert_eq!(results.disposition, query::Disposition::Direct); + assert!(results.scope.validated); + assert_eq!(results.definitions.len(), 1); + assert_eq!( + results.definitions[0].declaration.authored_id, + Some(elements[0]) + ); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert_eq!( + app.diagram_usages.as_ref().unwrap().context.subject, + elements[0] + ); + assert_eq!( + app.selected, elements[1], + "opening must not first jump to another source/model selection" + ); + assert_eq!(app.source_workspace.active, source); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(authored(&app), before); +} + +#[test] +fn dirty_or_comment_caret_has_no_selected_object_fallback() { + for dirty in [false, true] { + let (mut app, elements, _, _) = saved_fixture(); + let mut bundle = app.document.bundle().clone(); + let entry = bundle.entry.clone(); + bundle + .files + .get_mut(&entry) + .unwrap() + .insert_str(0, "// @not_a_live_alias\n"); + app.document = Document::from_bundle(bundle, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.page = Page::Text; + app.selected = elements[0]; + app.refresh_draft(); + app.show_source_file(false); + app.source_editor.move_to(text_editor::Cursor { + position: text_editor::Position { line: 0, column: 6 }, + selection: None, + }); + if dirty { + let cursor = app.source_editor.cursor(); + app.source_workspace + .edit("? invalid draft @not_a_live_alias".into(), cursor); + } + let before = authored(&app); + if let Some(request) = begin_caret(&mut app) { + assert!(!dirty, "dirty source must not start model lookup"); + let ready = complete_caret(&app, request); + assert_eq!( + ready.result.as_ref().unwrap().1.disposition, + query::Disposition::NoSymbol + ); + send(&mut app, Message::SourceSymbolsReady(ready)); + } else { + assert!(dirty); + } + assert!(app.diagram_usages.is_none()); + assert_eq!(app.selected, elements[0]); + assert!(app.source_symbols.notice.is_some()); + assert_eq!(authored(&app), before); + } +} + +#[test] +fn stale_caret_completion_never_opens_diagrams_for_current_selection() { + for stale in 0..4 { + let (mut app, elements, _, _) = saved_fixture(); + caret_on_alias(&mut app, elements[0]); + let request = begin_caret(&mut app).unwrap(); + let ready = complete_caret(&app, request); + match stale { + 0 => send( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), + ), + 1 => app.source_workspace.reset_navigation_session(), + 2 => { + app.source_symbols + .raw_interaction + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + 3 => app.source_editor.move_to(text_editor::Cursor { + position: text_editor::Position { line: 0, column: 0 }, + selection: None, + }), + _ => unreachable!(), + } + let before = authored(&app); + let prior = location(&app); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.diagram_usages.is_none()); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); + } +} + +fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let mut element = p + .make_element( + Kind::Occurrence, + p.elements[&owner].layer(), + name, + Some(owner), + ) + .unwrap(); + let id = element.id; + let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else { + panic!("occurrence"); + }; + *value = body; + *p = execute_batch(p, &[Command::CreateElement(element)]).unwrap(); + id +} + +#[test] +fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() { + let fixture = behavior::topology::direct_tests::fixture(Layer::Oa); + let mut project = fixture.p; + let reused = project + .make_element(Kind::Process, Layer::Oa, "Repeated reusable check", None) + .unwrap(); + let reused_id = reused.id; + project = execute_batch(&project, &[Command::CreateElement(reused)]).unwrap(); + let leaf = add_occurrence( + &mut project, + reused_id, + "Check", + OccurrenceBody::Action { + action: Some(fixture.work[0]), + lifeline: None, + }, + ); + let first = add_occurrence( + &mut project, + fixture.owner, + "First call", + OccurrenceBody::SequenceReference { + sequence: Some(reused_id), + }, + ); + let second = add_occurrence( + &mut project, + fixture.owner, + "Second call", + OccurrenceBody::SequenceReference { + sequence: Some(reused_id), + }, + ); + let mut app = App::with_project(project); + app.behavior.topology.viewport(iced::Size::new(850., 460.)); + let before = authored(&app); + let (token, rows) = open(&mut app, leaf); + let row = rows + .iter() + .find(|row| { + row.surface == Surface::Process(fixture.owner) + && row.target + == Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![first], + occurrence: leaf, + }) + }) + .unwrap() + .clone(); + choose(&mut app, token, row); + assert_eq!(app.selected, first); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.behavior.active_owner, Some(fixture.owner)); + let first_camera = app.behavior.topology.camera_boundary(); + let (token, rows) = open(&mut app, leaf); + let row = rows + .into_iter() + .find(|row| { + row.surface == Surface::Process(fixture.owner) + && row.target + == Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![second], + occurrence: leaf, + }) + }) + .unwrap(); + choose(&mut app, token, row); + assert_eq!(app.selected, second); + assert_eq!(app.selected_node, None); + assert!(app.behavior.topology.camera_boundary() != first_camera); + assert_eq!(authored(&app), before); +} + +#[test] +fn repeated_scenario_messages_select_the_exact_occurrence_and_move_camera() { + let fixture = behavior::scenario::direct_tests::fixture(); + let mut app = App::with_project(fixture.project); + app.behavior.scenario.viewport(iced::Size::new(850., 460.)); + let before = authored(&app); + let (token, rows) = open(&mut app, fixture.exchange); + let call = rows + .iter() + .find(|row| { + row.surface == Surface::Scenario(fixture.owner) + && row.target == Target::ScenarioObject(fixture.call) + }) + .unwrap() + .clone(); + let reply = rows + .iter() + .find(|row| { + row.surface == Surface::Scenario(fixture.owner) + && row.target == Target::ScenarioObject(fixture.reply) + }) + .unwrap() + .clone(); + assert_ne!(call.bounds, reply.bounds); + choose(&mut app, token, call); + assert_eq!(app.selected, fixture.call); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.behavior.active_owner, Some(fixture.owner)); + let first_camera = app.behavior.scenario.camera_boundary(); + let (token, _) = open(&mut app, fixture.exchange); + choose(&mut app, token, reply); + assert_eq!(app.selected, fixture.reply); + assert_eq!(app.selected_node, None); + assert!(app.behavior.scenario.camera_boundary() != first_camera); + assert_eq!(authored(&app), before); +} + +#[test] +fn repeated_preview_arrows_select_the_relation_and_highlight_the_exact_caller_link() { + let fixture = behavior::topology::direct_tests::fixture(Layer::Oa); + let mut p = fixture.p; + let reused = p + .make_element(Kind::Process, Layer::Oa, "Reusable sequence", None) + .unwrap(); + let sequence = reused.id; + p = execute_batch(&p, &[Command::CreateElement(reused)]).unwrap(); + let leaves: Vec<_> = ["First check", "Next check"] + .into_iter() + .map(|name| { + add_occurrence( + &mut p, + sequence, + name, + OccurrenceBody::Action { + action: Some(fixture.work[0]), + lifeline: None, + }, + ) + }) + .collect(); + let calls: Vec<_> = ["First invocation", "Second invocation"] + .into_iter() + .map(|name| { + add_occurrence( + &mut p, + fixture.owner, + name, + OccurrenceBody::SequenceReference { + sequence: Some(sequence), + }, + ) + }) + .collect(); + let relation = p.relate( + RelationKind::SequenceLink, + "Next check", + leaves[0], + leaves[1], + ); + let mut app = App::with_project(p); + app.behavior.topology.viewport(iced::Size::new(850., 460.)); + let before = authored(&app); + let mut previous_camera = None; + let mut previous_epoch = None; + for call in [calls[0], calls[1], calls[1]] { + let (token, rows) = open(&mut app, relation); + assert_eq!( + rows.iter() + .filter(|row| row.surface == Surface::Process(fixture.owner)) + .count(), + 2 + ); + assert!( + rows.iter() + .any(|row| row.surface == Surface::Process(sequence) + && row.target == Target::ProcessLink(relation)) + ); + let target = Target::ProcessPreviewLink { + relation, + source: OccurrenceEndpoint { + references: vec![call], + occurrence: leaves[0], + }, + target: OccurrenceEndpoint { + references: vec![call], + occurrence: leaves[1], + }, + }; + let row = rows.into_iter().find(|row| row.target == target).unwrap(); + assert!(usage::target_name(app.project(), &row).contains(app.project().named(call))); + choose(&mut app, token, row.clone()); + assert_eq!(app.selected, relation); + assert_eq!(app.selected_node, None); + assert_eq!(app.behavior.active_owner, Some(fixture.owner)); + let focus = app.diagram_focus(Surface::Process(fixture.owner)).unwrap(); + assert_eq!(focus.target, target); + assert_eq!(focus.bounds, row.bounds); + assert_eq!( + behavior::selected_owner(&app), + Some(fixture.owner), + "renderer must remain in the chosen caller" + ); + let camera = app.behavior.topology.camera_boundary(); + if call == calls[1] + && let Some((previous_call, previous)) = &previous_camera + { + if *previous_call != call { + assert!(&camera != previous); + } + } + if let Some(epoch) = previous_epoch { + assert_ne!(app.canvas_epoch, epoch); + } + previous_camera = Some((call, camera)); + previous_epoch = Some(app.canvas_epoch); + assert_eq!(authored(&app), before); + } + let context = behavior::topology::Context { + document: app.canvas_document, + owner: fixture.owner, + revision: app.document.revision(), + epoch: app.canvas_epoch, + }; + let camera_epoch = app.camera_epoch; + send( + &mut app, + Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera( + context, + camera_epoch, + behavior::canvas_workspace::Event::Pan(iced::Vector::new(50., 30.)), + )), + ); + assert_eq!(app.selected, relation); + assert!( + matches!(&app.diagram_focus(Surface::Process(fixture.owner)).unwrap().target, + Target::ProcessPreviewLink { source, .. } if source.references == vec![calls[1]]) + ); + assert_eq!(authored(&app), before); + let (token, rows) = open(&mut app, relation); + let row = rows + .into_iter() + .find(|row| { + matches!(&row.target, + Target::ProcessPreviewLink { source, .. } if source.references == vec![calls[1]]) + }) + .unwrap(); + let mut changed = app.project().elements[&calls[1]].clone(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence }, + .. + }) = changed.shared_mut() + { + *sequence = None; + } + app.document + .execute(Command::EditElement(calls[1], changed)) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let prior = app.behavior.topology.camera_boundary(); + choose(&mut app, token, row); + assert!( + app.behavior.topology.camera_boundary() == prior, + "stale retargeted arrow cannot jump" + ); + assert!(app.diagram_focus(Surface::Process(fixture.owner)).is_none()); +} + +#[test] +fn asynchronous_lookup_is_loading_inert_and_retains_filter_until_completion() { + let (mut app, elements, _, _) = saved_fixture(); + let before = authored(&app); + let prior = location(&app); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let ready = lookup_completion(&app); + let row = ready.result.as_ref().unwrap()[0].clone(); + let token = ready.token; + let panel = app.diagram_usages.as_ref().unwrap(); + assert!(panel.loading && panel.usages.is_empty()); + assert!(panel.choices(app.project()).is_empty()); + send( + &mut app, + Message::DiagramUsage(Event::Search(token, "Repeated".into())), + ); + send(&mut app, Message::DiagramUsage(Event::Move(token, 1))); + choose(&mut app, token, row); + assert_eq!(app.diagram_lookup_in_flight, Some(token)); + assert!(app.diagram_usages.as_ref().unwrap().loading); + assert_eq!(location(&app), prior); + send(&mut app, Message::DiagramUsagesReady(ready)); + let panel = app.diagram_usages.as_ref().unwrap(); + assert!(!panel.loading && !panel.usages.is_empty()); + assert_eq!(panel.search, "Repeated"); + assert_eq!(panel.cursor, 0); + assert!(app.diagram_lookup_in_flight.is_none()); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); +} + +#[test] +fn closed_lookup_keeps_slot_and_replacement_waits_for_exact_old_completion() { + let (mut app, elements, _, _) = saved_fixture(); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let old = lookup_completion(&app); + send(&mut app, Message::DiagramUsage(Event::Close(old.token))); + assert!(app.diagram_usages.is_none()); + assert_eq!(app.diagram_lookup_in_flight, Some(old.token)); + let message = app.diagram_usage_message(elements[1]); + send(&mut app, message); + let replacement = app.diagram_usages.as_ref().unwrap().token; + assert_ne!(replacement, old.token); + assert!(app.diagram_usages.as_ref().unwrap().loading); + assert_eq!( + app.diagram_lookup_in_flight, + Some(old.token), + "no overlapping worker" + ); + let before = authored(&app); + send(&mut app, Message::DiagramUsagesReady(old.clone())); + assert_eq!(app.diagram_lookup_in_flight, Some(replacement)); + assert!(app.diagram_usages.as_ref().unwrap().usages.is_empty()); + send(&mut app, Message::DiagramUsagesReady(old)); + assert_eq!( + app.diagram_lookup_in_flight, + Some(replacement), + "duplicate cannot release new worker" + ); + finish_lookup(&mut app); + let panel = app.diagram_usages.as_ref().unwrap(); + assert_eq!(panel.context.subject, elements[1]); + assert!(panel.usages.iter().all(|row| row.subject == elements[1])); + assert_eq!(authored(&app), before); +} + +#[test] +fn completion_after_cancel_releases_slot_without_reopening_or_moving() { + let (mut app, elements, _, _) = saved_fixture(); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let ready = lookup_completion(&app); + send(&mut app, Message::Escape); + assert!(app.diagram_usages.is_none()); + assert_eq!(app.diagram_lookup_in_flight, Some(ready.token)); + let before = authored(&app); + let prior = location(&app); + send(&mut app, Message::DiagramUsagesReady(ready)); + assert!(app.diagram_lookup_in_flight.is_none()); + assert!(app.diagram_usages.is_none()); + assert_eq!(authored(&app), before); + assert_eq!(location(&app), prior); +} + +#[test] +fn forged_completion_cannot_release_another_slot_or_admit_wrong_context() { + let (mut app, elements, _, _) = saved_fixture(); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let ready = lookup_completion(&app); + let before = authored(&app); + let prior = location(&app); + let mut wrong_job = ready.clone(); + wrong_job.token = Id::new_v4(); + send(&mut app, Message::DiagramUsagesReady(wrong_job)); + assert_eq!(app.diagram_lookup_in_flight, Some(ready.token)); + assert!(app.diagram_usages.as_ref().unwrap().loading); + let mut wrong_context = ready.clone(); + wrong_context.context.subject = elements[1]; + send(&mut app, Message::DiagramUsagesReady(wrong_context)); + assert!( + app.diagram_lookup_in_flight.is_none(), + "the exact slot is released even for invalid context" + ); + let panel = app.diagram_usages.as_ref().unwrap(); + assert!(panel.usages.is_empty() && !panel.loading && panel.notice.is_some()); + send(&mut app, Message::DiagramUsagesReady(ready)); + assert!(app.diagram_usages.as_ref().unwrap().usages.is_empty()); + assert_eq!(authored(&app), before); + assert_eq!(location(&app), prior); +} + +#[test] +fn stale_revision_completion_refreshes_asynchronously_without_activating() { + let (mut app, elements, _, _) = saved_fixture(); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let old = lookup_completion(&app); + app.document + .execute(Command::Rename(elements[0], "Updated accepted name".into())) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + let prior = location(&app); + send(&mut app, Message::DiagramUsagesReady(old.clone())); + let panel = app.diagram_usages.as_ref().unwrap(); + assert_ne!(panel.token, old.token); + assert_eq!(panel.context.revision, app.document.revision()); + assert!(panel.loading && panel.usages.is_empty()); + assert_eq!(app.diagram_lookup_in_flight, Some(panel.token)); + finish_lookup(&mut app); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); +} + +#[test] +fn reset_keeps_old_worker_slot_and_only_new_document_results_are_admitted() { + let (mut app, elements, _, _) = saved_fixture(); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let old = lookup_completion(&app); + let mut project = Project::empty(); + let subject = project.add(Kind::Activity, "New document", 30., 40.); + app.reset(project, None); + assert_ne!(app.canvas_document, old.context.document); + assert_eq!(app.diagram_lookup_in_flight, Some(old.token)); + assert!(app.diagram_usages.is_none()); + let message = app.diagram_usage_message(subject); + send(&mut app, message); + let token = app.diagram_usages.as_ref().unwrap().token; + assert_eq!(app.diagram_lookup_in_flight, Some(old.token)); + let before = authored(&app); + send(&mut app, Message::DiagramUsagesReady(old)); + assert_eq!(app.diagram_lookup_in_flight, Some(token)); + assert!(app.diagram_usages.as_ref().unwrap().usages.is_empty()); + finish_lookup(&mut app); + let panel = app.diagram_usages.as_ref().unwrap(); + assert_eq!(panel.context.document, app.canvas_document); + assert_eq!(panel.context.subject, subject); + assert!(panel.usages.iter().all(|row| row.subject == subject)); + assert_eq!(authored(&app), before); +} + +#[test] +fn worker_failure_finishes_loading_and_can_be_retried_without_losing_sources() { + let (mut app, elements, _, _) = saved_fixture(); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let mut completion = lookup_completion(&app); + completion.result = Err("Injected worker failure".into()); + let token = completion.token; + let before = authored(&app); + send(&mut app, Message::DiagramUsagesReady(completion)); + assert!(app.diagram_lookup_in_flight.is_none()); + let panel = app.diagram_usages.as_ref().unwrap(); + assert!(!panel.loading && panel.usages.is_empty()); + assert_eq!(panel.notice.as_deref(), Some("Injected worker failure")); + send(&mut app, Message::DiagramUsage(Event::Close(token))); + let (_, rows) = open(&mut app, elements[0]); + assert!(!rows.is_empty()); + assert_eq!(authored(&app), before); +} + +#[test] +fn oversized_node_focus_stays_on_label_and_single_surface_query_excludes_other_views() { + let (mut app, elements, _, diagram) = saved_fixture(); + let node = app.project().diagrams[&diagram] + .nodes + .values() + .find(|node| node.element == elements[0]) + .unwrap() + .id; + let mut expanded = app.project().diagrams[&diagram].nodes[&node].clone(); + expanded.size.width = 9000.; + expanded.size.height = 8000.; + app.document + .execute(Command::EditNode { + diagram, + original: node, + node: expanded.clone(), + }) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + let hits = usage::saved_usages_for(app.project(), elements[0], Some(diagram)); + assert_eq!(hits.len(), 1); + assert!(hits[0].bounds.width <= 420. && hits[0].bounds.height <= 64.); + assert_eq!( + (hits[0].bounds.x, hits[0].bounds.y), + (expanded.position.x, expanded.position.y) + ); + assert!(usage::saved_usages_for(app.project(), elements[0], Some(Id::new_v4())).is_empty()); + assert!( + hits.iter() + .all(|hit| hit.surface == Surface::Saved(diagram)) + ); + assert_eq!(authored(&app), before); +} + +#[test] +fn query_presentation_snapshot_keeps_expansion_without_retaining_live_state() { + let fixture = behavior::topology::direct_tests::fixture(Layer::Oa); + let mut project = fixture.p; + let reused = project + .make_element(Kind::Process, Layer::Oa, "Long reusable check", None) + .unwrap(); + let reused_id = reused.id; + project = execute_batch(&project, &[Command::CreateElement(reused)]).unwrap(); + let mut leaves = Vec::new(); + for n in 0..5 { + leaves.push(add_occurrence( + &mut project, + reused_id, + &format!("Check {n}"), + OccurrenceBody::Action { + action: Some(fixture.work[0]), + lifeline: None, + }, + )); + } + let call = add_occurrence( + &mut project, + fixture.owner, + "Expanded call", + OccurrenceBody::SequenceReference { + sequence: Some(reused_id), + }, + ); + let mut live = behavior::topology::State::default(); + live.enter(Id::new_v4(), fixture.owner); + assert!(live.expand(&project, fixture.owner, call)); + let snapshot = live.usage_snapshot(); + assert!(live.expand(&project, fixture.owner, call)); + assert!( + live.diagram_usages(&project, fixture.owner, leaves[4]) + .is_empty() + ); + let query_state = snapshot.into_query_state(); + let rows = query_state.diagram_usages(&project, fixture.owner, leaves[4]); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].target, + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![call], + occurrence: leaves[4], + }) + ); +} + +#[test] +fn whole_surface_camera_fits_huge_extent_while_target_camera_remains_readable() { + let bounds = iced::Rectangle { + x: -20_000., + y: -30_000., + width: 100_000., + height: 80_000., + }; + let viewport = iced::Size::new(800., 500.); + let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport); + assert!(zoom < 0.05 && zoom >= 0.0001); + assert!(bounds.width * zoom <= viewport.width - 64. + 0.01); + assert!(bounds.height * zoom <= viewport.height - 64. + 0.01); + assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01); + assert!((bounds.center_y() * zoom + pan.y - viewport.height / 2.).abs() < 0.01); + let focused = usage::camera_for(&Target::Placement(Id::new_v4()), bounds, viewport); + assert_eq!(focused, usage::camera(bounds, viewport)); +} + +#[test] +fn completion_releases_worker_behind_busy_modal_without_navigation() { + let (mut app, elements, _, _) = saved_fixture(); + let message = app.diagram_usage_message(elements[0]); + send(&mut app, message); + let ready = lookup_completion(&app); + app.busy = true; + app.hosted.visible = true; + let before = authored(&app); + let prior = location(&app); + send(&mut app, Message::DiagramUsagesReady(ready)); + assert!(app.diagram_lookup_in_flight.is_none()); + assert!(!app.diagram_usages.as_ref().unwrap().loading); + assert!(app.busy && app.hosted.visible); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); +} diff --git a/crates/oa-desktop/src/app/hosted.rs b/crates/oa-desktop/src/app/hosted.rs index 8b6fd16c..7cea3328 100644 --- a/crates/oa-desktop/src/app/hosted.rs +++ b/crates/oa-desktop/src/app/hosted.rs @@ -213,6 +213,14 @@ impl App { self.pending = None; return Task::none(); } + if self.path.is_some() + || self.hosted.session.as_ref().is_none_or(|session| { + session.resource_id != remote.resource_id + || session.connection.base() != request.connection.base() + }) + { + self.source_workspace.reset_navigation_session(); + } self.hosted.session = Some(Session { connection: request.connection.clone(), resource_id: remote.resource_id, @@ -345,6 +353,14 @@ impl App { match self.document.apply_compiled((*remote.compiled).clone()) { Ok(_) => { self.document.mark_saved(remote.compiled.clone()); + if self.path.is_some() + || self.hosted.session.as_ref().is_none_or(|session| { + session.resource_id != remote.resource_id + || session.connection.base() != connection.base() + }) + { + self.source_workspace.reset_navigation_session(); + } self.hosted.session = Some(Session { connection, resource_id: remote.resource_id, diff --git a/crates/oa-desktop/src/app/source_rename.rs b/crates/oa-desktop/src/app/source_rename.rs new file mode 100644 index 00000000..7bcbaa0a --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename.rs @@ -0,0 +1,1226 @@ +//! Editor-local, explicitly approved alias transactions. Workers own immutable +//! snapshots; only the live form can approve adoption into the current document. +use super::*; +use crate::{ + document::PreparedAliasRename, + source_navigation::{self as nav, query}, + source_rename::{origin, preview}, +}; +mod alias_input; +mod editor_patch; +const LINES_PER_PAGE: usize = 8; + +// A transient display hint from the same approved candidate. Document authority, +// model synchronization and source ownership remain with their existing paths. +pub(super) struct EditorRefresh<'a> { + path: &'a str, + before: &'a str, + after: &'a str, +} + +impl EditorRefresh<'_> { + pub(super) fn apply(self, editor: &mut text_editor::Content, path: &str, source: &str) -> bool { + self.path == path + && self.after == source + && editor_patch::replace(editor, self.before, self.after) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Begin { + pub document: Id, + pub revision: u64, + pub source: query::SnapshotKey, + pub path: String, + pub cursor: text_editor::Cursor, +} + +#[derive(Debug, Clone)] +pub enum Event { + Begin(Begin), + Input(Id, String), + PasteRejected(Id), + Preview(Id, Id), + Confirm(Id, Id, Id), + Close(Id), + Search(Id, String), + File(Id, Id, bool), + Hunk(Id, Id, bool), + Line(Id, Id, bool), + Segment(Id, Id, bool), + Details(Id, bool), + DetailSegment(Id, bool), + ReadOnly(Id, text_editor::Action), + Undo(Id), +} + +#[derive(Debug, Clone)] +struct Context { + begin: Begin, + accepted: Arc, +} + +#[derive(Debug, Clone)] +struct Job { + id: Id, + invocation: Id, + generation: Id, + context: Context, +} + +#[derive(Debug)] +struct Target { + id: Id, + alias: String, + caption: String, + origin: query::Origin, +} + +#[derive(Debug)] +struct Approval { + id: Id, + prepared: PreparedAliasRename, + preview: preview::Preview, + cursor: text_editor::Cursor, +} + +#[derive(Debug, Clone)] +enum Payload { + Target(Result, String>), + Preview(Result, String>), +} + +#[derive(Debug, Clone)] +pub struct Completion { + job: Job, + payload: Payload, +} + +struct Form { + invocation: Id, + generation: Id, + context: Context, + target: Option>, + replacement: String, + notice: String, + stale: bool, + pending: Option, + approval: Option>, + search: String, + file: usize, + hunk: usize, + line: usize, + segment: usize, + preview_destination: Id, + details: bool, + detail_segment: usize, + detail_editor: text_editor::Content, +} + +#[derive(Debug)] +struct Undo { + id: Id, + context: Context, +} + +#[derive(Default)] +pub struct State { + form: Option
, + pub(super) in_flight: Option, + undo: Option, +} + +impl State { + pub fn open(&self) -> bool { + self.form.is_some() + } +} + +impl App { + pub fn source_rename_begin(&self) -> Begin { + Begin { + document: self.canvas_document, + revision: self.document.revision(), + source: self.source_key(), + path: self.source_workspace.active.clone(), + cursor: self.source_editor.cursor(), + } + } + + pub fn source_rename_message(&self) -> Message { + Message::SourceRename(Event::Begin(self.source_rename_begin())) + } + + // Called before source_interaction or any form mutation. In particular an + // old Close/Confirm cannot disturb a newer form with the same source path. + pub(super) fn source_rename_envelope(&self, event: &Event) -> bool { + if let Event::Begin(begin) = event { + return self.page == Page::Text + && !self.source_rename.open() + && *begin == self.source_rename_begin(); + } + if let Event::Undo(id) = event { + return self.source_rename.undo.as_ref().is_some_and(|undo| { + undo.id == *id + && !self.source_rename.open() + && self.rename_undo_matches(&undo.context) + }); + } + let Some(form) = &self.source_rename.form else { + return false; + }; + match event { + Event::Preview(invocation, generation) => { + *invocation == form.invocation && *generation == form.generation + } + Event::Confirm(invocation, generation, preview) => { + *invocation == form.invocation + && *generation == form.generation + && form + .approval + .as_ref() + .is_some_and(|ready| ready.id == *preview) + } + Event::File(invocation, preview, _) + | Event::Hunk(invocation, preview, _) + | Event::Line(invocation, preview, _) + | Event::Segment(invocation, preview, _) => { + *invocation == form.invocation + && form + .approval + .as_ref() + .is_some_and(|ready| ready.id == *preview) + } + Event::Input(invocation, _) + | Event::PasteRejected(invocation) + | Event::Close(invocation) + | Event::Search(invocation, _) + | Event::Details(invocation, _) + | Event::DetailSegment(invocation, _) + | Event::ReadOnly(invocation, _) => *invocation == form.invocation, + Event::Begin(_) | Event::Undo(_) => false, + } + } + + fn rename_admission(&self) -> Result<(), String> { + if self.page != Page::Text + || self.busy + || self.pending.is_some() + || self.file_prompt.is_some() + || self.delete_pending + || self.workflow.is_some() + || self.hosted.visible + || self.model_menu.is_some() + || self.architecture.is_some() + || self.diagram_usages.is_some() + || self.reference_input_active() + || self.behavior.has_pending() + || self.draft_dirty() + { + return Err("Finish or cancel the current property, behavior or file operation before renaming. No drafts were applied.".into()); + } + let workspace = &self.source_workspace; + let accepted = self.document.bundle(); + if !workspace.applied || workspace.has_edits() { + return Err( + "Apply or revert source drafts before renaming an alias. Your drafts are retained." + .into(), + ); + } + if workspace.entry != accepted.entry + || workspace.files.len() != accepted.files.len() + || !workspace + .files + .iter() + .zip(&accepted.files) + .all(|((path, buffer), (other, text))| path == other && buffer.text == *text) + || workspace.reachable.len() != accepted.files.len() + || !workspace.reachable.iter().eq(accepted.files.keys()) + || self.source_editor.text() != workspace.text() + { + return Err("Rename requires exactly the accepted included files. Close a clean detached buffer, or reattach/revert its draft first.".into()); + } + Ok(()) + } + + fn rename_context(&self) -> Context { + Context { + begin: self.source_rename_begin(), + accepted: self.document.snapshot(), + } + } + + fn rename_context_matches(&self, context: &Context) -> bool { + context.begin == self.source_rename_begin() + && Arc::ptr_eq(&context.accepted, &self.document.snapshot()) + && self.rename_admission().is_ok() + } + + fn rename_undo_matches(&self, context: &Context) -> bool { + let mut current = self.source_rename_begin(); + current.cursor = context.begin.cursor; + context.begin == current + && Arc::ptr_eq(&context.accepted, &self.document.snapshot()) + && self.rename_admission().is_ok() + } + + pub(super) fn invalidate_source_rename(&mut self, message: &str) { + if let Some(form) = &mut self.source_rename.form { + form.stale = true; + form.pending = None; + form.approval = None; + form.notice = message.into(); + } + self.source_rename.undo = None; + } + + pub(super) fn refresh_source_rename_context(&mut self) { + let stale = self + .source_rename + .form + .as_ref() + .is_some_and(|form| !form.stale && !self.rename_context_matches(&form.context)); + if stale { + self.invalidate_source_rename("Source context changed. Your replacement is retained; close and invoke Rename again on the current source."); + } + if self + .source_rename + .undo + .as_ref() + .is_some_and(|undo| !self.rename_undo_matches(&undo.context)) + { + self.source_rename.undo = None; + } + if let Some(form) = &mut self.source_rename.form + && form.details + { + refresh_details(form); + } + } + + pub(super) fn close_source_rename(&mut self) { + if let Some(form) = self.source_rename.form.take() { + // Do not release the occupied worker. Its matching completion does. + if self.rename_context_matches(&form.context) { + self.queue_source_reveal(form.context.begin.cursor, false); + } + } + } + + pub fn source_rename_undo_message(&self) -> Option { + self.source_rename + .undo + .as_ref() + .filter(|undo| !self.source_rename.open() && self.rename_undo_matches(&undo.context)) + .map(|undo| Message::SourceRename(Event::Undo(undo.id))) + } + + pub(super) fn source_rename_event(&mut self, event: Event) -> Task { + let mut reset_preview_scroll = false; + match event { + Event::Begin(_) => return self.begin_source_rename(), + Event::Close(_) => self.close_source_rename(), + Event::Input(_, value) => { + let Some(form) = &mut self.source_rename.form else { + return Task::none(); + }; + if form.replacement != value { + form.replacement = value; + form.generation = Id::new_v4(); + form.approval = None; + form.pending = None; + if !form.stale { + form.notice = replacement_notice(form.target.as_deref(), &form.replacement) + .unwrap_or_else(|| { + "Choose Preview to review the complete change.".into() + }); + } + } + } + Event::PasteRejected(_) => { + let form = self.source_rename.form.as_mut().unwrap(); + form.generation = Id::new_v4(); + form.approval = None; + form.pending = None; + form.notice = "Paste rejected: aliases cannot contain line breaks, tabs or other control characters. The field is unchanged; paste a single-line alias and choose Preview.".into(); + if form.stale { + form.notice + .push_str(" The source context changed; close and invoke Rename again."); + } + } + Event::Preview(_, _) => return self.preview_source_rename(), + Event::Confirm(_, _, _) => self.confirm_source_rename(), + Event::Undo(_) => { + let Some(undo) = self.source_rename.undo.take() else { + return Task::none(); + }; + if !self.source_rename.open() + && self.rename_undo_matches(&undo.context) + && self.document.undo() + { + self.sync(); + self.status = + "Alias rename undone as one model change. Redo model restores it.".into(); + } + } + Event::Search(_, value) => { + let form = self.source_rename.form.as_mut().unwrap(); + if form.search == value { + return Task::none(); + } + form.search = value; + reset_preview_scroll = form.approval.is_some(); + if let Some(approval) = &form.approval + && let Some(index) = approval + .preview + .files() + .iter() + .position(|file| file.path().contains(&form.search)) + { + form.file = index; + form.hunk = 0; + form.line = 0; + form.segment = 0; + } + } + Event::File(_, _, next) + | Event::Hunk(_, _, next) + | Event::Line(_, _, next) + | Event::Segment(_, _, next) => { + let form = self.source_rename.form.as_mut().unwrap(); + let ready = form.approval.as_ref().unwrap(); + let previous_page = (form.file, form.hunk, form.line, form.segment); + match event { + Event::File(..) => { + let matching: Vec<_> = ready + .preview + .files() + .iter() + .enumerate() + .filter_map(|(i, file)| file.path().contains(&form.search).then_some(i)) + .collect(); + if let Some(position) = matching.iter().position(|i| *i == form.file) { + form.file = matching[step(position, matching.len(), next)]; + } + form.hunk = 0; + form.line = 0; + form.segment = 0; + } + Event::Hunk(..) => { + form.hunk = step( + form.hunk, + ready.preview.files()[form.file].hunks().len(), + next, + ); + form.line = 0; + form.segment = 0; + } + Event::Line(..) => { + let count = ready.preview.files()[form.file].hunks()[form.hunk] + .lines() + .len() + .div_ceil(LINES_PER_PAGE); + form.line = step(form.line / LINES_PER_PAGE, count, next) * LINES_PER_PAGE; + form.segment = 0; + } + _ => form.segment = step(form.segment, segment_total(form), next), + } + reset_preview_scroll = + previous_page != (form.file, form.hunk, form.line, form.segment); + } + Event::Details(_, value) => { + let form = self.source_rename.form.as_mut().unwrap(); + if form.details == value { + return Task::none(); + } + form.details = value; + // Leaving or returning to the preview ends the old visit. Keep + // its selected page and retained scroll; only retire delayed + // operations, without scheduling another return-to-top. + form.preview_destination = Id::new_v4(); + form.detail_segment = 0; + refresh_details(form); + } + Event::DetailSegment(_, next) => { + let form = self.source_rename.form.as_mut().unwrap(); + form.detail_segment = step( + form.detail_segment, + preview::segment_count(&details(form)), + next, + ); + refresh_details(form); + } + Event::ReadOnly(_, action) => { + if !action.is_edit() { + self.source_rename + .form + .as_mut() + .unwrap() + .detail_editor + .perform(action); + } + } + } + if reset_preview_scroll && let Some(form) = &mut self.source_rename.form { + return reset_preview_position(form); + } + Task::none() + } + + fn begin_source_rename(&mut self) -> Task { + if let Err(error) = self.rename_admission() { + self.status = error; + return Task::none(); + } + if self.source_rename.in_flight.is_some() { + self.status = + "The previous rename task is finishing. Try Rename again when it completes.".into(); + return Task::none(); + } + let context = self.rename_context(); + let Some(offset) = nav::cursor_offset(self.source_workspace.text(), context.begin.cursor) + else { + self.status = "Place the cursor on a source alias or its @ reference.".into(); + return Task::none(); + }; + let input = match self + .with_source_input(|input| query::admit_owned(input, query::Limits::default())) + { + Ok(input) => input, + Err(error) => { + self.status = format!("Rename source admission failed: {error:?}"); + return Task::none(); + } + }; + let job = Job { + id: Id::new_v4(), + invocation: Id::new_v4(), + generation: Id::new_v4(), + context, + }; + self.source_rename.undo = None; + self.source_rename.in_flight = Some(job.id); + self.source_rename.form = Some(Form { + invocation: job.invocation, + generation: job.generation, + context: job.context.clone(), + target: None, + replacement: String::new(), + notice: "Checking the selected source alias…".into(), + stale: false, + pending: Some(job.id), + approval: None, + search: String::new(), + file: 0, + hunk: 0, + line: 0, + segment: 0, + preview_destination: Id::new_v4(), + details: false, + detail_segment: 0, + detail_editor: text_editor::Content::new(), + }); + Task::perform( + async move { + let worker = job.clone(); + let result = tokio::task::spawn_blocking(move || { + let snapshot = input + .capture() + .map_err(|error| format!("Rename source admission failed: {error:?}"))?; + resolve_target(&snapshot, &worker.context, offset).map(Arc::new) + }) + .await + .unwrap_or_else(|error| Err(format!("Rename target task failed: {error}"))); + Completion { + job, + payload: Payload::Target(result), + } + }, + Message::SourceRenameReady, + ) + } + + fn preview_source_rename(&mut self) -> Task { + let Some(form) = &self.source_rename.form else { + return Task::none(); + }; + if form.stale || !self.rename_context_matches(&form.context) { + self.invalidate_source_rename("Source context changed. Close and invoke Rename again; your replacement is retained."); + return Task::none(); + } + if self.source_rename.in_flight.is_some() { + return Task::none(); + } + let Some(target) = form.target.clone() else { + return Task::none(); + }; + if let Some(notice) = replacement_notice(Some(&target), &form.replacement) { + self.source_rename.form.as_mut().unwrap().notice = notice; + return Task::none(); + } + let request = match self + .document + .alias_rename_request(target.id, form.replacement.clone()) + { + Ok(request) => request, + Err(error) => { + self.source_rename.form.as_mut().unwrap().notice = error.to_string(); + return Task::none(); + } + }; + let job = Job { + id: Id::new_v4(), + invocation: form.invocation, + generation: form.generation, + context: form.context.clone(), + }; + self.source_rename.in_flight = Some(job.id); + let form = self.source_rename.form.as_mut().unwrap(); + form.pending = Some(job.id); + form.approval = None; + form.notice = "Preparing the complete rename preview…".into(); + Task::perform( + async move { + let path = job.context.begin.path.clone(); + let result = tokio::task::spawn_blocking(move || { + let prepared = request.prepare().map_err(|error| error.to_string())?; + prepare_approval(prepared, &target, &path).map(Arc::new) + }) + .await + .unwrap_or_else(|error| Err(format!("Rename preview task failed: {error}"))); + Completion { + job, + payload: Payload::Preview(result), + } + }, + Message::SourceRenameReady, + ) + } + + pub(super) fn source_rename_ready(&mut self, completion: Completion) -> Task { + if self.source_rename.in_flight != Some(completion.job.id) { + return Task::none(); + } + self.source_rename.in_flight = None; + let job = &completion.job; + let wanted = self.source_rename.form.as_ref().is_some_and(|form| { + form.invocation == job.invocation + && form.generation == job.generation + && form.pending == Some(job.id) + && !form.stale + }) && self.rename_context_matches(&job.context); + if !wanted { + self.refresh_source_rename_context(); + return Task::none(); + } + let form = self.source_rename.form.as_mut().unwrap(); + form.pending = None; + let mut reset_preview_scroll = false; + match completion.payload { + Payload::Target(Ok(target)) => { + form.replacement = target.alias.clone(); + form.target = Some(target); + form.notice = "No change. Enter a different source alias, then Preview.".into(); + if form.details { + refresh_details(form); + return Task::none(); + } + return nav::interaction::guarded_focus( + input_id(form.invocation), + self.source_symbols.raw_interaction.clone(), + self.source_symbols + .raw_interaction + .load(std::sync::atomic::Ordering::SeqCst), + ); + } + Payload::Preview(Ok(ready)) => { + if !ready.prepared.changed() { + form.notice = "No change. Nothing was applied.".into(); + } else { + form.notice = "Review every changed file. Rename alias adopts this preview as one model change; Save remains separate.".into(); + form.approval = Some(ready); + form.file = 0; + form.hunk = 0; + form.line = 0; + form.segment = 0; + form.search.clear(); + reset_preview_scroll = true; + } + } + Payload::Target(Err(error)) | Payload::Preview(Err(error)) => form.notice = error, + } + if form.details { + refresh_details(form); + } + if reset_preview_scroll { + return reset_preview_position(form); + } + Task::none() + } + + fn confirm_source_rename(&mut self) { + let Some(form) = &self.source_rename.form else { + return; + }; + if form.stale || !self.rename_context_matches(&form.context) { + self.invalidate_source_rename( + "This preview is stale. Close and invoke Rename on the current source.", + ); + return; + } + let Some(target) = &form.target else { return }; + let Some(approval) = &form.approval else { + return; + }; + if approval.prepared.target() != target.id + || approval.prepared.old_alias() != target.alias + || approval.prepared.new_alias() != form.replacement + || approval.prepared.revision() != form.context.begin.revision + { + return; + } + let approval = self + .source_rename + .form + .as_mut() + .unwrap() + .approval + .take() + .unwrap(); + match self.document.commit_alias_rename(&approval.prepared) { + Ok(true) => { + let path = self.source_workspace.active.clone(); + let hint = approval + .prepared + .before() + .bundle + .files + .get(&path) + .zip(approval.prepared.after().bundle.files.get(&path)) + .map(|(before, after)| EditorRefresh { + path: &path, + before, + after, + }); + self.source_rename.form = None; + self.sync_with_alias_editor(hint); + self.source_editor.move_to(approval.cursor); + self.queue_source_reveal(approval.cursor, false); + self.source_rename.undo = Some(Undo { + id: Id::new_v4(), + context: self.rename_context(), + }); + self.status = "Alias renamed. Engineering name unchanged. Undo rename / Undo model restores the whole change; Save writes accepted changes.".into(); + } + Ok(false) => { + self.source_rename.form.as_mut().unwrap().notice = + "No change. Nothing was applied.".into() + } + Err(error) => self.source_rename.form.as_mut().unwrap().notice = error.to_string(), + } + } +} + +fn replacement_notice(target: Option<&Target>, value: &str) -> Option { + let target = target?; + if value == target.alias { + Some("No change. Enter a different source alias.".into()) + } else if !valid_alias(value) { + Some("Use a letter or underscore, then ASCII letters, digits or underscores; reserved aliases are unavailable. Input is not trimmed.".into()) + } else { + None + } +} + +fn resolve_target( + snapshot: &query::Snapshot, + context: &Context, + offset: usize, +) -> Result { + let result = snapshot + .query(query::Query { + stamp: query::QueryStamp { + snapshot: context.begin.source, + serial: 0, + }, + path: &context.begin.path, + byte_offset: offset, + action: query::Action::Definition, + }) + .map_err(|error| format!("Cannot resolve this alias: {error:?}"))?; + let scope = &result.scope; + if !scope.closure_complete + || !scope.applied + || !scope.validated + || scope.detached_files != 0 + || scope.unverified_files != 0 + || result.total_definitions != 1 + || result.definitions.len() != 1 + || result.omitted_definitions != 0 + { + return Err("Rename needs one complete, validated definition in the included source. Resolve ambiguous or incomplete source first.".into()); + } + let row = &result.definitions[0]; + let query::Identity::Unique(anchor) = row.declaration.identity else { + return Err("Rename requires one unique authored UUID for this alias.".into()); + }; + let id = anchor.id(); + let location = context + .accepted + .index + .declarations + .get(&id) + .ok_or("The authored declaration does not belong to the accepted model.")?; + let accepted_alias = context + .accepted + .decoded + .project + .identities() + .into_iter() + .find_map(|(key, alias)| (key == id).then_some(alias)); + if row.membership != query::Membership::Included + || row.evidence != query::Evidence::Validated + || row.declaration.authored_id != Some(id) + || accepted_alias != Some(row.declaration.alias.as_str()) + || location.path != row.path + || row.span.start < location.span.start + || row.span.end > location.span.end + { + return Err("The source alias does not exactly match its accepted declaration.".into()); + } + let origin = result + .origin + .ok_or("Place the cursor on an alias or its @ reference.")?; + let source = context + .accepted + .bundle + .files + .get(&context.begin.path) + .ok_or("Origin file is unavailable.")?; + if !nav::exact_cursor(source, context.begin.cursor) { + return Err("The source selection changed.".into()); + } + if let Some(selection) = context.begin.cursor.selection { + let a = + nav::byte_offset(source, context.begin.cursor.position).ok_or("Invalid selection.")?; + let b = nav::byte_offset(source, selection).ok_or("Invalid selection.")?; + if a != b { + let start = a.min(b); + let end = a.max(b); + let alias_only = start >= origin.span.start && end <= origin.span.end; + let reference = origin + .marker + .is_some_and(|marker| start == marker.start && end == origin.span.end); + if !alias_only && !reference { + return Err("Select only the source alias or its complete @ reference.".into()); + } + } + } + let project = &context.accepted.decoded.project; + let placement = project + .diagrams + .values() + .find_map(|diagram| diagram.nodes.values().find(|node| node.id == id)); + let caption = if let Some(node) = placement { + format!("Placement of {}", project.named(node.element)) + } else { + format!( + "{} · {}", + row.declaration.kind, + row.declaration.name.as_deref().unwrap_or("Unnamed") + ) + }; + Ok(Target { + id, + alias: row.declaration.alias.clone(), + caption, + origin, + }) +} + +fn prepare_approval( + prepared: PreparedAliasRename, + target: &Target, + path: &str, +) -> Result { + let preview = preview::Preview::build(&prepared.before().bundle, &prepared.after().bundle) + .map_err(|error| error.to_string())?; + let old = &prepared.before().bundle.files[path]; + let new = &prepared.after().bundle.files[path]; + let span = origin::map( + old, + new, + prepared.old_alias(), + prepared.new_alias(), + target.origin.span, + target.origin.marker, + ) + .map_err(|error| error.to_string())?; + let position = |offset| { + let prefix = &new[..offset]; + text_editor::Position { + line: prefix.bytes().filter(|b| *b == b'\n').count(), + column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + } + }; + let cursor = text_editor::Cursor { + position: position(span.start), + selection: Some(position(span.end)), + }; + if !nav::exact_cursor(new, cursor) { + return Err("Cannot preserve the exact renamed occurrence.".into()); + } + Ok(Approval { + id: Id::new_v4(), + prepared, + preview, + cursor, + }) +} + +fn step(index: usize, count: usize, next: bool) -> usize { + if next { + index.saturating_add(1).min(count.saturating_sub(1)) + } else { + index.saturating_sub(1) + } +} + +fn segment_total(form: &Form) -> usize { + let Some(ready) = &form.approval else { + return 0; + }; + let file = &ready.preview.files()[form.file]; + file.hunks()[form.hunk] + .lines() + .iter() + .skip(form.line) + .take(LINES_PER_PAGE) + .map(|line| { + preview::segment_count( + line.before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap_or(""), + ) + .max(preview::segment_count( + line.after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap_or(""), + )) + }) + .max() + .unwrap_or(0) +} + +fn details(form: &Form) -> String { + let target = form + .target + .as_ref() + .map_or("Resolving alias", |target| target.caption.as_str()); + let path = form + .approval + .as_ref() + .map_or(form.context.begin.path.as_str(), |ready| { + ready.preview.files()[form.file].path() + }); + format!( + "{target}\nOrigin: {}\nDisplayed file: {path}\nSource alias: {}\nReplacement: {}\n{}", + form.context.begin.path, + form.target + .as_ref() + .map_or("", |target| target.alias.as_str()), + form.replacement, + form.notice + ) +} + +fn refresh_details(form: &mut Form) { + let full = details(form); + form.detail_segment = form + .detail_segment + .min(preview::segment_count(&full).saturating_sub(1)); + let segment = preview::text_segment(&full, form.detail_segment).unwrap_or(""); + if form.detail_editor.text() != segment { + form.detail_editor = text_editor::Content::with_text(segment); + } +} + +fn input_id(invocation: Id) -> iced::widget::Id { + iced::widget::Id::from(format!("source-rename-{invocation}")) +} + +// Each navigation gets a fresh destination even when returning to the same page +// or changing a filter that leaves the page tuple unchanged (the ABA case). +fn reset_preview_position(form: &mut Form) -> Task { + form.preview_destination = Id::new_v4(); + if !form.details + && let Some(ready) = &form.approval + { + iced::widget::operation::snap_to( + preview_scroll_id(form, ready.id), + iced::widget::scrollable::RelativeOffset::START, + ) + } else { + Task::none() + } +} + +// The operation targets this visit, not a later page, visit or approval. +// A new widget id alone does not reset Iced's retained Scrollable state. +fn preview_scroll_id(form: &Form, approval: Id) -> iced::widget::Id { + iced::widget::Id::from(format!( + "source-rename-diff-{}-{approval}-{}-{}-{}-{}-{}", + form.invocation, form.preview_destination, form.file, form.hunk, form.line, form.segment + )) +} + +/// Complete indexed preview with bounded rendering: eight admitted source rows +/// and one UTF-8 segment per side at a time. No candidate content is discarded. +pub(crate) fn dialog(app: &App) -> Element<'_, Message> { + dialog_content( + app, + &app.source_rename + .form + .as_ref() + .expect("open rename form") + .detail_editor, + ) +} + +fn dialog_content<'a, R>( + app: &'a App, + detail_editor: &'a text_editor::Content, +) -> Element<'a, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'static, +{ + use crate::views::short; + use iced::{ + Fill, Font, + widget::{button, column, container, row, scrollable, text, text_input}, + }; + let form = app.source_rename.form.as_ref().expect("open rename form"); + let invocation = form.invocation; + let message = |event| Message::SourceRename(event); + let can_preview = !form.stale + && app.source_rename.in_flight.is_none() + && form.target.is_some() + && replacement_notice(form.target.as_deref(), &form.replacement).is_none(); + let caption = form + .target + .as_ref() + .map_or("Checking source target…".into(), |target| { + short(&target.caption, 76) + }); + let mut alias = text_input("Source alias", &form.replacement) + .id(input_id(invocation)) + .padding(8) + .size(14); + if form.target.is_some() { + alias = alias + .on_input(move |value| message(Event::Input(invocation, value))) + .on_submit(message(Event::Preview(invocation, form.generation))); + } + let alias = alias_input::guard( + alias.into(), + form.target + .is_some() + .then(|| message(Event::PasteRejected(invocation))), + ); + let content: Element<'_, Message, iced::Theme, R> = if form.details { + let count = preview::segment_count(&details(form)); + column![ + label("Selectable target, full paths and feedback · all segments available"), + pager( + format!("Details {}/{}", form.detail_segment + 1, count.max(1)), + (form.detail_segment > 0).then(|| message(Event::DetailSegment(invocation, false))), + (form.detail_segment + 1 < count) + .then(|| message(Event::DetailSegment(invocation, true))) + ), + iced::widget::text_editor(detail_editor) + .font(Font::MONOSPACE) + .size(12) + .height(Fill) + .on_action(move |event| message(Event::ReadOnly(invocation, event))), + ] + .spacing(8) + .height(Fill) + .into() + } else if let Some(ready) = &form.approval { + let files = ready.preview.files(); + let file = &files[form.file]; + let hunks = file.hunks(); + let lines = hunks[form.hunk].lines(); + let segments = segment_total(form); + let matched: Vec<_> = files + .iter() + .enumerate() + .filter_map(|(i, file)| file.path().contains(&form.search).then_some(i)) + .collect(); + let selected = matched.iter().position(|i| *i == form.file); + let preview_id = ready.id; + let mut displayed = column![].spacing(8).width(Fill); + for line in lines.iter().skip(form.line).take(LINES_PER_PAGE) { + let before = line + .before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap_or(""); + let after = line + .after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap_or(""); + displayed = displayed + .push( + text(format!( + "Line {} · {}", + line.number(), + if line.changed() { "changed" } else { "context" } + )) + .size(12), + ) + .push(text("− Before").size(11)) + .push( + text( + preview::text_segment(before, form.segment) + .unwrap_or("[No further segment on this side]"), + ) + .font(Font::MONOSPACE) + .size(12) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ) + .push(text("+ After").size(11)) + .push( + text( + preview::text_segment(after, form.segment) + .unwrap_or("[No further segment on this side]"), + ) + .font(Font::MONOSPACE) + .size(12) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ); + } + column![ + label(format!("{} changed files · {} changed lines · {} preview bytes. This operation only; earlier unsaved changes are separate.", files.len(), ready.preview.changed_line_count(), ready.preview.display_bytes())), + text_input("Find changed file by path", &form.search).padding(6).size(12) + .on_input(move |value| message(Event::Search(invocation, value))), + label(if matched.is_empty() { "No paths match. The previous file remains visible; clear search to browse all changed files." } else { "All matching files are reachable with Previous / Next." }), + pager(format!("File {}/{} · {}", form.file + 1, files.len(), short(file.path(), 45)), + selected.filter(|i| *i > 0).map(|_| message(Event::File(invocation, preview_id, false))), + selected.filter(|i| i + 1 < matched.len()).map(|_| message(Event::File(invocation, preview_id, true)))), + row![ + pager(format!("Hunk {}/{}", form.hunk + 1, hunks.len()), + (form.hunk > 0).then(|| message(Event::Hunk(invocation, preview_id, false))), + (form.hunk + 1 < hunks.len()).then(|| message(Event::Hunk(invocation, preview_id, true)))), + pager(format!("Rows {}–{}/{}", form.line + 1, (form.line + LINES_PER_PAGE).min(lines.len()), lines.len()), + (form.line > 0).then(|| message(Event::Line(invocation, preview_id, false))), + (form.line + LINES_PER_PAGE < lines.len()).then(|| message(Event::Line(invocation, preview_id, true)))), + ].spacing(8), + pager(format!("Row segments {}/{} · both sides", form.segment + 1, segments.max(1)), + (form.segment > 0).then(|| message(Event::Segment(invocation, preview_id, false))), + (form.segment + 1 < segments).then(|| message(Event::Segment(invocation, preview_id, true)))), + scrollable(displayed).id(preview_scroll_id(form, preview_id)).height(Fill).width(Fill).spacing(8), + ].spacing(6).height(Fill).into() + } else { + scrollable(column![ + text("Preview changes the source alias and its typed uses across all included files.").size(13), + text("Engineering names, identities and other model fields stay unchanged. Physical files outside the loaded project are untouched.").size(13), + text("After Rename alias, use Undo rename or Undo model for the whole transaction. Ctrl+Z in Text remains Undo text. Save writes all accepted unsaved changes.").size(13), + ].spacing(12)).height(Fill).into() + }; + let confirm = form + .approval + .as_ref() + .filter(|_| !form.stale) + .map(|ready| message(Event::Confirm(invocation, form.generation, ready.id))); + container( + column![ + row![ + text("Rename alias").size(20), + container(text(caption).size(12)).width(Fill), + action( + if form.details { + "Preview pane" + } else { + "Full details" + }, + message(Event::Details(invocation, !form.details)) + ) + ] + .spacing(10), + label("Source alias · Engineering name stays unchanged"), + container(alias).id("source-rename-input"), + text(short(&form.notice, 190)).size(12), + container(content).id("source-rename-preview").height(Fill), + container( + row![ + action("Cancel", message(Event::Close(invocation))), + button(text("Preview").size(13)).padding(10).on_press_maybe( + can_preview.then(|| message(Event::Preview(invocation, form.generation))) + ), + button(text("Rename alias").size(13)) + .padding(10) + .on_press_maybe(confirm), + ] + .spacing(10) + ) + .id("source-rename-footer"), + ] + .spacing(8) + .height((app.window_size.height - 120.).clamp(200., 680.)), + ) + .width(Fill) + .into() +} + +fn pager( + label: String, + previous: Option, + next: Option, +) -> Element<'static, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'static, +{ + use iced::{ + Fill, + widget::{button, container, row, text}, + }; + row![ + button(text("Previous").size(11)) + .padding(5) + .on_press_maybe(previous), + container(text(label).size(11)).width(Fill), + button(text("Next").size(11)) + .padding(5) + .on_press_maybe(next), + ] + .spacing(6) + .align_y(iced::Center) + .width(Fill) + .into() +} + +fn action<'a, R>(label: &str, message: Message) -> iced::widget::Button<'a, Message, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'a, +{ + iced::widget::button(iced::widget::text(label.to_owned()).size(12)) + .padding([8, 10]) + .on_press(message) + .style(crate::style::nav) +} + +fn label<'a, R>(value: impl Into) -> iced::widget::Text<'a, iced::Theme, R> +where + R: iced::advanced::text::Renderer + 'a, +{ + iced::widget::text(value.into()) + .size(11) + .color(crate::style::MUTED) +} + +#[cfg(test)] +mod tests; + +#[cfg(all(test, windows, debug_assertions))] +#[path = "source_rename_timing_tests.rs"] +mod timing_tests; diff --git a/crates/oa-desktop/src/app/source_rename/alias_input.rs b/crates/oa-desktop/src/app/source_rename/alias_input.rs new file mode 100644 index 00000000..f7240271 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/alias_input.rs @@ -0,0 +1,219 @@ +//! Keep Iced's single-line input from silently filtering pasted control bytes. +//! Only the alias field uses this adapter. The child retains editing and focus. +use iced::advanced::{ + Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget, +}; +use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse}; + +pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>( + input: Element<'a, Message, Theme, R>, + rejected: Option, +) -> Element<'a, Message, Theme, R> { + Element::new(AliasInput { input, rejected }) +} + +struct AliasInput<'a, Message, Theme, R> { + input: Element<'a, Message, Theme, R>, + rejected: Option, +} + +#[derive(Default)] +struct State { + modifiers: keyboard::Modifiers, +} + +// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its +// previously admitted paste while V is held, until KeyReleased clears that cache. +struct CapturedClipboard<'a> { + content: Option, + original: &'a mut dyn Clipboard, +} +impl Clipboard for CapturedClipboard<'_> { + fn read(&self, kind: clipboard::Kind) -> Option { + if kind == clipboard::Kind::Standard { + self.content.clone() + } else { + self.original.read(kind) + } + } + fn write(&mut self, kind: clipboard::Kind, contents: String) { + self.original.write(kind, contents); + } +} + +impl Widget + for AliasInput<'_, Message, Theme, R> +{ + fn size(&self) -> Size { + self.input.as_widget().size() + } + fn size_hint(&self) -> Size { + self.input.as_widget().size_hint() + } + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(self.input.as_widget())] + } + fn diff(&self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_ref(&self.input)); + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &R, + limits: &layout::Limits, + ) -> layout::Node { + self.input + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.input.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn widget::Operation, + ) { + self.input + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let focused = tree.children[0] + .state + .downcast_ref::>() + .is_focused(); + let state = tree.state.downcast_mut::(); + // Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers. + // Mirror its Escape reset and its Latin/physical-key paste shortcut. + if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event { + state.modifiers = *modifiers; + } + let paste = matches!(event, + Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. }) + if key.to_latin(*physical_key) == Some('v') + && state.modifiers.command() && !state.modifiers.alt()); + if focused + && paste + && let Some(rejected) = &self.rejected + { + let content = clipboard.read(clipboard::Kind::Standard); + if content + .as_ref() + .is_some_and(|value| value.chars().any(char::is_control)) + { + // Do not invoke the child: even an empty replacement would erase + // its selection. Approval invalidation is an explicit App event. + shell.publish(rejected.clone()); + shell.capture_event(); + return; + } + self.input.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + &mut CapturedClipboard { + content, + original: clipboard, + }, + shell, + viewport, + ); + return; + } + self.input.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + // In Iced 0.14.2 the only KeyPressed branch that removes focus is its + // Escape branch, which also resets stored modifiers. Observe that branch + // after its text/shortcut early returns and effective-key conversion. + if focused + && matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. })) + && !tree.children[0] + .state + .downcast_ref::>() + .is_focused() + { + tree.state.downcast_mut::().modifiers = keyboard::Modifiers::default(); + } + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.input.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.input.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +#[cfg(all(test, debug_assertions))] +mod tests; diff --git a/crates/oa-desktop/src/app/source_rename/alias_input/tests.rs b/crates/oa-desktop/src/app/source_rename/alias_input/tests.rs new file mode 100644 index 00000000..579880f4 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/alias_input/tests.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::views::source_tests::FontLayout; +use iced::keyboard::{ + Key, Modifiers, + key::{Code, Named, Physical}, +}; +use std::cell::Cell; + +#[derive(Debug, Clone, PartialEq)] +enum Signal { + Input(String), + Rejected, +} + +struct ClipboardText { + text: String, + reads: Cell, +} +impl Clipboard for ClipboardText { + fn read(&self, _: clipboard::Kind) -> Option { + self.reads.set(self.reads.get() + 1); + Some(self.text.clone()) + } + fn write(&mut self, _: clipboard::Kind, _: String) {} +} +type InputState = iced::widget::text_input::State<::Paragraph>; + +struct Mounted { + input: Element<'static, Signal, iced::Theme, FontLayout>, + tree: widget::Tree, + node: layout::Node, + clipboard: ClipboardText, + messages: Vec, +} +impl Mounted { + fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self { + let mut child = iced::widget::text_input("Alias", "send_water"); + if enabled { + child = child.on_input(Signal::Input); + } + let mut input = guard(child.into(), enabled.then_some(Signal::Rejected)); + let mut tree = widget::Tree::new(input.as_widget()); + let node = input.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, Size::new(500., 40.)), + ); + if focused { + let state = tree.children[0].state.downcast_mut::(); + state.focus(); + state.select_range(0, 10); + } + let mut mounted = Self { + input, + tree, + node, + clipboard: ClipboardText { + text: "foo\nbar".into(), + reads: Cell::new(0), + }, + messages: vec![], + }; + mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged( + modifiers, + ))); + mounted + } + fn state(&self) -> &InputState { + self.tree.children[0].state.downcast_ref::() + } + fn event(&mut self, event: Event) -> bool { + let mut shell = Shell::new(&mut self.messages); + self.input.as_widget_mut().update( + &mut self.tree, + &event, + Layout::new(&self.node), + mouse::Cursor::Unavailable, + &FontLayout, + &mut self.clipboard, + &mut shell, + &Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)), + ); + shell.is_event_captured() + } +} +fn key(key: Key, modified_key: Key, code: Code) -> Event { + Event::Keyboard(keyboard::Event::KeyPressed { + key, + modified_key, + physical_key: Physical::Code(code), + location: keyboard::Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }) +} +fn paste() -> Event { + key( + Key::Character("v".into()), + Key::Character("v".into()), + Code::KeyV, + ) +} + +#[test] +fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() { + for focused in [false, true] { + for enabled in [false, true] { + for modifiers in [ + Modifiers::empty(), + Modifiers::SHIFT, + Modifiers::COMMAND, + Modifiers::COMMAND | Modifiers::ALT, + Modifiers::COMMAND | Modifiers::SHIFT, + ] { + let mut mounted = Mounted::new(focused, enabled, modifiers); + let before = mounted.state().cursor(); + let expected = focused && enabled && modifiers.command() && !modifiers.alt(); + let captured = mounted.event(paste()); + assert_eq!( + mounted.messages, + if expected { + vec![Signal::Rejected] + } else { + vec![] + } + ); + assert_eq!(mounted.clipboard.reads.get(), usize::from(expected)); + assert_eq!(mounted.state().cursor(), before); + if expected { + assert!(captured); + } + } + } + } +} + +#[test] +fn effective_escape_reset_follows_child_after_early_returns() { + // Raw Escape mapped to Home must not clear only the guard's Command state. + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + mounted.event(key( + Key::Named(Named::Escape), + Key::Named(Named::Home), + Code::Escape, + )); + assert!(mounted.state().is_focused()); + assert!(mounted.event(paste())); + assert_eq!(mounted.messages, vec![Signal::Rejected]); + + // Effective Escape clears both states; a later focus operation does not + // invent a new ModifiersChanged event or revive the previous Command state. + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + mounted.event(key( + Key::Named(Named::Home), + Key::Named(Named::Escape), + Code::Home, + )); + assert!(!mounted.state().is_focused()); + mounted.tree.children[0] + .state + .downcast_mut::() + .focus(); + mounted.event(paste()); + assert!(mounted.messages.is_empty()); + assert_eq!(mounted.clipboard.reads.get(), 0); + + // Printable text returns before effective Escape and leaves modifiers live. + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + let mut event = key( + Key::Named(Named::Home), + Key::Named(Named::Escape), + Code::Home, + ); + if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event { + *text = Some("x".into()); + } + mounted.event(event); + assert!(mounted.state().is_focused()); + assert_eq!(mounted.messages, vec![Signal::Input("x".into())]); + mounted.messages.clear(); + mounted.event(paste()); + assert_eq!(mounted.messages, vec![Signal::Rejected]); +} + +#[test] +fn held_paste_cache_contains_only_previously_admitted_text() { + let mut mounted = Mounted::new(true, true, Modifiers::COMMAND); + mounted.clipboard.text = "water".into(); + mounted.event(paste()); + assert_eq!(mounted.messages, vec![Signal::Input("water".into())]); + mounted.messages.clear(); + mounted.clipboard.text = "other".into(); + mounted.event(paste()); + assert_eq!( + mounted.messages, + vec![Signal::Input("waterwater".into())], + "Iced reuses its first admitted paste while the key is held" + ); + mounted.messages.clear(); + let before = mounted.state().cursor(); + mounted.clipboard.text = "bad\tvalue".into(); + mounted.event(paste()); + assert_eq!(mounted.messages, vec![Signal::Rejected]); + assert_eq!(mounted.state().cursor(), before); + mounted.messages.clear(); + mounted.event(Event::Keyboard(keyboard::Event::KeyReleased { + key: Key::Character("v".into()), + modified_key: Key::Character("v".into()), + physical_key: Physical::Code(Code::KeyV), + location: keyboard::Location::Standard, + modifiers: Modifiers::empty(), + })); + mounted.clipboard.text = "later".into(); + mounted.event(paste()); + assert_eq!( + mounted.messages, + vec![Signal::Input("waterwaterlater".into())] + ); +} diff --git a/crates/oa-desktop/src/app/source_rename/editor_patch.rs b/crates/oa-desktop/src/app/source_rename/editor_patch.rs new file mode 100644 index 00000000..f30a08b8 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/editor_patch.rs @@ -0,0 +1,303 @@ +//! Display-only reuse after a guarded alias rename. This is not model authority. +use iced::{advanced::text::Renderer, widget::text_editor}; +use std::sync::Arc; + +/// Replace exact, unchanged-line-count source in an existing editor. +/// +/// The caller must first authorize/commit the model change and must reconstruct +/// from accepted source on false. Preflight failures do not mutate the editor; +/// a failed final readback can leave it changed. On changing success the cursor +/// is at 0:0 without selection; the caller restores its approved origin/reveal. +/// Direct Content actions deliberately bypass App draft edits and Undo history. +pub(super) fn replace( + editor: &mut text_editor::Content, + before: &str, + after: &str, +) -> bool { + if editor.text() != before { + return false; + } + let Some(old_lines) = lines(before) else { + return false; + }; + let Some(new_lines) = lines(after) else { + return false; + }; + if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() { + return false; + } + let mut patches = Vec::new(); + for (line, ((old, old_ending), (new, new_ending))) in + old_lines.into_iter().zip(new_lines).enumerate() + { + let Some(actual) = editor.line(line) else { + return false; + }; + // Cosmic also recognizes bare CR and LFCR. Comparing the actual lines + // catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too. + if old_ending != new_ending || actual.text != old || actual.ending != old_ending { + return false; + } + if old == new { + continue; + } + let mut start = old + .bytes() + .zip(new.bytes()) + .take_while(|(a, b)| a == b) + .count(); + while !old.is_char_boundary(start) || !new.is_char_boundary(start) { + start -= 1; + } + let mut suffix = old[start..] + .bytes() + .rev() + .zip(new[start..].bytes().rev()) + .take_while(|(a, b)| a == b) + .count(); + while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix) + { + suffix -= 1; + } + // All positions and owned payloads are prepared before the first edit. + patches.push(( + line, + start, + old.len() - suffix, + Arc::new(new[start..new.len() - suffix].to_owned()), + )); + } + if patches.is_empty() { + return true; + } + let empty = Arc::new(String::new()); + for (line, start, end, replacement) in patches.into_iter().rev() { + select(editor, line, start, end); + editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste( + replacement, + ))); + } + // Iced overwrites its earliest-change marker on each Edit. An explicit + // zero-width selection + empty Paste changes no bytes, but marks line zero. + // This also refreshes previously unhighlighted lines before the first patch. + // It resets only line zero's text cache; other unchanged lines retain theirs. + select(editor, 0, 0, 0); + editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty))); + editor.text() == after +} + +fn select( + editor: &mut text_editor::Content, + line: usize, + start: usize, + end: usize, +) { + editor.move_to(text_editor::Cursor { + position: text_editor::Position { + line, + column: start, + }, + // move_to(None) does not clear a previous selection in this Iced version. + selection: Some(text_editor::Position { line, column: end }), + }); +} + +fn lines(source: &str) -> Option> { + use text_editor::LineEnding; + let mut result = Vec::new(); + let mut parts = source.split('\n').peekable(); + while let Some(part) = parts.next() { + let (text, ending) = if parts.peek().is_none() { + (part, LineEnding::None) + } else if let Some(text) = part.strip_suffix('\r') { + (text, LineEnding::CrLf) + } else { + (part, LineEnding::Lf) + }; + if text.contains('\r') + || (text.is_empty() + && ending == LineEnding::CrLf + && result + .last() + .is_some_and(|(_, previous)| *previous == LineEnding::Lf)) + { + return None; + } + result.push((text, ending)); + } + Some(result) +} + +#[cfg(all(test, debug_assertions))] +mod tests { + use super::*; + use crate::views::source_tests::FontLayout; + use iced::advanced::{layout, text::Highlighter, widget::Tree}; + use iced::{Element, Font, Length, Size}; + use std::{cell::RefCell, ops::Range}; + + #[test] + fn exact_text_matrix_and_reverse_preserve_endings() { + for (before, after) in [ + ("", ""), + ("", "alias"), + ("alias", ""), + ("same", "same"), + ("a\n", "alias\n"), + ("a\r\n", "alias\r\n"), + ("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"), + ("\na\n\n", "\nlong_alias\n\n"), + ("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"), + ( + "雨🙂 e\u{301} @a @a // a", + "雨🙂 e\u{301} @a_long @a_long // a", + ), + ("@abc", "@abcdef"), + ("@abcdef", "@abc"), + ("@abc_suffix", "@def_suffix"), + ("@none", "@identifier"), + // Shared prefix/suffix bytes inside distinct UTF-8 codepoints must + // not be used as String split positions, even outside alias scope. + ("雨xé", "雪xê"), + ("é", "©"), + ( + "unchanged\n@a\nunchanged\n@a\n", + "unchanged\n@long\nunchanged\n@long\n", + ), + ] { + let mut editor = text_editor::Content::::with_text(before); + assert!( + replace(&mut editor, before, after), + "{before:?} -> {after:?}" + ); + assert_eq!(editor.text(), after); + let expected = text_editor::Content::::with_text(after); + assert_eq!(editor.line_count(), expected.line_count()); + for (actual, expected) in editor.lines().zip(expected.lines()) { + assert_eq!(actual.text, expected.text); + assert_eq!(actual.ending, expected.ending); + } + if before != after { + assert_eq!( + editor.cursor().position, + text_editor::Position { line: 0, column: 0 } + ); + assert_eq!(editor.cursor().selection, None); + } + assert!(replace(&mut editor, after, before)); + assert_eq!(editor.text(), before); + } + } + + #[test] + fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() { + for (actual, before, after) in [ + ("live", "stale", "changed"), + ("a\nb", "a\nb", "long\nb\nc"), + ("a\nb", "a\nb", "long\r\nb"), + // Refusal on a later line must not apply the earlier planned edit. + ("a\nb\r\n", "a\nb\r\n", "long\nb\n"), + ("a\rb", "a\rb", "long\rb"), + ("a\n\rb", "a\n\rb", "long\n\rb"), + ("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"), + // The replacement must not introduce a greedy LFCR boundary either. + ("a\nx\r\n", "a\nx\r\n", "a\n\r\n"), + ("a", "a", "long\r"), + ] { + let mut editor = text_editor::Content::::with_text(actual); + select(&mut editor, 0, 0, 1); + let cursor = editor.cursor(); + let selection = editor.selection(); + assert!( + !replace(&mut editor, before, after), + "{before:?} -> {after:?}" + ); + assert_eq!(editor.text(), actual); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.selection(), selection); + } + } + + #[test] + fn unchanged_content_preserves_selection_and_next_direct_edit_works() { + let mut editor = text_editor::Content::::with_text("@a"); + select(&mut editor, 0, 1, 2); + let cursor = editor.cursor(); + assert!(replace(&mut editor, "@a", "@a")); + assert_eq!(editor.cursor(), cursor); + assert!(replace(&mut editor, "@a", "@long")); + editor.move_to(text_editor::Cursor { + position: text_editor::Position { line: 0, column: 5 }, + selection: None, + }); + editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!'))); + assert_eq!(editor.text(), "@long!"); + } + + thread_local! { + static CHANGES: RefCell> = const { RefCell::new(Vec::new()) }; + } + + struct Tracked(crate::highlight::ArcadiaHighlighter); + impl Highlighter for Tracked { + type Settings = Arc; + type Highlight = oa_core::language::TokenClass; + type Iterator<'a> = std::vec::IntoIter<(Range, Self::Highlight)>; + fn new(settings: &Self::Settings) -> Self { + Self(crate::highlight::ArcadiaHighlighter::new(settings)) + } + fn update(&mut self, settings: &Self::Settings) { + self.0.update(settings); + } + fn change_line(&mut self, line: usize) { + CHANGES.with(|changes| changes.borrow_mut().push(line)); + self.0.change_line(line); + } + fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { + self.0.highlight_line(line) + } + fn current_line(&self) -> usize { + self.0.current_line() + } + } + + fn view( + editor: &text_editor::Content, + source: Arc, + ) -> Element<'_, (), iced::Theme, FontLayout> { + text_editor(editor) + .font(Font::MONOSPACE) + .size(14) + .height(Length::Fill) + .highlight_with::(source, crate::highlight::format) + .into() + } + + #[test] + fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() { + let before = "// earlier\n// unmodified\n@a"; + let after = "// earlier\n// unmodified\n@none"; + let mut editor = text_editor::Content::::with_text(before); + let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.)); + let mut tree = { + let mut widget = view(&editor, Arc::from(before)); + let mut tree = Tree::new(widget.as_widget()); + let _ = widget + .as_widget_mut() + .layout(&mut tree, &FontLayout, &limits); + tree + }; + CHANGES.with(|changes| changes.borrow_mut().clear()); + assert!(replace(&mut editor, before, after)); + { + let mut widget = view(&editor, Arc::from(after)); + let _ = widget + .as_widget_mut() + .layout(&mut tree, &FontLayout, &limits); + } + // Font/metrics/bounds are already stable. This notification must come + // from the helper's empty edit, not a fresh renderer initialization. + CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0])); + assert_eq!(editor.text(), after); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs new file mode 100644 index 00000000..d2b1011e --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs @@ -0,0 +1,450 @@ +//! 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}; + +mod palette_union; + +fn model_id(number: u8) -> Id { + Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap() +} + +fn navigation_fixture() -> App { + let mut source = bundle(); + let entry = source.files.get_mut("project.arcadia").unwrap(); + *entry = entry.replace( + "include \"uses.arcadia\";", + "include \"uses.arcadia\"; include \"views.arcadia\";", + ); + source.files.insert( + "views.arcadia".into(), + concat!( + "// Keep @pump and the exact distant placement.\r\n", + "diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n", + "node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n", + ).into(), + ); + let mut app = fixture(); + app.document = Document::from_bundle(source.clone(), LIMITS).unwrap(); + assert_eq!(app.document.bundle(), &source); + assert!( + !validate(app.project()) + .iter() + .any(|issue| issue.severity == validation::Severity::Error) + ); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + seek_pump(&mut app); + app.canvas_size = Some(Size::new(1200., 700.)); + app.zoom = 0.8; + app.pan = Vector::new(31., 47.); + app +} + +fn seek_pump(app: &mut App) { + let source = app.source_workspace.text(); + let offset = source.rfind("pump;").unwrap(); + app.source_editor + .move_to(cursor(source, offset, offset + 4)); +} + +fn source_completion(app: &App, request: nav::Request) -> nav::Completion { + let snapshot = Arc::new( + app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()), + ); + let results = snapshot + .query(query::Query { + stamp: request.stamp, + path: &request.path, + byte_offset: request.offset, + action: request.action, + }) + .unwrap(); + nav::Completion { + request, + result: Ok((snapshot, Arc::new(results))), + } +} + +fn diagram_completion(app: &App) -> usage::Completion { + let panel = app.diagram_usages.as_ref().unwrap(); + let process = app.behavior.topology.usage_snapshot().into_query_state(); + usage::Completion { + token: panel.token, + context: panel.context, + result: Ok(usage::collect( + app.project(), + panel.context.subject, + &process, + &behavior::scenario::State::default(), + )), + } +} + +fn finish_lookup(app: &mut App) -> (Id, Usage) { + let completion = diagram_completion(app); + send(app, Message::DiagramUsagesReady(completion)); + let panel = app.diagram_usages.as_ref().unwrap(); + assert!(!panel.loading); + assert!(app.diagram_lookup_in_flight.is_none()); + let found = panel + .usages + .iter() + .find(|u| { + u.surface == Surface::Saved(model_id(8)) + && u.target == UsageTarget::Placement(model_id(9)) + }) + .expect("the exact placed pump is represented") + .clone(); + assert_eq!(found.subject, model_id(2)); + assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.)); + (panel.token, found) +} + +fn navigate(app: &mut App) -> Usage { + let message = app.diagram_usage_message(model_id(2)); + send(app, message); + let (token, found) = finish_lookup(app); + send( + app, + Message::DiagramUsage(usage::Event::Choose(token, found.clone())), + ); + assert_eq!(app.page, Page::Diagram); + assert_eq!(app.active_diagram, Some(model_id(8))); + assert_eq!(app.selected, model_id(2)); + assert_eq!(app.selected_node, Some(model_id(9))); + assert!(app.diagram_usages.is_none()); + assert!(app.pending_diagram_reveal.is_some()); + assert!(app.diagram_focus(found.surface).is_some()); + found +} + +#[derive(Debug, PartialEq)] +struct Navigation { + page: Page, + selected: Id, + placement: Option, + diagram: Option, + zoom: f32, + pan: Vector, + pending: Option<(usage::Context, Usage, u64)>, + focus: Option<(usage::Context, Usage, Id)>, + canvas_epoch: u64, + camera_epoch: u64, + source_interaction: Id, + source_reveal: Option, +} + +fn navigation(app: &App) -> Navigation { + Navigation { + page: app.page, + selected: app.selected, + placement: app.selected_node, + diagram: app.active_diagram, + zoom: app.zoom, + pan: app.pan, + pending: app.pending_diagram_reveal.clone(), + focus: app + .diagram_navigation_focus + .as_ref() + .map(|f| (f.context, f.usage.clone(), f.selected)), + canvas_epoch: app.canvas_epoch, + camera_epoch: app.camera_epoch, + source_interaction: app.source_symbols.interaction, + source_reveal: app.source_reveal_ticket(), + } +} + +fn first_measurement(app: &mut App, found: &Usage) { + let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap(); + let (context, epoch) = (*context, *epoch); + let actual = Size::new(500., 300.); + send(app, Message::DiagramRevealViewport(context, epoch, actual)); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!(app.canvas_size, Some(actual)); + assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05); + assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05); + assert_eq!(app.selected, model_id(2)); + assert_eq!(app.selected_node, Some(model_id(9))); + assert!(app.diagram_focus(found.surface).is_some()); +} + +#[test] +fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() { + let mut app = navigation_fixture(); + let original = evidence(&app); + let snapshot = app.document.snapshot(); + let queued = app.diagram_usage_message(model_id(2)); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + let form = app.source_rename.form.as_ref().unwrap(); + let approved = ( + form.invocation, + form.generation, + form.approval.as_ref().unwrap().id, + ); + send(&mut app, queued); + let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages); + send(&mut app, source); + let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)); + send(&mut app, definition); + assert!(app.diagram_usages.is_none()); + assert!(app.diagram_lookup_in_flight.is_none()); + assert!(app.source_symbols.in_flight.is_none()); + assert!(!app.source_navigation_available()); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + ( + form.invocation, + form.generation, + form.approval.as_ref().unwrap().id + ), + approved + ); + assert_eq!(form.replacement, "send_water"); + assert_eq!(evidence(&app), original); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + send(&mut app, Message::Escape); + send(&mut app, confirm); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), original); +} + +#[test] +fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() { + for finish in [false, true] { + let mut app = navigation_fixture(); + let before = evidence(&app); + let queued_rename = app.source_rename_message(); + let open = app.diagram_usage_message(model_id(2)); + send(&mut app, open); + if finish { + finish_lookup(&mut app); + } + let panel = app.diagram_usages.as_ref().unwrap(); + let token = panel.token; + let loading = panel.loading; + assert!(app.rename_admission().is_err()); + assert!(!app.source_navigation_available()); + send(&mut app, queued_rename); + assert!(!app.source_rename.open()); + assert!(app.source_rename.in_flight.is_none()); + let panel = app.diagram_usages.as_ref().unwrap(); + assert_eq!((panel.token, panel.loading), (token, loading)); + assert_eq!(evidence(&app), before); + send(&mut app, Message::DiagramUsage(usage::Event::Close(token))); + let rename = app.source_rename_message(); + send(&mut app, rename); + assert!( + app.source_rename.open(), + "the dismissed chooser no longer owns input" + ); + } +} + +#[test] +fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() { + for preview_pending in [false, true] { + let mut app = navigation_fixture(); + let before = evidence(&app); + let completion = if preview_pending { + begin(&mut app); + input(&mut app, "send_water"); + let job = start_preview(&mut app); + preview_completion(&app, job) + } else { + let rename = app.source_rename_message(); + send(&mut app, rename); + target_completion(&app, current_job(&app)) + }; + let occupied = completion.job.id; + event(&mut app, Event::Close(completion.job.invocation)); + assert_eq!(app.source_rename.in_flight, Some(occupied)); + let found = navigate(&mut app); + let location = navigation(&app); + assert_eq!(evidence(&app), before); + let mut forged = completion.clone(); + forged.job.id = Id::new_v4(); + send(&mut app, Message::SourceRenameReady(forged)); + assert_eq!(app.source_rename.in_flight, Some(occupied)); + assert_eq!(navigation(&app), location); + send(&mut app, Message::SourceRenameReady(completion.clone())); + assert!(app.source_rename.in_flight.is_none()); + assert!(!app.source_rename.open()); + assert_eq!(navigation(&app), location); + assert_eq!(evidence(&app), before); + send(&mut app, Message::SourceRenameReady(completion)); + assert_eq!( + navigation(&app), + location, + "duplicate completion is passive too" + ); + first_measurement(&mut app, &found); + assert_eq!(evidence(&app), before); + } +} + +#[test] +fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged() +{ + let mut app = navigation_fixture(); + let before = evidence(&app); + let snapshot = app.document.snapshot(); + let queued_begin = app.source_rename_message(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(invocation)); + let found = navigate(&mut app); + let location = navigation(&app); + for stale in [ + queued_begin, + Message::SourceRename(Event::Close(invocation)), + confirm, + ] { + send(&mut app, stale); + assert_eq!(navigation(&app), location); + assert_eq!(evidence(&app), before); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + } + first_measurement(&mut app, &found); +} + +#[test] +fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() { + let mut app = navigation_fixture(); + let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)); + send(&mut app, source); + let request = app.source_symbols.request.clone().unwrap(); + let source_ready = source_completion(&app, request.clone()); + let rename = app.source_rename_message(); + send(&mut app, rename); + let rename_ready = target_completion(&app, current_job(&app)); + let rename_job = rename_ready.job.id; + event(&mut app, Event::Close(rename_ready.job.invocation)); + let open = app.diagram_usage_message(model_id(2)); + send(&mut app, open); + let old_diagram = diagram_completion(&app); + let diagram_job = old_diagram.token; + let old_document = app.canvas_document; + let project = app.project().clone(); + app.reset(project, None); + assert_ne!(app.canvas_document, old_document); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.source_rename.in_flight, Some(rename_job)); + assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job)); + assert!(!app.source_rename.open()); + assert!(app.diagram_usages.is_none()); + let before = evidence(&app); + let snapshot = app.document.snapshot(); + let new_open = app.diagram_usage_message(model_id(2)); + send(&mut app, new_open); + let new_token = app.diagram_usages.as_ref().unwrap().token; + assert_ne!(new_token, diagram_job); + assert_eq!( + app.diagram_lookup_in_flight, + Some(diagram_job), + "replacement waits for old worker" + ); + send(&mut app, Message::SourceRenameReady(rename_ready)); + assert!(app.source_rename.in_flight.is_none()); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job)); + send(&mut app, Message::SourceSymbolsReady(source_ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(!app.source_symbols.open); + send(&mut app, Message::DiagramUsagesReady(old_diagram)); + assert_eq!(app.diagram_lookup_in_flight, Some(new_token)); + assert!(app.diagram_usages.as_ref().unwrap().loading); + finish_lookup(&mut app); + assert_eq!(evidence(&app), before); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert!(!app.source_rename.open()); +} + +#[test] +fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() { + let mut app = navigation_fixture(); + let baseline = app.document.snapshot(); + let project = app.project().id; + app.document + .execute(Command::Rename(project, "Previously accepted name".into())) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + seek_pump(&mut app); + let before = app.document.snapshot(); + let mut expected_project = app.project().clone(); + expected_project + .elements + .get_mut(&model_id(2)) + .unwrap() + .alias = "send_water".into(); + let mut expected_source = app.document.bundle().clone(); + let defs = expected_source.files.get_mut("defs.arcadia").unwrap(); + *defs = defs.replace("oa_activity pump ", "oa_activity send_water "); + let uses = expected_source.files.get_mut("uses.arcadia").unwrap(); + *uses = uses + .replace("source @pump;", "source @send_water;") + .replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;"); + let views = expected_source.files.get_mut("views.arcadia").unwrap(); + *views = views.replace("element @pump;", "element @send_water;"); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + assert_eq!(app.project(), &expected_project); + assert_eq!(app.document.bundle(), &expected_source); + let renamed = app.document.snapshot(); + let accepted = evidence(&app); + let queued_undo_rename = app + .source_rename_undo_message() + .expect("fresh rename offers its guarded undo"); + let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages); + send(&mut app, message); + let request = app + .source_symbols + .request + .clone() + .expect("renamed exact caret remains queryable"); + let ready = source_completion(&app, request); + send(&mut app, Message::SourceSymbolsReady(ready)); + let (token, found) = finish_lookup(&mut app); + send( + &mut app, + Message::DiagramUsage(usage::Event::Choose(token, found.clone())), + ); + assert_eq!(app.selected, model_id(2)); + assert_eq!(app.selected_node, Some(model_id(9))); + let destination = navigation(&app); + send(&mut app, queued_undo_rename); + assert_eq!(navigation(&app), destination); + assert_eq!(evidence(&app), accepted); + first_measurement(&mut app, &found); + assert_eq!(evidence(&app), accepted); + assert!(Arc::ptr_eq(&renamed, &app.document.snapshot())); + for (message, expected) in [ + (Message::Undo, before.clone()), + (Message::Redo, renamed.clone()), + (Message::Undo, before.clone()), + (Message::Undo, baseline.clone()), + (Message::Redo, before), + (Message::Redo, renamed), + ] { + send(&mut app, message); + assert!( + Arc::ptr_eq(&expected, &app.document.snapshot()), + "exact history entry restored" + ); + assert_eq!(app.project(), &expected.decoded.project); + assert_eq!(app.document.bundle(), &expected.bundle); + assert_eq!(app.source_workspace.bundle(), expected.bundle); + assert!(!app.source_dirty()); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs new file mode 100644 index 00000000..8aee5cf8 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs @@ -0,0 +1,72 @@ +//! Interactions absent from the independently published navigation branch. +use super::*; + +#[test] +fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() { + let mut app = navigation_fixture(); + let before = app.document.snapshot(); + let notify = || Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary); + send(&mut app, notify()); + let found = navigate(&mut app); + let destination = navigation(&app); + let allocation_epoch = app.allocation_drag.epoch; + send(&mut app, notify()); + assert_eq!(navigation(&app), destination); + assert_eq!(app.allocation_drag.epoch, allocation_epoch); + first_measurement(&mut app, &found); + send(&mut app, notify()); + assert!(app.diagram_focus(found.surface).is_some()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), before.bundle); +} + +#[test] +fn stale_rename_begin_cannot_retire_allocation_epoch_or_new_navigation() { + let mut app = navigation_fixture(); + let captured = app.source_rename_begin(); + let found = navigate(&mut app); + let destination = navigation(&app); + let allocation_epoch = app.allocation_drag.epoch; + let accepted = app.document.snapshot(); + send(&mut app, Message::SourceRename(Event::Begin(captured))); + assert_eq!(navigation(&app), destination); + assert_eq!(app.allocation_drag.epoch, allocation_epoch); + assert!(!app.source_rename.open()); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + first_measurement(&mut app, &found); +} + +#[test] +fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() { + let mut project = Project::empty(); + let scenario = project + .add_element(Kind::Scenario, Layer::Oa, "Prior scenario", None) + .unwrap(); + let mut previous = App::with_project(project); + previous.page = Page::Behavior; + previous.behavior.active_owner = Some(scenario); + previous + .behavior + .scenario + .enter(previous.canvas_document, scenario); + let scope = behavior::canvas_workspace::Scope::input_owner(&previous).unwrap(); + let captured = behavior::canvas_workspace::intent( + scope, + "Fit", + Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Zoom( + previous.scenario_context().unwrap(), + None, + )), + ); + + let mut app = navigation_fixture(); + let found = navigate(&mut app); + let destination = navigation(&app); + let allocation_epoch = app.allocation_drag.epoch; + let accepted = app.document.snapshot(); + send(&mut app, captured); + assert_eq!(navigation(&app), destination); + assert_eq!(app.allocation_drag.epoch, allocation_epoch); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + first_measurement(&mut app, &found); +} diff --git a/crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs b/crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs new file mode 100644 index 00000000..90b38641 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/preview_glyph_tests.rs @@ -0,0 +1,449 @@ +//! Real paragraph advances and clipping through the actual preview dialog. +//! Native screenshots additionally check raster ink and the platform renderer. +use super::*; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, layout, renderer, text as shaped, widget}; +use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector}; +use std::collections::{BTreeMap, BTreeSet}; + +struct Glyph { + text: String, + paragraph_y: u32, + line: usize, + bytes: std::ops::Range, + bounds: Rectangle, + clip: Rectangle, +} + +struct GlyphRecorder { + wanted: BTreeSet, + transforms: Vec, + clips: Vec, + glyphs: Vec, + quads: Vec, +} +impl GlyphRecorder { + fn new(wanted: BTreeSet, viewport: Rectangle) -> Self { + Self { + wanted, + transforms: vec![Transformation::IDENTITY], + clips: vec![viewport], + glyphs: vec![], + quads: vec![], + } + } + fn transform(&self) -> Transformation { + *self.transforms.last().unwrap() + } + fn clip(&self, bounds: Rectangle) -> Rectangle { + self.clips + .last() + .unwrap() + .intersection(&(bounds * self.transform())) + .unwrap_or(Rectangle::with_size(Size::ZERO)) + } +} +impl renderer::Renderer for GlyphRecorder { + fn start_layer(&mut self, bounds: Rectangle) { + self.clips.push(self.clip(bounds)); + } + fn end_layer(&mut self) { + self.clips.pop().unwrap(); + } + fn start_transformation(&mut self, transform: Transformation) { + self.transforms.push(self.transform() * transform); + } + fn end_transformation(&mut self) { + self.transforms.pop().unwrap(); + } + fn reset(&mut self, bounds: Rectangle) { + self.transforms = vec![Transformation::IDENTITY]; + self.clips = vec![bounds]; + self.glyphs.clear(); + self.quads.clear(); + } + fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into) { + self.quads.push(quad.bounds * self.transform()); + } + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} +impl shaped::Renderer for GlyphRecorder { + type Font = Font; + type Paragraph = ::Paragraph; + type Editor = ::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::ICED_LOGO; + fn default_font(&self) -> Font { + shaped::Renderer::default_font(&FontLayout) + } + fn default_size(&self) -> Pixels { + shaped::Renderer::default_size(&FontLayout) + } + fn fill_paragraph( + &mut self, + paragraph: &Self::Paragraph, + position: Point, + _: Color, + clip: Rectangle, + ) { + let buffer = paragraph.buffer(); + let text = buffer + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n"); + if !self.wanted.contains(&text) { + return; + } + let clip = self.clip(clip); + for run in buffer.layout_runs() { + for glyph in run.glyphs { + if run.text[glyph.start..glyph.end] + .chars() + .all(char::is_whitespace) + { + continue; + } + let bounds = Rectangle { + x: position.x + glyph.x + glyph.x_offset * glyph.font_size, + y: position.y + run.line_top, + width: glyph.w, + height: run.line_height, + } * self.transform(); + self.glyphs.push(Glyph { + text: text.clone(), + paragraph_y: position.y.to_bits(), + line: run.line_i, + bytes: glyph.start..glyph.end, + bounds, + clip, + }); + } + } + } + fn fill_editor( + &mut self, + editor: &Self::Editor, + position: Point, + color: Color, + clip: Rectangle, + ) { + shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip); + } + fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) { + shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip); + } +} + +#[derive(Default)] +struct ScrollGeometry { + id: Option, + bounds: Option, + content: Option, + offset: Vector, +} +impl widget::Operation for ScrollGeometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + content: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == self.id.as_ref() { + self.bounds = Some(bounds); + self.content = Some(content); + self.offset = offset; + } + } +} +fn normalized(text: &str) -> String { + text.replace("\r\n", "\n").replace('\r', "\n") +} +fn required_chars(text: &str) -> BTreeSet<(usize, usize)> { + text.split('\n') + .enumerate() + .flat_map(|(line, text)| { + text.char_indices() + .filter(|(_, ch)| !ch.is_whitespace()) + .map(move |(byte, _)| (line, byte)) + }) + .collect() +} + +fn declarations(long: bool) -> App { + let name = if long { + format!("Assess {} request", "界🚀".repeat(90)) + } else { + "Assess 救援 request".into() + }; + let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ + ("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()), + ("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")), + ]) }; + let mut app = fixture(); + app.document = Document::from_bundle(source, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.source_workspace.active = "救援/assess.arcadia".into(); + app.selected = app.project().id; + app.refresh_draft(); + app.show_source_file(false); + let start = app + .source_workspace + .text() + .find("oa_activity assess") + .unwrap() + + "oa_activity ".len(); + app.source_editor.move_to(cursor( + app.source_workspace.text(), + start, + start + "assess".len(), + )); + begin(&mut app); + let replacement = if long { + "a".repeat(255) + } else { + "assess_damage".into() + }; + input(&mut app, &replacement); + let _ = preview(&mut app); + app +} + +fn assert_page_glyphs_visible(app: &App, host: Size) { + let form = app.source_rename.form.as_ref().unwrap(); + let ready = form.approval.as_ref().unwrap(); + let file = &ready.preview.files()[form.file]; + let mut expected = BTreeMap::::new(); + for line in file.hunks()[form.hunk] + .lines() + .iter() + .skip(form.line) + .take(LINES_PER_PAGE) + { + for source in [ + line.before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap(), + line.after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap(), + ] { + if let Some(fragment) = preview::text_segment(source, form.segment) { + let text = normalized(fragment); + if !required_chars(&text).is_empty() { + *expected.entry(text).or_default() += 1; + } + } + } + } + assert!(!expected.is_empty()); + let viewport = Rectangle::new(Point::ORIGIN, host); + let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport); + let editor = text_editor::Content::::new(); + let mut view = dialog_content(app, &editor); + let mut tree = widget::Tree::new(view.as_widget()); + let node = + view.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host)); + let mut geometry = ScrollGeometry { + id: Some(preview_scroll_id(form, ready.id)), + ..ScrollGeometry::default() + }; + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut geometry); + let bounds = geometry.bounds.unwrap(); + let content = geometry.content.unwrap(); + assert!(bounds.height >= 40.); + let maximum = (content.height - bounds.height).max(0.); + let steps = (maximum / (bounds.height * 0.5)).ceil() as usize; + let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new(); + let mut reserved_bar_gap = true; + for step in 0..=steps { + let offset = (step as f32 * bounds.height * 0.5).min(maximum); + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &renderer, + &mut widget::operation::scrollable::scroll_to( + preview_scroll_id(form, ready.id), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(offset), + }, + ), + ); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut geometry); + assert!( + (geometry.offset.y - offset).abs() <= 0.5, + "actual scroll operation reaches the requested source offset" + ); + renderer::Renderer::reset(&mut renderer, viewport); + view.as_widget().draw( + &tree, + &mut renderer, + &iced::Theme::Light, + &renderer::Style::default(), + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + // The vertical scrollbar's actual painted thumb fixes its excluded x + // region; the outer scroll clip alone cannot detect overpainting. + let scrollbar_left = renderer + .quads + .iter() + .filter(|quad| { + quad.width > 0. + && quad.width <= 16. + && quad.height > 0. + && quad.x >= bounds.x + bounds.width - 18. + && quad.x + quad.width <= bounds.x + bounds.width + 0.5 + && quad.y >= bounds.y - 0.5 + && quad.y + quad.height <= bounds.y + bounds.height + 0.5 + }) + .map(|quad| quad.x) + .min_by(f32::total_cmp); + if maximum > 0. { + let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar"); + reserved_bar_gap &= content.x + content.width <= left - 7.5; + } + for glyph in &renderer.glyphs { + let right = glyph.bounds.x + glyph.bounds.width; + if let Some(left) = scrollbar_left { + assert!( + right <= left - 0.5, + "source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}", + glyph.text, + glyph.bytes, + glyph.bounds + ); + } + assert!( + glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5, + "source glyph escaped reserved width: {:?} {:?}", + glyph.text, + glyph.bounds + ); + assert!( + glyph.bounds.x >= glyph.clip.x - 0.5 + && right <= glyph.clip.x + glyph.clip.width + 0.5, + "source glyph horizontally clipped: {:?} {:?} {:?}", + glyph.text, + glyph.bounds, + glyph.clip + ); + if glyph.bounds.y >= glyph.clip.y - 0.5 + && glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5 + { + let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap(); + let chars = logical_line + .char_indices() + .filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace()) + .map(|(byte, _)| (glyph.line, byte)); + visible + .entry((glyph.text.clone(), glyph.paragraph_y)) + .or_default() + .extend(chars); + } + } + } + assert!( + reserved_bar_gap, + "source content must reserve actual scrollbar width plus gap" + ); + assert!( + bounds.width >= host.width - 30., + "preview must use available dialog width: {bounds:?} / {host:?}" + ); + for (text, count) in expected { + let required = required_chars(&text); + let instances: Vec<_> = visible + .iter() + .filter(|((value, _), _)| value == &text) + .collect(); + assert_eq!( + instances.len(), + count, + "all before/after occurrences must be seen: {text:?}" + ); + for (_, seen) in instances { + assert_eq!( + seen, &required, + "every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}" + ); + } + } +} + +#[test] +fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() { + for long in [false, true] { + let mut app = declarations(long); + let ready = app + .source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .clone(); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + let before = evidence(&app); + for (width, height) in [(1100., 700.), (580., 700.)] { + app.window_size = Size::new(width, height); + let host = Size::new((width - 84.).min(708.), height - 120.); + let total = segment_total(app.source_rename.form.as_ref().unwrap()); + if long { + assert!( + total >= 3, + "include unbroken ASCII and wide Unicode across segments" + ); + } + for segment in 0..total { + assert_page_glyphs_visible(&app, host); + if segment + 1 < total { + event(&mut app, Event::Segment(invocation, ready.id, true)); + } + } + for _ in 1..total { + event(&mut app, Event::Segment(invocation, ready.id, false)); + } + } + assert_eq!( + evidence(&app), + before, + "preview geometry/paging cannot alter transaction inputs" + ); + assert_eq!( + app.source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .id, + ready.id + ); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs new file mode 100644 index 00000000..4adb3bf4 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs @@ -0,0 +1,385 @@ +//! Execute the controller's scroll operations on a retained, scrolled dialog. +use super::*; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, layout, widget}; +use iced::futures::StreamExt; +use iced::{Rectangle, Size, Vector}; + +const HOST: Size = Size::new(720., 660.); + +fn paged_fixture() -> App { + let mut source = bundle(); + let name = "Long engineering name 雨 ".repeat(18); + source.files.get_mut("defs.arcadia").unwrap().replace_range( + .., + &format!( + "oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\ + oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n" + ), + ); + let mut uses = String::new(); + for index in 0..16 { + if index == 12 { + uses.push_str(&"// unchanged gap\r\n".repeat(10)); + } + uses.push_str(&format!( + "interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n" + )); + } + source.files.insert("uses.arcadia".into(), uses); + let mut app = fixture(); + app.window_size = HOST; + app.document = Document::from_bundle(source, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len(); + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + begin(&mut app); + input(&mut app, "pump_long"); + let _ = preview(&mut app); + app +} + +fn scroll_id(app: &App) -> widget::Id { + let form = app.source_rename.form.as_ref().unwrap(); + preview_scroll_id(form, form.approval.as_ref().unwrap().id) +} + +struct Geometry { + id: widget::Id, + offset: Option, +} +impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == Some(&self.id) { + self.offset = Some(offset); + } + } +} + +struct Mounted { + editor: text_editor::Content, + tree: widget::Tree, +} +impl Mounted { + fn new(app: &App) -> Self { + let editor = text_editor::Content::::new(); + let tree = widget::Tree::new(dialog_content(app, &editor).as_widget()); + Self { editor, tree } + } + fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) { + let mut view = dialog_content(app, &self.editor); + // Preserve the actual scroll state across the new page's view diff. + self.tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut self.tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, HOST), + ); + view.as_widget_mut() + .operate(&mut self.tree, Layout::new(&node), &FontLayout, operation); + } + fn offset(&mut self, app: &App) -> f32 { + let mut geometry = Geometry { + id: scroll_id(app), + offset: None, + }; + self.operate(app, &mut geometry); + geometry.offset.expect("mounted preview scrollable").y + } + fn scroll_down(&mut self, app: &App) -> f32 { + self.operate( + app, + &mut widget::operation::scrollable::scroll_to( + scroll_id(app), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(10_000.), + }, + ), + ); + let offset = self.offset(app); + assert!(offset > 10., "fixture must really be scrolled"); + offset + } + async fn execute(&mut self, app: &App, task: Task) -> usize { + let mut count = 0; + if let Some(mut actions) = iced_runtime::task::into_stream(task) { + while let Some(action) = actions.next().await { + let iced_runtime::Action::Widget(mut operation) = action else { + panic!("preview paging must only schedule a widget operation"); + }; + self.operate(app, operation.as_mut()); + count += 1; + } + } + count + } +} + +#[tokio::test] +async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + mounted.scroll_down(&app); + let old_id = scroll_id(&app); + let task = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + assert_ne!(old_id, scroll_id(&app)); + // Negative control: even after diff/layout with the new id and new file, + // Iced retains the previous offset until the returned operation executes. + assert!(mounted.offset(&app) > 10.); + assert_eq!(mounted.execute(&app, task).await, 1); + assert_eq!(mounted.offset(&app), 0.); + + for navigation in [ + Event::Line(invocation, approval, true), + Event::Segment(invocation, approval, true), + Event::Hunk(invocation, approval, true), + Event::File(invocation, approval, false), + Event::Search(invocation, "uses".into()), + Event::Search(invocation, "no-path-match".into()), + Event::Search(invocation, String::new()), + ] { + mounted.scroll_down(&app); + let task = app.update(Message::SourceRename(navigation)); + assert_eq!(mounted.execute(&app, task).await, 1); + assert_eq!(mounted.offset(&app), 0.); + assert_eq!(evidence(&app), before); + } + // Passive redraw and unchanged filter input preserve deliberate scrolling. + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::Search( + invocation, + String::new(), + ))); + assert_eq!(mounted.execute(&app, task).await, 0); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::Input( + invocation, + "pump_long".into(), + ))); + assert_eq!(mounted.execute(&app, task).await, 0); + assert_eq!(mounted.offset(&app), offset); + let task = app.update(Message::SourceRename(Event::File( + invocation, approval, false, + ))); + assert_eq!( + mounted.execute(&app, task).await, + 0, + "clamped first-page request is a no-op" + ); + assert_eq!(mounted.offset(&app), offset); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let old_approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + let old_page_task = app.update(Message::SourceRename(Event::File( + invocation, + old_approval, + true, + ))); + let next_page_task = app.update(Message::SourceRename(Event::Line( + invocation, + old_approval, + true, + ))); + assert_eq!(mounted.execute(&app, next_page_task).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_page_task).await, 1); + assert_eq!(mounted.offset(&app), offset, "old page target is absent"); + + let old_approval_task = app.update(Message::SourceRename(Event::Search( + invocation, + "defs".into(), + ))); + input(&mut app, "pump_new"); + let _ = preview(&mut app); // Real new approval in the same invocation/page. + assert_ne!( + app.source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .id, + old_approval + ); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_approval_task).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old approval target is absent" + ); + let task = app.update(Message::SourceRename(Event::File( + invocation, + old_approval, + true, + ))); + assert_eq!( + mounted.execute(&app, task).await, + 0, + "stale envelope is rejected before scheduling" + ); + assert_eq!(mounted.offset(&app), offset); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + + let first_visit = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + let next_page = app.update(Message::SourceRename(Event::Line( + invocation, approval, true, + ))); + assert_eq!(mounted.execute(&app, next_page).await, 1); + let return_visit = app.update(Message::SourceRename(Event::Line( + invocation, approval, false, + ))); + assert_eq!(mounted.execute(&app, return_visit).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, first_visit).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old visit to the identical page is stale" + ); + + let old_filter = app.update(Message::SourceRename(Event::Search( + invocation, + "uses".into(), + ))); + let new_filter = app.update(Message::SourceRename(Event::Search( + invocation, + "us".into(), + ))); + assert_eq!(mounted.execute(&app, new_filter).await, 1); + let offset = mounted.scroll_down(&app); + assert_eq!(mounted.execute(&app, old_filter).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "old filter on the same page is stale" + ); + + input(&mut app, "pump_new"); + let job = start_preview(&mut app); + let completion = preview_completion(&app, job); + // No intermediate frame mounts the temporary no-approval view. The old + // Scrollable survives even though the new approval has a different id. + let ready = app.update(Message::SourceRenameReady(completion)); + assert!(mounted.offset(&app) > 10.); + assert_eq!(mounted.execute(&app, ready).await, 1); + assert_eq!(mounted.offset(&app), 0.); + assert_eq!(evidence(&app), before); +} + +#[tokio::test] +async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll() +{ + // Exercise both an actually mounted Details view and a fast round trip + // between frames, where Iced retains the same preview Scrollable state. + for mount_details in [true, false] { + let mut app = paged_fixture(); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let approval = form.approval.as_ref().unwrap().id; + let mut mounted = Mounted::new(&app); + mounted.scroll_down(&app); + let held_reset = app.update(Message::SourceRename(Event::File( + invocation, approval, true, + ))); + let old_preview = scroll_id(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let page = (form.file, form.hunk, form.line, form.segment); + + let show_details = app.update(Message::SourceRename(Event::Details(invocation, true))); + assert_eq!(mounted.execute(&app, show_details).await, 0); + let details_visit = scroll_id(&app); + assert_ne!(details_visit, old_preview); + if mount_details { + let mut hidden = Geometry { + id: old_preview.clone(), + offset: None, + }; + mounted.operate(&app, &mut hidden); + assert!( + hidden.offset.is_none(), + "Details actually replaced the preview" + ); + } + let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true))); + assert_eq!(mounted.execute(&app, repeat_details).await, 0); + assert_eq!( + scroll_id(&app), + details_visit, + "unchanged visibility keeps its visit" + ); + + let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false))); + assert_eq!( + mounted.execute(&app, show_preview).await, + 0, + "returning does not force a scroll reset" + ); + let returned_visit = scroll_id(&app); + assert_ne!(returned_visit, old_preview); + assert_ne!(returned_visit, details_visit); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!((form.file, form.hunk, form.line, form.segment), page); + assert_eq!(form.approval.as_ref().unwrap().id, approval); + + let offset = mounted.scroll_down(&app); + let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false))); + assert_eq!(mounted.execute(&app, repeat_preview).await, 0); + assert_eq!(scroll_id(&app), returned_visit); + assert_eq!( + mounted.offset(&app), + offset, + "repeated Preview preserves deliberate scroll" + ); + assert_eq!(mounted.execute(&app, held_reset).await, 1); + assert_eq!( + mounted.offset(&app), + offset, + "the previous visit cannot reset the returned preview" + ); + assert_eq!(evidence(&app), before); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/tests.rs b/crates/oa-desktop/src/app/source_rename/tests.rs new file mode 100644 index 00000000..2b90ec6e --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename/tests.rs @@ -0,0 +1,1427 @@ +use super::*; +use oa_core::source_project::SourceBundle; +use std::collections::BTreeMap; + +#[path = "navigation_union_tests.rs"] +mod navigation_union_tests; + +#[cfg(debug_assertions)] +#[path = "preview_glyph_tests.rs"] +mod preview_glyph_tests; + +#[cfg(debug_assertions)] +#[path = "preview_scroll_tests.rs"] +mod preview_scroll_tests; + +fn bundle() -> SourceBundle { + SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ + ("project.arcadia".into(), concat!("arcadia 7;\r\nproject model \"救援\" id \"00000000-0000-0000-0000-000000000001\" {\r\n", + " include \"defs.arcadia\"; include \"uses.arcadia\";\r\n}\r\n").into()), + ("defs.arcadia".into(), concat!("oa_activity pump \"送水 🚀\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n", + "oa_activity backup \"送水 🚀\" id \"00000000-0000-0000-0000-000000000004\" {}\r\n").into()), + ("uses.arcadia".into(), concat!("// keep @pump and Unicode 雨\r\n", + "interaction first \"先\" id \"00000000-0000-0000-0000-000000000003\" { description \"literal @pump\"; source @pump; target @backup; } ", + "interaction second \"後\" id \"00000000-0000-0000-0000-000000000005\" { source @ // keep @pump\r\n pump; target @backup; }\r\n").into()), + ]) } +} + +fn fixture() -> App { + let mut app = App::with_project(Project::empty()); + app.document = Document::from_bundle(bundle(), LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.page = Page::Text; + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + let start = app.source_workspace.text().rfind("pump;").unwrap(); + let cursor = cursor(app.source_workspace.text(), start, start + 4); + app.source_editor.move_to(cursor); + app +} + +fn cursor(text: &str, start: usize, end: usize) -> text_editor::Cursor { + let pos = |offset| { + let prefix = &text[..offset]; + text_editor::Position { + line: prefix.bytes().filter(|b| *b == b'\n').count(), + column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + } + }; + text_editor::Cursor { + position: pos(start), + selection: Some(pos(end)), + } +} + +fn send(app: &mut App, message: Message) { + // Drop unpolled Tasks; deterministic completion still travels through the + // real update route. These tests never launch an application or file worker. + let _ = app.update(message); +} + +fn event(app: &mut App, event: Event) { + send(app, Message::SourceRename(event)); +} + +fn current_job(app: &App) -> Job { + let form = app.source_rename.form.as_ref().unwrap(); + Job { + id: form.pending.unwrap(), + invocation: form.invocation, + generation: form.generation, + context: form.context.clone(), + } +} + +fn target_completion(app: &App, job: Job) -> Completion { + let snapshot = app + .with_source_input(|input| query::capture(input, query::Limits::default())) + .unwrap(); + let offset = nav::cursor_offset( + &job.context.accepted.bundle.files[&job.context.begin.path], + job.context.begin.cursor, + ) + .unwrap(); + let result = resolve_target(&snapshot, &job.context, offset).map(Arc::new); + Completion { + job, + payload: Payload::Target(result), + } +} + +fn begin(app: &mut App) { + let message = app.source_rename_message(); + send(app, message); + let ready = target_completion(app, current_job(app)); + send(app, Message::SourceRenameReady(ready)); + assert!(app.source_rename.form.as_ref().unwrap().target.is_some()); +} + +fn input(app: &mut App, replacement: &str) { + let id = app.source_rename.form.as_ref().unwrap().invocation; + event(app, Event::Input(id, replacement.into())); +} + +fn preview_completion(app: &App, job: Job) -> Completion { + let form = app.source_rename.form.as_ref().unwrap(); + let target = form.target.as_ref().unwrap(); + let result = app + .document + .alias_rename_request(target.id, form.replacement.clone()) + .unwrap() + .prepare() + .map_err(|error| error.to_string()) + .and_then(|prepared| prepare_approval(prepared, target, &form.context.begin.path)) + .map(Arc::new); + Completion { + job, + payload: Payload::Preview(result), + } +} + +fn start_preview(app: &mut App) -> Job { + let form = app.source_rename.form.as_ref().unwrap(); + let message = Event::Preview(form.invocation, form.generation); + event(app, message); + current_job(app) +} + +fn preview(app: &mut App) -> Message { + let job = start_preview(app); + let completion = preview_completion(app, job); + send(app, Message::SourceRenameReady(completion)); + let form = app.source_rename.form.as_ref().unwrap(); + Message::SourceRename(Event::Confirm( + form.invocation, + form.generation, + form.approval.as_ref().unwrap().id, + )) +} + +#[derive(Debug, PartialEq)] +struct Evidence { + project: Project, + source: SourceBundle, + drafts: SourceBundle, + revision: u64, + workspace: query::SnapshotKey, + cursor: text_editor::Cursor, + undo: bool, + redo: bool, + dirty: bool, +} + +fn evidence(app: &App) -> Evidence { + Evidence { + project: app.project().clone(), + source: app.document.bundle().clone(), + drafts: app.source_workspace.bundle(), + revision: app.document.revision(), + workspace: app.source_key(), + cursor: app.source_editor.cursor(), + undo: app.document.can_undo(), + redo: app.document.can_redo(), + dirty: app.document.dirty(), + } +} + +fn assert_accepted_display(app: &App, accepted: &SourceBundle) { + let active = &accepted.files[&app.source_workspace.active]; + assert_eq!( + app.source_editor.text(), + *active, + "displayed accepted source" + ); + assert_eq!( + app.source_snapshot.as_ref(), + active.as_str(), + "snapshot for highlighting" + ); + assert_eq!(app.source_workspace.text(), active, "active workspace text"); + assert_eq!( + app.source_workspace.bundle(), + *accepted, + "all accepted workspace files" + ); + assert!( + !app.source_workspace.dirty(), + "display synchronization creates no source draft" + ); +} + +#[test] +fn complete_alias_change_preserves_literals_and_last_comment_separated_origin_and_undo() { + let mut app = fixture(); + let original = evidence(&app); + let mut expected_model = original.project.clone(); + let id = Id::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); + expected_model.elements.get_mut(&id).unwrap().alias = "send_water".into(); + let mut expected_source = original.source.clone(); + expected_source + .files + .get_mut("defs.arcadia") + .unwrap() + .replace_range(12..16, "send_water"); + let uses = expected_source.files.get_mut("uses.arcadia").unwrap(); + *uses = uses + .replace("source @pump;", "source @send_water;") + .replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;"); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + assert_eq!(evidence(&app), original, "preview is not Apply"); + send(&mut app, confirm.clone()); + assert!(!app.source_rename.open()); + assert_eq!(app.project(), &expected_model); + assert_eq!(app.document.bundle(), &expected_source); + assert_accepted_display(&app, &expected_source); + let text = &expected_source.files["uses.arcadia"]; + let start = text.rfind("send_water;").unwrap(); + assert_eq!( + app.source_editor.cursor(), + cursor(text, start, start + "send_water".len()) + ); + let accepted = evidence(&app); + send(&mut app, confirm); + assert_eq!(evidence(&app), accepted, "repeated approval has no effect"); + // A plain caret movement is not a newer model operation. + send( + &mut app, + Message::SourceEdit(text_editor::Action::Move(text_editor::Motion::Home)), + ); + let undo = app + .source_rename_undo_message() + .expect("caret move retains Undo rename"); + send(&mut app, undo); + assert_eq!(app.document.bundle(), &original.source); + assert_accepted_display(&app, &original.source); + assert_eq!(app.project(), &original.project); + send(&mut app, Message::Redo); + assert_eq!(app.document.bundle(), &expected_source); + assert_accepted_display(&app, &expected_source); + assert_eq!(app.project(), &expected_model); +} + +#[test] +fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_history() { + for mismatch in ["none", "path", "before", "after"] { + let mut app = fixture(); + let original_project = app.project().clone(); + let original_source = app.document.bundle().clone(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + let renamed_project = app.project().clone(); + let renamed_source = app.document.bundle().clone(); + assert_accepted_display(&app, &renamed_source); + + // Undo the actual accepted transaction at the Document boundary, leaving + // the display/workspace on the newer source until the common sync runs. + assert!(app.document.undo()); + assert_eq!(app.project(), &original_project); + assert_eq!(app.document.bundle(), &original_source); + assert!(!app.document.can_undo()); + assert!(app.document.can_redo()); + let revision = app.document.revision(); + let dirty = app.document.dirty(); + let workspace_key = app.source_key(); + let path = app.source_workspace.active.clone(); + let before = app.source_editor.text(); + let after = original_source.files[&path].clone(); + assert_ne!(before, after, "fallback must visibly replace stale text"); + + // Observable stale derived state makes a skipped sync fail independently + // of the editor byte oracle. None of this changes Document authority. + let selected = Id::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); + app.selected = selected; + app.draft = None; + app.source_snapshot = Arc::from("stale snapshot"); + app.source_select_line = true; + app.source_jump = Some(cursor(&before, 0, 1)); + app.source_navigation.query = "send_water".into(); + app.source_navigation.refresh(&before); + assert_ne!(app.source_navigation.label(), "0 matches"); + let mut invalid = original_source.clone(); + invalid.files.insert(path.clone(), "?".into()); + app.source_diagnostics = source_workspace::lint(&invalid); + assert!(!app.source_diagnostics.is_empty()); + let version = app.source_version; + app.lint_requested = Some(version); + app.model_issues = vec![oa_core::validation::Issue { + severity: oa_core::validation::Severity::Error, + element: None, + code: "stale-test-issue".into(), + field: None, + message: "Must be replaced by accepted-model validation".into(), + }]; + let hint = match mismatch { + "none" => None, + "path" => Some(EditorRefresh { + path: "different.arcadia", + before: &before, + after: &after, + }), + "before" => Some(EditorRefresh { + path: &path, + before: "not the displayed source", + after: &after, + }), + "after" => Some(EditorRefresh { + path: &path, + before: &before, + after: &before, + }), + _ => unreachable!(), + }; + if mismatch == "none" { + app.sync(); + } else { + app.sync_with_alias_editor(hint); + } + + assert_accepted_display(&app, &original_source); + assert_eq!(app.project(), &original_project, "{mismatch}"); + assert_eq!(app.document.bundle(), &original_source); + assert_eq!( + app.document.revision(), + revision, + "display sync adds no model operation" + ); + assert_eq!(app.document.dirty(), dirty); + assert!(!app.document.can_undo()); + assert!(app.document.can_redo()); + assert_ne!( + app.source_key(), + workspace_key, + "workspace adopts accepted source" + ); + assert_eq!(app.source_version, version + 1); + assert_eq!(app.lint_requested, None); + assert_eq!(app.source_diagnostics, app.document.compiled().diagnostics); + assert_eq!(app.source_navigation.label(), "0 matches"); + assert!(!app.source_select_line); + assert_eq!(app.source_jump, None); + let draft = app + .draft + .as_ref() + .expect("accepted property draft refreshed"); + assert_eq!(draft.original, selected); + assert_eq!(draft.value["alias"], "pump"); + assert!(!draft.dirty()); + assert_eq!( + serde_json::to_value(&app.model_issues).unwrap(), + serde_json::to_value(validate(&original_project)).unwrap(), + ); + + // A display fallback must not consume, replace or append model history. + send(&mut app, Message::Redo); + assert_eq!(app.project(), &renamed_project); + assert_eq!(app.document.bundle(), &renamed_source); + assert_accepted_display(&app, &renamed_source); + assert!(!app.document.can_redo()); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &original_project); + assert_eq!(app.document.bundle(), &original_source); + assert_accepted_display(&app, &original_source); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn stale_dialog_envelopes_do_not_cancel_new_same_path_form_or_its_approval() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let old_confirm = preview(&mut app); + let old_id = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(old_id)); + begin(&mut app); + input(&mut app, "current_water"); + let _ = preview(&mut app); + let form = app.source_rename.form.as_ref().unwrap(); + let current_id = form.invocation; + let approval_id = form.approval.as_ref().unwrap().id; + let interaction = app.source_symbols.interaction; + let before = evidence(&app); + for stale in [ + old_confirm, + Message::SourceRename(Event::Close(old_id)), + Message::SourceRename(Event::Input(old_id, "stale".into())), + ] { + send(&mut app, stale); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!(form.invocation, current_id); + assert_eq!(form.replacement, "current_water"); + assert_eq!(form.approval.as_ref().unwrap().id, approval_id); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(evidence(&app), before); + } +} + +#[test] +fn close_and_reset_retain_worker_slot_until_exact_completion_without_reopening() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let job = start_preview(&mut app); + let ready = preview_completion(&app, job.clone()); + event(&mut app, Event::Close(job.invocation)); + assert_eq!(app.source_rename.in_flight, Some(job.id)); + let project = app.project().clone(); + app.reset(project, None); + assert_eq!(app.source_rename.in_flight, Some(job.id)); + let mut impostor = ready.clone(); + impostor.job.id = Id::new_v4(); + send(&mut app, Message::SourceRenameReady(impostor)); + assert_eq!(app.source_rename.in_flight, Some(job.id)); + send(&mut app, Message::SourceRenameReady(ready)); + assert!(app.source_rename.in_flight.is_none()); + assert!(!app.source_rename.open()); +} + +#[test] +fn replacement_generation_aba_and_modal_shortcuts_cannot_commit_stale_preview() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let stale = preview(&mut app); + let before = evidence(&app); + input(&mut app, "other_water"); + input(&mut app, "send_water"); + send(&mut app, stale); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + for message in [ + Message::Save(false), + Message::Undo, + Message::Page(Page::Guide), + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), + Message::SourceNavigation(nav::Event::Open(false)), + ] { + send(&mut app, message); + assert_eq!(evidence(&app), before); + assert!(app.source_rename.open()); + } + send(&mut app, Message::Escape); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), before); +} + +#[test] +fn property_draft_and_detached_buffer_refuse_without_hidden_apply() { + let mut app = fixture(); + send( + &mut app, + Message::Field("/name".into(), "Retain property draft".into()), + ); + assert!(app.draft_dirty()); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + assert!(!app.source_rename.open()); + assert!(app.draft_dirty()); + assert_eq!( + app.draft.as_ref().unwrap().inputs["/name"].raw, + "Retain property draft" + ); + assert_eq!(evidence(&app), before); + let mut app = fixture(); + let mut detached = app.document.bundle().clone(); + detached + .files + .insert("orphan.arcadia".into(), "// retained orphan\n".into()); + app.source_workspace = Workspace::new(&detached, true); + app.source_workspace + .set_reachable(app.document.bundle().files.keys().cloned().collect()); + app.show_source_file(false); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), before); +} + +#[test] +fn same_alias_invalid_and_cancel_keep_existing_redo_and_source_selection() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + send(&mut app, Message::Undo); + let start = app.source_workspace.text().rfind("pump;").unwrap(); + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + assert!(app.document.can_redo()); + let before = evidence(&app); + begin(&mut app); + for replacement in ["pump", " bad alias", "true"] { + input(&mut app, replacement); + let form = app.source_rename.form.as_ref().unwrap(); + let message = Event::Preview(form.invocation, form.generation); + event(&mut app, message); + assert!(app.source_rename.in_flight.is_none()); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + assert_eq!(evidence(&app), before); + } + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(invocation)); + assert_eq!(evidence(&app), before); + send(&mut app, Message::Redo); + assert!(app.document.bundle().files["defs.arcadia"].contains("oa_activity send_water ")); +} + +#[test] +fn source_edit_undo_and_recovery_invalidation_do_not_revive_approval() { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let old = preview(&mut app); + let before = app.document.bundle().clone(); + let text = app.source_workspace.text().to_owned(); + let cursor = app.source_editor.cursor(); + app.source_workspace + .edit(format!("{text}// temporary\n"), cursor); + app.source_workspace.undo(false); + assert_eq!(app.source_workspace.text(), text); + send(&mut app, old); + assert!(app.source_rename.form.as_ref().unwrap().stale); + assert_eq!(app.document.bundle(), &before); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Close(invocation)); + begin(&mut app); + input(&mut app, "send_water"); + let old = preview(&mut app); + let target = PathBuf::from("recovery/project.arcadia"); + app.pending_recovery = Some(target.clone()); + send( + &mut app, + Message::SourceRecovered(Ok(RecoverySnapshot { + target, + disk: Err("readback unavailable".into()), + })), + ); + send(&mut app, old); + assert!(app.source_rename.form.as_ref().unwrap().stale); + assert_eq!(app.document.bundle(), &before); + assert_eq!( + app.source_rename.form.as_ref().unwrap().replacement, + "send_water" + ); +} + +#[test] +fn full_details_selection_survives_readonly_and_passive_messages_and_tracks_input() { + let mut app = fixture(); + begin(&mut app); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Details(invocation, true)); + event( + &mut app, + Event::ReadOnly(invocation, text_editor::Action::SelectAll), + ); + let selected = app + .source_rename + .form + .as_ref() + .unwrap() + .detail_editor + .cursor(); + assert!(selected.selection.is_some()); + let size = app.window_size; + send(&mut app, Message::WindowResized(size)); + let version = app.source_version; + send(&mut app, Message::Linted(version, Ok(vec![]))); + assert_eq!( + app.source_rename + .form + .as_ref() + .unwrap() + .detail_editor + .cursor(), + selected + ); + input(&mut app, "send_water"); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + form.detail_editor.text(), + preview::text_segment(&details(form), form.detail_segment).unwrap() + ); + assert!(details(form).contains("Replacement: send_water")); + event( + &mut app, + Event::ReadOnly( + invocation, + text_editor::Action::Edit(text_editor::Edit::Insert('x')), + ), + ); + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + form.detail_editor.text(), + preview::text_segment(&details(form), form.detail_segment).unwrap() + ); +} + +fn many_uses(count: usize) -> App { + uses_fixture(count, false) +} + +fn uses_fixture(count: usize, multiline: bool) -> App { + let mut source = bundle(); + let mut uses = String::new(); + for i in 0..count { + uses.push_str(&format!( + "interaction r{i} \"同名\" id \"{}\" {{ source @pump; target @backup; }} ", + Id::from_u128(100 + i as u128) + )); + if multiline { + uses.push_str("\r\n"); + if i + 1 == count / 2 { + uses.push_str(&"// unchanged context 雨\r\n".repeat(10)); + } + } + } + uses.push_str("\r\n"); + source.files.insert("uses.arcadia".into(), uses); + let mut app = fixture(); + app.document = Document::from_bundle(source, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.source_workspace.active = "uses.arcadia".into(); + app.show_source_file(false); + let start = app.source_workspace.text().rfind("@pump").unwrap() + 1; + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + app +} + +#[test] +fn last_same_line_use_beyond_reference_row_limit_and_selection_boundaries() { + for count in [2, 220] { + for selection_kind in 0..3 { + let mut app = many_uses(count); + let original = evidence(&app); + let mut selection = app.source_editor.cursor(); + if selection_kind == 1 { + std::mem::swap( + &mut selection.position, + selection.selection.as_mut().unwrap(), + ); + } else if selection_kind == 2 { + selection.selection.as_mut().unwrap().column += 1; + } + app.source_editor.move_to(selection); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + let ready = target_completion(&app, current_job(&app)); + send(&mut app, Message::SourceRenameReady(ready)); + if selection_kind == 2 { + assert!(app.source_rename.form.as_ref().unwrap().target.is_none()); + assert_eq!(evidence(&app), before); + continue; + } + input(&mut app, "send_water"); + let confirm = preview(&mut app); + send(&mut app, confirm); + let expected = original.source.files["uses.arcadia"] + .replace("source @pump;", "source @send_water;"); + assert_eq!(app.document.bundle().files["uses.arcadia"], expected); + let start = expected.rfind("@send_water").unwrap() + 1; + assert_eq!( + app.source_editor.cursor(), + cursor(&expected, start, start + 10) + ); + } + } +} + +#[test] +fn genuine_save_receipts_preserve_same_destination_preview_but_invalidate_save_as() { + for relocated in [false, true] { + let mut app = fixture(); + let directory = tempfile::tempdir().unwrap(); + let original_path = directory.path().join("original").join("project.arcadia"); + let destination = if relocated { + directory.path().join("relocated").join("project.arcadia") + } else { + original_path.clone() + }; + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + app.path = Some(original_path); + app.document.mark_recovered(); + let receipt = write_snapshot( + app.canvas_document, + app.document.revision(), + app.document.snapshot(), + destination, + None, + ) + .unwrap(); + begin(&mut app); + input(&mut app, "send_water"); + let confirm = preview(&mut app); + let original = app.document.bundle().clone(); + send(&mut app, Message::Saved(Ok(Some(receipt)))); + assert!( + !app.document.dirty(), + "actual saved snapshot became the live baseline" + ); + send(&mut app, confirm); + if relocated { + assert_eq!(app.document.bundle(), &original); + assert!(app.source_rename.form.as_ref().unwrap().stale); + } else { + assert!(!app.source_rename.open()); + assert!(app.document.dirty()); + let undo = app.source_rename_undo_message().unwrap(); + send(&mut app, undo); + assert_eq!(app.document.bundle(), &original); + assert!( + !app.document.dirty(), + "Undo uses the latest saved marker, not the preview-time marker" + ); + } + } +} + +#[test] +fn invalid_open_retains_accepted_document_but_refuses_alias_rename() { + let mut app = fixture(); + let original = evidence(&app); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("project.arcadia"); + let invalid = "arcadia 7;\nproject broken { ? invalid source }\n"; + std::fs::write(&path, invalid).unwrap(); + let disk = source_files::load_project(&path, LIMITS).unwrap(); + send( + &mut app, + Message::Loaded(Ok(Some(LoadedSnapshot::Source(disk)))), + ); + assert_eq!(app.document.bundle(), &original.source); + assert_eq!(app.project(), &original.project); + assert!(!app.source_workspace.applied); + assert_eq!(app.source_workspace.text(), invalid); + let before = evidence(&app); + let message = app.source_rename_message(); + send(&mut app, message); + assert!(!app.source_rename.open()); + assert_eq!(evidence(&app), before); +} + +fn captured_escape(app: &App) -> Vec { + use iced::advanced::{Layout, Shell, layout, subscription, widget}; + use iced::futures::{StreamExt, stream, task::noop_waker_ref}; + use iced::keyboard::{self, key::Named}; + use std::task::{Context as PollContext, Poll}; + let mut input: iced::Element<'_, (), iced::Theme, ()> = + iced::widget::text_input("Source alias", "retained_alias") + .on_input(|_| ()) + .into(); + let mut tree = widget::Tree::new(input.as_widget()); + let size = iced::Size::new(300., 40.); + let node = + input + .as_widget_mut() + .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); + tree.state + .downcast_mut::>() + .focus(); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(Named::Escape), + modified_key: keyboard::Key::Named(Named::Escape), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }); + let mut widget_messages = Vec::new(); + let mut shell = Shell::new(&mut widget_messages); + input.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + assert!(shell.is_event_captured()); + let event = subscription::Event::Interaction { + window: iced::window::Id::unique(), + event, + status: iced::event::Status::Captured, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let _entered = runtime.enter(); + let mut messages = Vec::new(); + for recipe in subscription::into_recipes(app.subscription()) { + let mut output = recipe.stream(Box::pin(stream::iter([event.clone()]))); + let mut context = PollContext::from_waker(noop_waker_ref()); + while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) { + messages.push(message); + } + } + messages +} + +#[test] +fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { + for pending in [false, true] { + for other_modal in 0..4 { + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let job = start_preview(&mut app); + let ready = preview_completion(&app, job.clone()); + if !pending { + send(&mut app, Message::SourceRenameReady(ready.clone())); + } + app.source_navigation.open = true; + app.source_symbols.open = true; + match other_modal { + 1 => app.hosted.visible = true, + 2 => { + app.file_prompt = Some(FilePrompt { + operation: FileOperation::Save, + path: "retained.arcadia".into(), + overwrite: false, + }) + } + 3 => app.pending = Some(Pending::Walkthrough), + _ => {} + } + let before = evidence(&app); + let mut messages = captured_escape(&app); + assert_eq!(messages.len(), 1); + send(&mut app, messages.remove(0)); + if other_modal != 0 { + assert!(app.source_rename.open(), "existing modal has priority"); + let mut messages = captured_escape(&app); + assert_eq!(messages.len(), 1); + send(&mut app, messages.remove(0)); + } + assert!(!app.source_rename.open()); + assert!( + app.source_symbols.open && app.source_navigation.open, + "rename Escape does not fall through" + ); + if pending { + assert_eq!(app.source_rename.in_flight, Some(job.id)); + send(&mut app, Message::SourceRenameReady(ready)); + assert!(!app.source_rename.open()); + } + assert_eq!(evidence(&app), before); + app.source_symbols.open = false; + app.source_navigation.open = false; + assert!(captured_escape(&app).is_empty()); + } + } +} + +#[test] +fn paging_all_utf8_fragments_preserves_exact_preview_and_approval() { + for multiline in [false, true] { + check_all_preview_pages( + if multiline { + uses_fixture(24, true) + } else { + many_uses(220) + }, + multiline, + ); + } +} + +fn check_all_preview_pages(mut app: App, multiline: bool) { + begin(&mut app); + input(&mut app, &"water".repeat(40)); + let confirm = preview(&mut app); + let original = evidence(&app); + let ready = app + .source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .clone(); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + let mut row_transitions = 0; + let mut hunk_transitions = 0; + for file_index in 0..ready.preview.files().len() { + let file = &ready.preview.files()[file_index]; + for hunk_index in 0..file.hunks().len() { + let hunk = &file.hunks()[hunk_index]; + for page in 0..hunk.lines().len().div_ceil(LINES_PER_PAGE) { + let mut reconstructed: BTreeMap = BTreeMap::new(); + let segments = segment_total(app.source_rename.form.as_ref().unwrap()); + for segment in 0..segments { + let form = app.source_rename.form.as_ref().unwrap(); + assert_eq!( + (form.file, form.hunk, form.line, form.segment), + (file_index, hunk_index, page * LINES_PER_PAGE, segment) + ); + let mut mounted = 0; + for line in hunk.lines().iter().skip(form.line).take(LINES_PER_PAGE) { + let before = line + .before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap(); + let after = line + .after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap(); + let a = preview::text_segment(before, segment).unwrap_or(""); + let b = preview::text_segment(after, segment).unwrap_or(""); + mounted += a.len() + b.len(); + let pair = reconstructed.entry(line.number()).or_default(); + pair.0.push_str(a); + pair.1.push_str(b); + } + assert!(mounted <= LINES_PER_PAGE * 2 * 255); + if segment + 1 < segments { + event(&mut app, Event::Segment(invocation, ready.id, true)); + } + } + for line in hunk + .lines() + .iter() + .skip(page * LINES_PER_PAGE) + .take(LINES_PER_PAGE) + { + let pair = &reconstructed[&line.number()]; + assert_eq!( + pair.0, + line.before_text(&ready.prepared.before().bundle.files[file.path()]) + .unwrap() + ); + assert_eq!( + pair.1, + line.after_text(&ready.prepared.after().bundle.files[file.path()]) + .unwrap() + ); + } + if page + 1 < hunk.lines().len().div_ceil(LINES_PER_PAGE) { + event(&mut app, Event::Line(invocation, ready.id, true)); + row_transitions += 1; + } + } + if hunk_index + 1 < file.hunks().len() { + event(&mut app, Event::Hunk(invocation, ready.id, true)); + hunk_transitions += 1; + } + } + if file_index + 1 < ready.preview.files().len() { + event(&mut app, Event::File(invocation, ready.id, true)); + } + } + if multiline { + assert!( + row_transitions >= 2, + "both multiline hunks require row paging" + ); + assert!(hunk_transitions >= 1, "unchanged gap must separate hunks"); + } + assert_eq!(evidence(&app), original); + send(&mut app, confirm); + assert_eq!(app.document.bundle(), &ready.prepared.after().bundle); +} + +#[cfg(debug_assertions)] +#[test] +fn actual_dialog_real_paragraph_geometry_keeps_input_preview_and_footer_reachable() { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Layout, layout, widget}; + #[derive(Default)] + struct Bounds { + diff_id: Option, + input: Option, + preview: Option, + footer: Option, + diff: Option, + } + impl widget::Operation for Bounds { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: iced::Rectangle) { + if id == Some(&widget::Id::new("source-rename-input")) { + self.input = Some(bounds); + } + if id == Some(&widget::Id::new("source-rename-preview")) { + self.preview = Some(bounds); + } + if id == Some(&widget::Id::new("source-rename-footer")) { + self.footer = Some(bounds); + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: iced::Rectangle, + _: iced::Rectangle, + _: iced::Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == self.diff_id.as_ref() { + self.diff = Some(bounds); + } + } + } + let mut app = many_uses(12); + let id = Id::from_u128(2); + app.document + .execute(Command::Rename(id, "長い救援の名前 🚀 ".repeat(100))) + .unwrap(); + app.sync(); + let start = app.source_workspace.text().rfind("@pump").unwrap() + 1; + app.source_editor + .move_to(cursor(app.source_workspace.text(), start, start + 4)); + begin(&mut app); + input(&mut app, &"water".repeat(100)); + let _ = preview(&mut app); + for (width, height) in [(1100., 700.), (580., 700.), (1440., 900.)] { + app.window_size = iced::Size::new(width, height); + for details in [false, true] { + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + event(&mut app, Event::Details(invocation, details)); + let exact = app + .source_rename + .form + .as_ref() + .unwrap() + .detail_editor + .text(); + let editor = text_editor::Content::::with_text(&exact); + let mut view = dialog_content(&app, &editor); + let mut tree = widget::Tree::new(view.as_widget()); + let host = iced::Size::new((width - 84.).min(708.), height - 120.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, host), + ); + let form = app.source_rename.form.as_ref().unwrap(); + let mut bounds = Bounds { + diff_id: Some(preview_scroll_id(form, form.approval.as_ref().unwrap().id)), + ..Bounds::default() + }; + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &FontLayout, &mut bounds); + let input = bounds.input.unwrap(); + let body = bounds.preview.unwrap(); + let footer = bounds.footer.unwrap(); + assert!(input.height >= 28. && input.y >= 0.); + assert!(body.height >= 80., "{width}×{height} body={body:?}"); + assert!( + footer.y >= body.y + body.height && footer.y + footer.height <= host.height + 1. + ); + if !details { + assert!( + bounds.diff.unwrap().height >= 40., + "{width}×{height} diff={:?}", + bounds.diff + ); + } + } + } +} + +#[cfg(debug_assertions)] +#[test] +fn mounted_editor_f2_requires_focus_and_no_modifiers() { + use crate::views::{source_binding, source_tests::FontLayout}; + use iced::advanced::widget::operation::Focusable; + use iced::advanced::{Layout, Shell, layout, text as shaped, widget}; + use iced::keyboard::{ + self, Key, Modifiers, + key::{Code, Named, Physical}, + }; + let app = fixture(); + let begin = app.source_rename_begin(); + let context = app.source_symbol_context(); + for focused in [false, true] { + for modifiers in [ + Modifiers::empty(), + Modifiers::SHIFT, + Modifiers::CTRL, + Modifiers::ALT, + Modifiers::LOGO, + ] { + let content = + text_editor::Content::::with_text(app.source_workspace.text()); + let mut editor: iced::Element<'_, Message, iced::Theme, FontLayout> = + iced::widget::text_editor(&content) + .on_action(Message::SourceEdit) + .key_binding(|press| source_binding(press, &context, Some(&begin))) + .height(200.) + .into(); + let mut tree = widget::Tree::new(editor.as_widget()); + let size = iced::Size::new(500., 200.); + let node = editor.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, size), + ); + if focused { + tree.state + .downcast_mut::>() + .focus(); + } + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(Named::F2), + modified_key: Key::Named(Named::F2), + physical_key: Physical::Code(Code::F2), + location: keyboard::Location::Standard, + modifiers, + text: None, + repeat: false, + }); + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + editor.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + let renames: Vec<_> = messages + .iter() + .filter_map(|message| match message { + Message::SourceRename(Event::Begin(context)) => Some(context), + _ => None, + }) + .collect(); + assert_eq!(renames.len(), usize::from(focused && modifiers.is_empty())); + if let Some(context) = renames.first() { + assert_eq!(*context, &begin); + } + } + } +} + +#[cfg(debug_assertions)] +#[test] +fn actual_dialog_enter_requests_preview_even_when_confirm_is_available() { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Layout, Shell, layout, widget}; + use iced::keyboard::{ + self, Key, + key::{Code, Named, Physical}, + }; + struct Focus(iced::widget::Id); + impl widget::Operation for Focus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: iced::Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + if id == Some(&self.0) { + state.focus(); + } else { + state.unfocus(); + } + } + } + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let _ = preview(&mut app); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let generation = form.generation; + let mut messages = Vec::new(); + { + let editor = text_editor::Content::::new(); + let mut view = dialog_content(&app, &editor); + let mut tree = widget::Tree::new(view.as_widget()); + let size = iced::Size::new(708., 580.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, size), + ); + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &FontLayout, + &mut Focus(input_id(invocation)), + ); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(Named::Enter), + modified_key: Key::Named(Named::Enter), + physical_key: Physical::Code(Code::Enter), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }); + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + } + assert_eq!(messages.len(), 1); + assert!( + matches!(messages[0], Message::SourceRename(Event::Preview(i,g)) if i == invocation && g == generation) + ); + send(&mut app, messages.remove(0)); + assert_eq!(evidence(&app), before); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + assert!(app.source_rename.in_flight.is_some()); +} + +#[cfg(debug_assertions)] +#[test] +fn actual_alias_clipboard_rejects_controls_without_normalizing_or_moving_selection() { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Clipboard, Layout, Shell, clipboard, layout, text, widget}; + use iced::keyboard::{ + self, Key, Modifiers, + key::{Code, Physical}, + }; + use std::cell::Cell; + struct ClipboardText { + value: String, + reads: Cell, + } + impl Clipboard for ClipboardText { + fn read(&self, _: clipboard::Kind) -> Option { + self.reads.set(self.reads.get() + 1); + Some(self.value.clone()) + } + fn write(&mut self, _: clipboard::Kind, _: String) {} + } + struct Select(iced::widget::Id); + impl widget::Operation for Select { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: iced::Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + if id == Some(&self.0) { + state.focus(); + } else { + state.unfocus(); + } + } + } + fn focused_cursor(tree: &widget::Tree) -> Option { + type InputState = + iced::widget::text_input::State<::Paragraph>; + if tree.tag == widget::tree::Tag::of::() { + let state = tree.state.downcast_ref::(); + if state.is_focused() { + return Some(state.cursor()); + } + } + tree.children.iter().find_map(focused_cursor) + } + for (pasted, pending) in [ + ("foo\nbar", false), + ("foo\tbar", false), + ("foo\r\nbar", false), + ("foo\0bar", false), + ("water_flow", false), + ("foo\nbar", true), + ] { + let rejected = pasted.chars().any(char::is_control); + let mut app = fixture(); + begin(&mut app); + input(&mut app, "send_water"); + let stale_confirm = preview(&mut app); + let pending_completion = pending.then(|| { + let job = start_preview(&mut app); + preview_completion(&app, job) + }); + let before = evidence(&app); + let form = app.source_rename.form.as_ref().unwrap(); + let invocation = form.invocation; + let generation = form.generation; + let mut messages = Vec::new(); + let editor = text_editor::Content::::new(); + let mut tree; + let selected; + { + let mut view = dialog_content(&app, &editor); + tree = widget::Tree::new(view.as_widget()); + let size = iced::Size::new(708., 580.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(iced::Size::ZERO, size), + ); + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &FontLayout, + &mut Select(input_id(invocation)), + ); + // Iced visits text_input before focusable. A separate traversal + // selects after focus(), which otherwise moves the caret to the end. + view.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &FontLayout, + &mut widget::operation::text_input::select_range(input_id(invocation), 0, 10), + ); + selected = focused_cursor(&tree).unwrap(); + assert_eq!( + selected.state(&iced::widget::text_input::Value::new("send_water")), + iced::widget::text_input::cursor::State::Selection { start: 0, end: 10 } + ); + let mut clipboard = ClipboardText { + value: pasted.into(), + reads: Cell::new(0), + }; + for event in [ + iced::Event::Keyboard(keyboard::Event::ModifiersChanged(Modifiers::COMMAND)), + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Character("v".into()), + modified_key: Key::Character("v".into()), + physical_key: Physical::Code(Code::KeyV), + location: keyboard::Location::Standard, + // Iced's paste branch deliberately uses the stored modifiers. + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }), + ] { + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut clipboard, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + if matches!( + event, + iced::Event::Keyboard(keyboard::Event::KeyPressed { .. }) + ) { + assert!(shell.is_event_captured()); + } + } + assert_eq!( + clipboard.reads.get(), + 1, + "child uses the checked clipboard snapshot" + ); + if rejected { + assert_eq!(focused_cursor(&tree), Some(selected)); + } + } + assert_eq!(messages.len(), 1); + if rejected { + assert!( + matches!(messages[0], Message::SourceRename(Event::PasteRejected(id)) if id == invocation) + ); + } else { + assert!( + matches!(&messages[0], Message::SourceRename(Event::Input(id, value)) if *id == invocation && value == pasted), + "paste must replace the entire active selection: {:?}", + messages[0] + ); + } + send(&mut app, messages.remove(0)); + let form = app.source_rename.form.as_ref().unwrap(); + assert_ne!(form.generation, generation); + assert!(form.approval.is_none()); + assert_eq!( + form.replacement, + if rejected { "send_water" } else { pasted } + ); + if rejected { + assert!(form.notice.contains("Paste rejected")); + let view = dialog_content(&app, &editor); + tree.diff(view.as_widget()); + assert_eq!( + focused_cursor(&tree), + Some(selected), + "refusal feedback retains mounted selection" + ); + } + send(&mut app, stale_confirm); + assert_eq!(evidence(&app), before); + if let Some(completion) = pending_completion { + assert_eq!(app.source_rename.in_flight, Some(completion.job.id)); + assert!(app.source_rename.form.as_ref().unwrap().pending.is_none()); + send(&mut app, Message::SourceRenameReady(completion)); + assert!(app.source_rename.in_flight.is_none()); + assert!(app.source_rename.form.as_ref().unwrap().approval.is_none()); + assert_eq!(evidence(&app), before); + } + } +} diff --git a/crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs b/crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs new file mode 100644 index 00000000..ea37c4d4 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename_layout_timing_tests.rs @@ -0,0 +1,313 @@ +//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint. +//! Child of source_rename_timing_tests, preserving the separate real App test. +use super::*; +use crate::{highlight, views::source_tests::FontLayout}; +use iced::advanced::widget::operation::Focusable; +use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree}; +use iced::{ + Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation, +}; + +// The same real Paragraph and Editor as FontLayout. During draw, retain only a +// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor. +#[derive(Default)] +struct ObservedFontLayout { + drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>, +} +impl renderer::Renderer for ObservedFontLayout { + fn start_layer(&mut self, _: Rectangle) {} + fn end_layer(&mut self) {} + fn start_transformation(&mut self, _: Transformation) {} + fn end_transformation(&mut self) {} + fn reset(&mut self, _: Rectangle) { + self.drawn = None; + } + fn fill_quad(&mut self, _: renderer::Quad, _: impl Into) {} + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} +impl shaped::Renderer for ObservedFontLayout { + type Font = Font; + type Paragraph = ::Paragraph; + type Editor = ::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::ICED_LOGO; + fn default_font(&self) -> Font { + shaped::Renderer::default_font(&FontLayout) + } + fn default_size(&self) -> Pixels { + shaped::Renderer::default_size(&FontLayout) + } + fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {} + fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) { + self.drawn = Some((editor.downgrade(), position, clip)); + } + fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} +} + +type Content = text_editor::Content; +const HOST: Size = Size::new(640., 350.); + +fn view(editor: &Content, source: Arc) -> Element<'_, (), iced::Theme, ObservedFontLayout> { + text_editor(editor) + .on_action(|_| ()) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(Length::Fill) + .highlight_with::(source, highlight::format) + .into() +} + +fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) { + assert_eq!(editor.text(), expected); + assert_eq!(editor.cursor(), cursor); + let lines: Vec<_> = expected.split('\n').collect(); + assert_eq!(editor.line_count(), lines.len()); + for (index, part) in lines.iter().enumerate() { + let (text, ending) = if index + 1 == lines.len() { + (*part, text_editor::LineEnding::None) + } else if let Some(text) = part.strip_suffix('\r') { + (text, text_editor::LineEnding::CrLf) + } else { + (*part, text_editor::LineEnding::Lf) + }; + let actual = editor.line(index).unwrap(); + assert_eq!(actual.text, text); + assert_eq!(actual.ending, ending); + } +} + +fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) { + let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw"); + let editor = weak.upgrade().expect("drawn editor is still live"); + let buffer = editor.buffer(); + assert_eq!(weak.bounds, Size::new(608., 318.)); + assert_eq!(buffer.size(), (Some(608.), Some(318.))); + assert_eq!(buffer.metrics().font_size, 14.); + assert_eq!( + buffer.scroll().horizontal, + 0., + "fixture origin fits the viewport" + ); + let start = cursor.position; + let end = cursor.selection.expect("approved alias selection"); + assert_eq!(start.line, end.line); + assert_eq!(end.column - start.column, alias.len()); + let line = &buffer.lines[start.line]; + assert_eq!(&line.text()[start.column..end.column], alias); + let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139)); + let font = graphics::text::to_attributes(Font::MONOSPACE); + for byte in start.column..end.column { + let attrs = line.attrs_list().get_span(byte); + assert_eq!( + attrs.color_opt, + Some(color), + "accepted alias syntax attributes" + ); + assert_eq!(attrs.family, font.family); + } + let mut covered = vec![false; alias.len()]; + for run in buffer.layout_runs().filter(|run| run.line_i == start.line) { + for glyph in run.glyphs { + let from = glyph.start.max(start.column); + let to = glyph.end.min(end.column); + if from >= to { + continue; + } + assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color"); + let x = position.x + glyph.x + glyph.x_offset * glyph.font_size; + let y = position.y + run.line_top; + assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5); + assert!(y + run.line_height > clip.y && y < clip.y + clip.height); + covered[from - start.column..to - start.column].fill(true); + } + } + assert!( + covered.into_iter().all(|seen| seen), + "entire alias has visible shaped glyphs" + ); + // This temporary strong upgrade is dropped before the next editor mutation. +} + +#[derive(Clone, Copy)] +enum Arm { + Constructor, + Reuse, +} +impl Arm { + fn name(self) -> &'static str { + match self { + Self::Constructor => "with_text", + Self::Reuse => "same_line_reuse", + } + } + fn phases(self) -> [&'static str; 4] { + match self { + Self::Constructor => [ + "constructor_replace_origin", + "constructor_next_layout", + "constructor_highlight_draw", + "constructor_combined", + ], + Self::Reuse => [ + "reuse_replace_origin", + "reuse_next_layout", + "reuse_highlight_draw", + "reuse_combined", + ], + } + } +} + +fn trial( + fixture: &Fixture, + cursor: text_editor::Cursor, + arm: Arm, + trial: usize, + samples: &mut Samples, +) { + let before = &fixture.bundle.files[ORIGIN]; + let after = &fixture.expected_bundle.files[ORIGIN]; + let mut editor = Content::with_text(before); + editor.move_to(fixture.cursor); + let mut renderer = ObservedFontLayout::default(); + let theme = iced::Theme::Light; + let defaults = renderer::Style::default(); + let viewport = Rectangle::with_size(HOST); + let limits = layout::Limits::new(Size::ZERO, HOST); + let mut tree = { + let mut widget = view(&editor, Arc::from(before.as_str())); + let mut tree = Tree::new(widget.as_widget()); + let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits); + tree.state + .downcast_mut::>() + .focus(); + widget.as_widget().draw( + &tree, + &mut renderer, + &theme, + &defaults, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + tree + }; + check_text(&editor, before, fixture.cursor); + check_draw(&renderer, fixture.cursor, OLD); + // No recorder Weak/Strong from setup may perturb replacement ownership. + renderer.drawn = None; + let next_source: Arc = Arc::from(after.as_str()); + + // Only normal operations and four timestamps occur between start and end. + // Source/model oracles, logging and recorder glyph inspection are outside. + let started = Instant::now(); + let replaced = match arm { + Arm::Constructor => { + editor = Content::with_text(after); + true + } + Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after), + }; + editor.move_to(cursor); + let replaced_at = Instant::now(); + let mut widget = view(&editor, next_source); + let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits); + let laid_out_at = Instant::now(); + widget.as_widget().draw( + &tree, + &mut renderer, + &theme, + &defaults, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + let drawn_at = Instant::now(); + drop(widget); + + assert!(replaced, "reuse must not silently time a fallback/refusal"); + check_text(&editor, after, cursor); + check_draw(&renderer, cursor, NEW); + renderer.drawn = None; + for (phase, duration) in arm.phases().into_iter().zip([ + replaced_at.duration_since(started), + laid_out_at.duration_since(replaced_at), + drawn_at.duration_since(laid_out_at), + drawn_at.duration_since(started), + ]) { + println!( + "{}", + serde_json::json!({ + "record": "editor_layout_timing", "arm": arm.name(), "phase": phase, + "trial": trial, "warmup": trial == 0, + "elapsed_ns": duration.as_nanos().to_string(), + "elapsed_ms": duration.as_secs_f64() * 1000.0, + }) + ); + if trial != 0 { + samples.0.entry(phase).or_default().push(duration); + } + } +} + +#[test] +#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"] +fn accepted_release1_constructor_vs_reuse_through_next_highlight() { + let fixture = Fixture::load(); + // Resolve the exact production approval and verify full Project/source once, + // outside all intervals. The actual App Confirm evaluator remains separate. + let cursor = { + let app = fixture.app(); + let target = resolve(&app, &app.rename_context()); + let prepared = app + .document + .alias_rename_request(target.id, NEW.into()) + .unwrap() + .prepare() + .unwrap(); + fixture.check_candidate(&prepared); + prepare_approval(prepared, &target, ORIGIN).unwrap().cursor + }; + println!( + "{}", + serde_json::json!({ + "record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height, + "font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill", + "active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(), + "after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1, + "measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions), + "scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm", + }) + ); + let mut samples = Samples::default(); + for index in 0..=5 { + let arms = if index % 2 == 0 { + [Arm::Constructor, Arm::Reuse] + } else { + [Arm::Reuse, Arm::Constructor] + }; + println!( + "{}", + serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()}) + ); + for arm in arms { + trial(&fixture, cursor, arm, index, &mut samples); + } + } + samples.report(); +} diff --git a/crates/oa-desktop/src/app/source_rename_timing_tests.rs b/crates/oa-desktop/src/app/source_rename_timing_tests.rs new file mode 100644 index 00000000..b7df39f7 --- /dev/null +++ b/crates/oa-desktop/src/app/source_rename_timing_tests.rs @@ -0,0 +1,347 @@ +//! Manual timing through the real Document and App paths. No disk writes, +//! executor polling, layout, GPU work or first-paint measurement occurs here. +//! Register as a test child of app::source_rename to use its actual worker helpers. +use super::*; +use crate::source_workspace::Workspace; +use oa_core::source_project::SourceBundle; +use std::{ + collections::BTreeMap, + path::PathBuf, + time::{Duration, Instant}, +}; + +#[cfg(debug_assertions)] +#[path = "source_rename_layout_timing_tests.rs"] +mod layout_timing_tests; + +const OLD: &str = "oa_restore_work"; +const NEW: &str = "oa_restore_pressure"; +const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia"; +const ORIGIN: &str = "traceability.arcadia"; + +struct Fixture { + bundle: SourceBundle, + project: Project, + expected_bundle: SourceBundle, + expected_project: Project, + target: Id, + cursor: text_editor::Cursor, +} + +impl Fixture { + fn load() -> Self { + // The optional entry selects a parent-pinned copy of this exact fixture, + // not an arbitrary performance generator or a mutable GUI destination. + let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/release1/project.arcadia") + }); + let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap(); + let bundle = loaded.reachable_bundle(); + assert_eq!( + bundle.files.len(), + 30, + "use the preserved release1 walkthrough" + ); + let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap(); + assert_eq!( + document.bundle(), + &bundle, + "fixture must need no stabilization/migration" + ); + assert!(!document.dirty()); + let project = document.project().clone(); + let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap(); + assert_eq!(project.elements[&target].alias, OLD); + assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW)); + let mut expected_project = project.clone(); + expected_project.elements.get_mut(&target).unwrap().alias = NEW.into(); + + // Independent literal source oracle, authored before running the rename. + // Do not replace the alias substring in filenames or the trace's alias. + let mut expected_bundle = bundle.clone(); + let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap(); + assert_eq!( + declarations.matches("oa_activity oa_restore_work ").count(), + 1 + ); + assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4); + *declarations = declarations + .replace( + "oa_activity oa_restore_work ", + "oa_activity oa_restore_pressure ", + ) + .replace("parent @oa_restore_work;", "parent @oa_restore_pressure;"); + let traces = expected_bundle.files.get_mut(ORIGIN).unwrap(); + assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1); + *traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;"); + let source = &bundle.files[ORIGIN]; + let start = source.rfind("@oa_restore_work;").unwrap() + 1; + let position = |offset| { + let prefix = &source[..offset]; + text_editor::Position { + line: prefix.bytes().filter(|byte| *byte == b'\n').count(), + column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + } + }; + let cursor = text_editor::Cursor { + position: position(start), + selection: Some(position(start + OLD.len())), + }; + assert!(nav::exact_cursor(source, cursor)); + println!( + "{}", + serde_json::json!({ + "record": "fixture", "entry": entry, "reachable_files": bundle.files.len(), + "source_bytes": bundle.files.values().map(String::len).sum::(), + "identities": project.identities().len(), "active_file": ORIGIN, + "active_file_bytes": source.len(), "old": OLD, "new": NEW, + "warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions), + "scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering" + }) + ); + Self { + bundle, + project, + expected_bundle, + expected_project, + target, + cursor, + } + } + + fn app(&self) -> App { + let mut app = App::with_project(Project::empty()); + app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.source_workspace.active = ORIGIN.into(); + app.selected = app.project().id; + app.page = Page::Text; + app.sync(); + app.source_editor.move_to(self.cursor); + assert_eq!(app.project(), &self.project); + assert_eq!(app.document.bundle(), &self.bundle); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo()); + assert!(app.rename_admission().is_ok()); + app + } + + fn check_candidate(&self, prepared: &PreparedAliasRename) { + assert!(prepared.changed()); + assert_eq!(prepared.before().bundle, self.bundle); + assert_eq!(prepared.before().decoded.project, self.project); + assert_eq!(prepared.after().bundle, self.expected_bundle); + assert_eq!(prepared.after().decoded.project, self.expected_project); + } + + fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) { + assert!( + std::ptr::eq(app.document.compiled(), candidate_address), + "adopt exact candidate allocation" + ); + assert_eq!(app.project(), &self.expected_project); + assert_eq!(app.document.bundle(), &self.expected_bundle); + assert_eq!(app.source_workspace.bundle(), self.expected_bundle); + assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]); + assert_eq!(app.document.revision(), 1); + assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo()); + assert!(app.document.undo()); + assert_eq!(app.project(), &self.project); + assert_eq!(app.document.bundle(), &self.bundle); + assert!( + !app.document.dirty(), + "live known saved baseline remains authoritative" + ); + assert!( + !app.document.can_undo(), + "exactly one preceding model transaction" + ); + assert!(app.document.redo()); + assert_eq!(app.project(), &self.expected_project); + assert_eq!(app.document.bundle(), &self.expected_bundle); + assert!(std::ptr::eq(app.document.compiled(), candidate_address)); + } +} + +#[derive(Default)] +struct Samples(BTreeMap<&'static str, Vec>); + +impl Samples { + fn time(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T { + let started = Instant::now(); + let result = action(); + let elapsed = started.elapsed(); + // Printing, assertions and dropping the returned value are outside this + // interval. Full Confirm still includes production's own local drops. + println!( + "{}", + serde_json::json!({ + "record": "timing", "phase": phase, "trial": trial, + "warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(), + "elapsed_ms": elapsed.as_secs_f64() * 1000.0 + }) + ); + if trial != 0 { + self.0.entry(phase).or_default().push(elapsed); + } + result + } + + fn report(mut self) { + for (phase, values) in &mut self.0 { + assert_eq!(values.len(), 5); + values.sort(); + println!( + "{}", + serde_json::json!({ + "record": "summary", "phase": phase, "samples": values.len(), + "min_ms": values[0].as_secs_f64() * 1000.0, + "median_ms": values[2].as_secs_f64() * 1000.0, + "max_ms": values[4].as_secs_f64() * 1000.0 + }) + ); + } + } +} + +fn resolve(app: &App, context: &Context) -> Target { + let snapshot = app + .with_source_input(|input| query::admit_owned(input, query::Limits::default())) + .unwrap() + .capture() + .unwrap(); + let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap(); + resolve_target(&snapshot, context, offset).unwrap() +} + +fn active_job(app: &App) -> Job { + let form = app.source_rename.form.as_ref().unwrap(); + let id = form.pending.unwrap(); + assert_eq!(app.source_rename.in_flight, Some(id)); + Job { + id, + invocation: form.invocation, + generation: form.generation, + context: form.context.clone(), + } +} + +fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) { + let mut app = fixture.app(); + let target = resolve(&app, &app.rename_context()); + assert_eq!(target.id, fixture.target); + let request = samples + .time("document_request_mint", trial, || { + app.document.alias_rename_request(target.id, NEW.into()) + }) + .unwrap(); + let prepared = samples + .time("worker_document_prepare", trial, || request.prepare()) + .unwrap(); + fixture.check_candidate(&prepared); + let approval = samples + .time("worker_preview_and_origin", trial, || { + prepare_approval(prepared, &target, ORIGIN) + }) + .unwrap(); + assert_eq!(approval.preview.files().len(), 2); + assert_eq!(approval.preview.changed_line_count(), 6); + let candidate_address = approval.prepared.after() as *const CompiledSourceProject; + // Same-destination save completion is a legitimate live update after Preview. + app.document.mark_saved(app.document.snapshot()); + let changed = samples + .time("document_guarded_commit", trial, || { + app.document.commit_alias_rename(&approval.prepared) + }) + .unwrap(); + assert!(changed); + samples.time("app_sync", trial, || app.sync()); + fixture.check_adopted(&mut app, candidate_address); +} + +fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) { + let mut app = fixture.app(); + let begin = app.source_rename_message(); + // These Tasks are deliberately not polled: deterministic worker helpers + // supply the same results through the real readiness messages below. + drop(app.update(begin)); + let target_job = active_job(&app); + let target = Arc::new(resolve(&app, &target_job.context)); + assert_eq!(target.id, fixture.target); + drop(app.update(Message::SourceRenameReady(Completion { + job: target_job, + payload: Payload::Target(Ok(target.clone())), + }))); + let invocation = app.source_rename.form.as_ref().unwrap().invocation; + drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into())))); + let generation = app.source_rename.form.as_ref().unwrap().generation; + drop(app.update(Message::SourceRename(Event::Preview( + invocation, generation, + )))); + let preview_job = active_job(&app); + let prepared = app + .document + .alias_rename_request(target.id, NEW.into()) + .unwrap() + .prepare() + .unwrap(); + fixture.check_candidate(&prepared); + let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap()); + drop(target); + let candidate_address = approval.prepared.after() as *const CompiledSourceProject; + let expected_cursor = approval.cursor; + let approval_id = approval.id; + drop(app.update(Message::SourceRenameReady(Completion { + job: preview_job, + payload: Payload::Preview(Ok(approval)), + }))); + assert_eq!( + app.source_rename + .form + .as_ref() + .unwrap() + .approval + .as_ref() + .unwrap() + .id, + approval_id + ); + app.document.mark_saved(app.document.snapshot()); + let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id)); + let task = samples.time("app_confirm_update", trial, || app.update(confirm)); + samples.time("confirm_returned_task_drop_without_polling", trial, || { + drop(task) + }); + assert!(!app.source_rename.open()); + assert!(app.source_rename.in_flight.is_none()); + assert!(app.source_rename_undo_message().is_some()); + assert_eq!(app.source_editor.cursor(), expected_cursor); + fixture.check_adopted(&mut app, candidate_address); +} + +#[test] +#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"] +fn accepted_release1_prepare_commit_sync_and_confirm_timing() { + let fixture = Fixture::load(); + let mut samples = Samples::default(); + for trial in 0..=5 { + println!( + "{}", + serde_json::json!({ "record": "order", "trial": trial, + "warmup": trial == 0, "separate_phases_first": trial % 2 == 0 }) + ); + if trial % 2 == 0 { + separate_phases(&fixture, &mut samples, trial); + whole_confirm(&fixture, &mut samples, trial); + } else { + whole_confirm(&fixture, &mut samples, trial); + separate_phases(&fixture, &mut samples, trial); + } + } + samples.report(); +} diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs new file mode 100644 index 00000000..03aaade7 --- /dev/null +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -0,0 +1,508 @@ +//! Current draft navigation, isolated from model application and disk discovery. +use super::*; +use crate::source_navigation::{self as nav, query}; +use std::sync::atomic::Ordering; + +fn admission_notice(error: query::AdmissionError) -> String { + use nav::symbols::{BuildError, InputProblem, Resource}; + let (limit, resource) = match error { + query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource), + query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource { + language::OutlineResource::SourceBytes => Resource::SourceBytes, + language::OutlineResource::Tokens => Resource::Tokens, + language::OutlineResource::Declarations => Resource::Records, + }), + query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem { + InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath => + "Navigation needs distinct relative source paths. Check file names and include paths.".into(), + _ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(), + }, + }; + let unit = match resource { + Resource::Files => "loaded files", + Resource::SourceBytes => "source bytes", + Resource::PathBytes => "bytes in file paths", + Resource::Tokens => "source tokens", + Resource::Records => "declarations and references", + }; + format!( + "Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again." + ) +} + +impl App { + pub fn source_symbol_context(&self) -> nav::SymbolContext { + nav::SymbolContext { + session: self.source_workspace.session(), + path: self.source_workspace.active.clone(), + } + } + + pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message { + Message::SourceSymbols(self.source_symbol_context(), event) + } + + pub fn source_find_close_message(&self) -> Message { + Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance) + } + pub fn source_key(&self) -> query::SnapshotKey { + query::SnapshotKey { + workspace: self.source_workspace.session(), + revision: self.source_workspace.revision(), + } + } + + pub(super) fn with_source_input(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T { + let files: Vec<_> = self + .source_workspace + .files + .iter() + .map(|(path, buffer)| query::DraftFile { + path, + text: &buffer.text, + }) + .collect(); + let accepted = self.document.bundle(); + let validated: Vec<_> = accepted + .files + .iter() + .map(|(path, text)| query::DraftFile { path, text }) + .collect(); + use_input(query::Input { + key: self.source_key(), + entry: &self.source_workspace.entry, + files: &files, + applied: self.source_workspace.applied, + validation: self + .source_workspace + .applied + .then_some(query::ValidatedSources { + entry: &accepted.entry, + files: &validated, + }), + }) + } + + 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() + && !self.delete_pending + && self.workflow.is_none() + && !self.hosted.visible + && self.model_menu.is_none() + && self.diagram_usages.is_none() + && !self.reference_input_active() + && self.architecture.is_none() + && !self.behavior.has_pending() + } + + /// Mounted widget IDs change when another interaction supersedes a reveal. + /// A queued focus operation therefore cannot find a subsequently reused editor. + pub fn source_editor_id(&self) -> iced::widget::Id { + iced::widget::Id::from(format!( + "source-editor-{}-{}-{}", + self.source_workspace.session(), + self.source_workspace.active, + self.source_symbols.interaction + )) + } + + pub fn source_reveal_ticket(&self) -> Option { + self.source_symbols + .reveal + .as_ref() + .map(|reveal| reveal.ticket) + } + + pub(super) fn source_interaction(&mut self) { + self.source_symbols.interaction = Id::new_v4(); + self.source_symbols.request = None; + self.source_symbols.reveal = None; + self.source_jump = None; + } + + pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) { + self.source_jump = Some(cursor); + self.source_select_line = select_line; + self.source_symbols.reveal = Some(nav::Reveal { + ticket: Id::new_v4(), + interaction: self.source_symbols.interaction, + raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst), + snapshot: self.source_key(), + path: self.source_workspace.active.clone(), + }); + } + + pub(super) fn restore_source_editor_focus(&mut self) { + let cursor = self.source_editor.cursor(); + if self.source_navigation_available() + && nav::exact_cursor(self.source_workspace.text(), cursor) + { + self.queue_source_reveal(cursor, false); + } + } + + pub(super) fn source_viewport(&mut self, ticket: Option) -> Task { + let Some(reveal) = &self.source_symbols.reveal else { + return Task::none(); + }; + if Some(reveal.ticket) != ticket + || reveal.interaction != self.source_symbols.interaction + || !nav::interaction::is_current( + &self.source_symbols.raw_interaction, + reveal.raw_interaction, + ) + || reveal.snapshot != self.source_key() + || reveal.path != self.source_workspace.active + || !self.source_navigation_available() + { + return Task::none(); + } + let raw_interaction = reveal.raw_interaction; + self.source_symbols.reveal = None; + if let Some(cursor) = self.source_jump.take() { + if !nav::exact_cursor(self.source_workspace.text(), cursor) { + return Task::none(); + } + self.source_editor.move_to(cursor); + if self.source_select_line && cursor.selection.is_none() { + self.source_editor.perform(text_editor::Action::SelectLine); + } + return nav::interaction::guarded_focus( + self.source_editor_id(), + self.source_symbols.raw_interaction.clone(), + raw_interaction, + ); + } + Task::none() + } + + pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task { + if !self.source_navigation_available() { + return Task::none(); + } + match event { + nav::SymbolEvent::DiagramUsages => { + if self.source_dirty() || !self.source_workspace.applied { + self.source_symbols.open = true; + self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into()); + return Task::none(); + } + let task = self.start_source_query(query::Action::Definition); + self.source_diagram_job = self + .source_symbols + .request + .as_ref() + .map(|request| request.job); + return task; + } + nav::SymbolEvent::Close => { + self.source_symbols.open = false; + self.source_symbols.results = None; + self.source_symbols.notice = None; + } + nav::SymbolEvent::Back => { + self.source_symbol_back(); + // A successful Back needs no empty results panel. Retain an + // already open panel, or reveal an actionable return notice. + self.source_symbols.open |= self.source_symbols.notice.is_some(); + } + nav::SymbolEvent::Choose { + stamp, + reference, + index, + } => { + let Some(results) = self.source_symbols.results.clone() else { + return Task::none(); + }; + if results.stamp != stamp + || stamp.snapshot != self.source_key() + || self + .source_symbols + .cache + .as_ref() + .is_none_or(|cache| !self.source_snapshot_matches(cache)) + { + self.source_symbols.notice = + Some("Source changed. Run navigation again for current locations.".into()); + return Task::none(); + } + let destination = if reference { + results + .references + .get(index) + .map(|row| (&row.path, row.position, row.end_position)) + } else { + results + .definitions + .get(index) + .map(|row| (&row.path, row.position, row.end_position)) + }; + if let Some((path, start, end)) = destination { + self.source_symbol_jump(path, start, end); + } + } + nav::SymbolEvent::Query(action) => return self.start_source_query(action), + } + Task::none() + } + + fn start_source_query(&mut self, action: query::Action) -> Task { + self.source_symbols.open = true; + if !nav::interaction::is_current( + &self.source_symbols.raw_interaction, + self.source_symbols.raw_interaction.load(Ordering::SeqCst), + ) { + self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into()); + return Task::none(); + } + if self.source_symbols.in_flight.is_some() { + self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into()); + return Task::none(); + } + let cursor = self.source_editor.cursor(); + let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else { + self.source_symbols.notice = + Some("Place the cursor on an alias or its @ reference.".into()); + return Task::none(); + }; + self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) { + Some(serial) => serial, + None => { + self.source_workspace.reset_navigation_session(); + self.source_symbols.back.clear(); + self.source_symbols.cache = None; + 0 + } + }; + let request = nav::Request { + job: Id::new_v4(), + interaction: self.source_symbols.interaction, + raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst), + stamp: query::QueryStamp { + snapshot: self.source_key(), + serial: self.source_symbols.serial, + }, + path: self.source_workspace.active.clone(), + offset, + cursor, + action, + }; + let cache = self + .source_symbols + .cache + .as_ref() + .filter(|cache| self.source_snapshot_matches(cache)) + .cloned(); + let input = if cache.is_none() { + match self + .with_source_input(|input| query::admit_owned(input, query::Limits::default())) + { + Ok(input) => Some(input), + Err(error) => { + self.source_symbols.notice = Some(admission_notice(error)); + return Task::none(); + } + } + } else { + None + }; + self.source_symbols.in_flight = Some(request.job); + self.source_symbols.request = Some(request.clone()); + self.source_symbols.results = None; + self.source_symbols.notice = None; + Task::perform( + async move { + let worker_request = request.clone(); + let result = tokio::task::spawn_blocking(move || { + let snapshot = match cache { + Some(cache) => cache, + None => Arc::new( + input + .expect("admitted input for uncached request") + .capture() + .map_err(admission_notice)?, + ), + }; + let results = snapshot + .query(query::Query { + stamp: worker_request.stamp, + path: &worker_request.path, + byte_offset: worker_request.offset, + action: worker_request.action, + }) + .map_err(|error| match error { + query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(), + _ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(), + })?; + Ok((snapshot, Arc::new(results))) + }) + .await + .unwrap_or_else(|error| Err(format!("Navigation task failed: {error}"))); + nav::Completion { request, result } + }, + Message::SourceSymbolsReady, + ) + } + + pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task { + let request = completion.request; + if self.source_symbols.in_flight != Some(request.job) { + return Task::none(); + } + self.source_symbols.in_flight = None; + let diagram_query = self.source_diagram_job == Some(request.job); + if diagram_query { + self.source_diagram_job = None; + } + let wanted = self + .source_symbols + .request + .take() + .is_some_and(|current| current.job == request.job) + && request.interaction == self.source_symbols.interaction + && request.stamp.snapshot == self.source_key() + && nav::interaction::is_current( + &self.source_symbols.raw_interaction, + request.raw_interaction, + ) + && request.path == self.source_workspace.active + && request.cursor == self.source_editor.cursor() + && self.source_navigation_available(); + if !wanted { + return Task::none(); + } + match completion.result { + Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => { + self.source_symbols.cache = Some(snapshot); + self.source_symbols.results = Some(results.clone()); + if diagram_query { + if results.disposition == query::Disposition::Direct + && results.scope.validated + && results.omitted_definitions == 0 + && let [row] = results.definitions.as_slice() + && let query::Identity::Unique(identity) = row.declaration.identity + && row.membership == query::Membership::Included + && row.evidence == query::Evidence::Validated + && self.project().contains(identity.id()) + && crate::diagram_usage::alias(self.project(), identity.id()) + == row.declaration.alias + { + self.source_symbols.open = false; + return self.diagram_usage_event(crate::diagram_usage::Event::Open( + crate::diagram_usage::Context { + document: self.canvas_document, + revision: self.document.revision(), + subject: identity.id(), + }, + )); + } + self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into()); + return Task::none(); + } + if results.disposition == query::Disposition::Direct + && let Some(row) = results.definitions.first() + { + self.source_symbol_jump(&row.path, row.position, row.end_position); + } + } + Ok(_) => { + self.source_symbols.notice = Some("Source changed. Run navigation again.".into()) + } + Err(error) => self.source_symbols.notice = Some(error), + } + Task::none() + } + + fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) { + let cursor = text_editor::Cursor { + position: text_editor::Position { + line: start.line, + column: start.byte_column, + }, + selection: Some(text_editor::Position { + line: end.line, + column: end.byte_column, + }), + }; + if self + .source_workspace + .files + .get(path) + .is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor)) + { + self.source_symbols.notice = + Some("That source location is no longer available. Run navigation again.".into()); + return; + } + if let Some(buffer) = self + .source_workspace + .files + .get(&self.source_workspace.active) + { + let origin = nav::HistoryEntry { + session: self.source_workspace.session(), + path: self.source_workspace.active.clone(), + generation: buffer.generation(), + cursor: self.source_editor.cursor(), + }; + self.source_symbols + .back + .retain(|entry| entry.session == origin.session); + if self.source_symbols.back.len() == 64 { + self.source_symbols.back.remove(0); + } + self.source_symbols.back.push(origin); + } + self.source_workspace + .switch(path, self.source_editor.cursor()); + self.show_source_file(false); + self.queue_source_reveal(cursor, false); + } + + fn source_symbol_back(&mut self) { + while let Some(origin) = self.source_symbols.back.pop() { + if origin.session != self.source_workspace.session() { + continue; + } + let Some(buffer) = self.source_workspace.files.get(&origin.path) else { + self.source_symbols.notice = Some("The previous file is no longer loaded.".into()); + return; + }; + let unchanged = origin.generation == buffer.generation(); + let cursor = if unchanged { + origin.cursor + } else if origin.path == self.source_workspace.active { + self.source_editor.cursor() + } else { + buffer.cursor + }; + if !nav::exact_cursor(&buffer.text, cursor) { + self.source_symbols.notice = Some( + "The previous location changed. Open the file to choose a current position." + .into(), + ); + return; + } + self.source_workspace + .switch(&origin.path, self.source_editor.cursor()); + self.show_source_file(false); + self.queue_source_reveal(cursor, false); + self.source_symbols.notice = + (!unchanged).then(|| "This draft changed; its current cursor was restored.".into()); + return; + } + self.source_symbols.notice = Some("No previous source location in this workspace.".into()); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/app/source_symbols/tests.rs b/crates/oa-desktop/src/app/source_symbols/tests.rs new file mode 100644 index 00000000..28cbefb3 --- /dev/null +++ b/crates/oa-desktop/src/app/source_symbols/tests.rs @@ -0,0 +1,1613 @@ +use super::*; +use crate::source_workspace::Workspace; +use oa_core::source_project::SourceBundle; +use std::collections::BTreeMap; +use std::time::Instant; + +const DEFINITIONS: &str = "defs/活動.arcadia"; +const USES: &str = "uses.arcadia"; + +fn fixture_bundle() -> SourceBundle { + SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), concat!( + "arcadia 7;\r\nproject model \"Navigation\" id \"00000000-0000-0000-0000-000000000001\" {\r\n", + " include \"defs/活動.arcadia\";\r\n include \"uses.arcadia\";\r\n}\r\n" + ).into()), + (DEFINITIONS.into(), concat!( + "// 船 and exact CRLF\r\n", + "oa_activity pump \"送水 🚀\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n", + "oa_activity backup \"送水 🚀\" id \"00000000-0000-0000-0000-000000000004\" {}\r\n" + ).into()), + (USES.into(), concat!( + "// Reference names remain lexical\r\n", + "interaction first \"先\" id \"00000000-0000-0000-0000-000000000003\" {\r\n", + " description \"船 @pump\"; source @pump; target @backup;\r\n}\r\n", + "interaction second \"後\" id \"00000000-0000-0000-0000-000000000005\" {\r\n", + " source @backup; target @ // @pump is a comment\r\n pump;\r\n}\r\n" + ).into()), + ]), + } +} + +fn fixture() -> App { + let mut app = App::with_project(Project::empty()); + app.document = Document::from_bundle(fixture_bundle(), LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.page = Page::Text; + app.show_source_file(false); + show(&mut app, USES); + let text = app.source_workspace.text(); + let offset = text.find("source @pump").unwrap() + "source @".len(); + app.source_editor + .move_to(selection(text, offset, offset + 4, true)); + assert!(app.source_navigation_available()); + app +} + +fn send(app: &mut App, message: Message) { + // Dropping the returned Task does not poll its worker/lint future. Completion + // below is deterministic and still traverses the real App message handler. + let _ = app.update(message); +} + +fn symbol(app: &mut App, event: nav::SymbolEvent) { + let message = app.source_symbol_message(event); + send(app, message); +} + +fn show(app: &mut App, path: &str) { + send(app, Message::SourceFile(path.into())); + reveal(app); +} + +fn reveal(app: &mut App) { + if let Some(ticket) = app.source_reveal_ticket() { + send(app, Message::SourceViewport(Some(ticket))); + } +} + +fn position(text: &str, offset: usize) -> text_editor::Position { + assert!(text.is_char_boundary(offset)); + let prefix = &text[..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()), + } +} + +fn selection(text: &str, start: usize, end: usize, reversed: bool) -> text_editor::Cursor { + let (start, end) = (position(text, start), position(text, end)); + text_editor::Cursor { + position: if reversed { end } else { start }, + selection: Some(if reversed { start } else { end }), + } +} + +fn begin(app: &mut App, action: query::Action) -> nav::Request { + symbol(app, nav::SymbolEvent::Query(action)); + let request = app + .source_symbols + .request + .clone() + .expect("request admitted"); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + request +} + +fn completion(app: &App, request: nav::Request) -> nav::Completion { + let snapshot = Arc::new(app.with_source_input(|input| { + query::admit_owned(input, query::Limits::default()) + .unwrap() + .capture() + .unwrap() + })); + let result = Arc::new( + snapshot + .query(query::Query { + stamp: request.stamp, + path: &request.path, + byte_offset: request.offset, + action: request.action, + }) + .unwrap(), + ); + nav::Completion { + request, + result: Ok((snapshot, result)), + } +} + +fn finish(app: &mut App, action: query::Action) -> Arc { + let request = begin(app, action); + let ready = completion(app, request); + let results = ready.result.as_ref().unwrap().1.clone(); + send(app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + results +} + +#[test] +fn valid_definition_and_reference_choice_keep_whole_model_and_source_unchanged() { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let results = finish(&mut app, query::Action::Definition); + assert_eq!(results.disposition, query::Disposition::Direct); + assert_eq!(results.total_definitions, 1); + assert_eq!(app.source_workspace.active, DEFINITIONS); + assert_eq!(app.source_symbols.back.len(), 1); + let row = &results.definitions[0]; + assert_eq!(row.declaration.alias, "pump"); + assert_eq!(row.declaration.name.as_deref(), Some("送水 🚀")); + let expected = selection(&raw.files[DEFINITIONS], row.span.start, row.span.end, false); + assert_eq!( + app.source_jump, + Some(expected), + "jump waits for the matching viewport" + ); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), expected); + let uses = finish(&mut app, query::Action::References); + assert_eq!( + uses.total_references, 2, + "comment/string references must not leak into App results" + ); + assert_eq!( + app.source_workspace.active, DEFINITIONS, + "references require a choice" + ); + let second = uses.references[1].clone(); + symbol( + &mut app, + nav::SymbolEvent::Choose { + stamp: uses.stamp, + reference: true, + index: 1, + }, + ); + reveal(&mut app); + assert_eq!(app.source_workspace.active, USES); + assert_eq!( + app.source_editor.cursor(), + selection(&raw.files[USES], second.span.start, second.span.end, false) + ); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn detached_duplicate_requires_an_explicit_choice_even_when_included_source_is_validated() { + let mut app = fixture(); + let mut loaded = app.source_workspace.bundle(); + loaded.files.insert( + "detached.arcadia".into(), + "oa_activity pump \"Different source\" id \"00000000-0000-0000-0000-000000000099\" {}\r\n" + .into(), + ); + let cursor = app.source_editor.cursor(); + app.source_workspace = Workspace::new(&loaded, true); + app.source_workspace.active = USES.into(); + app.show_source_file(false); + app.source_editor.move_to(cursor); + let results = finish(&mut app, query::Action::Definition); + assert_eq!(results.total_definitions, 2); + assert_eq!(results.disposition, query::Disposition::Choices); + assert_eq!(app.source_workspace.active, USES); + assert!(app.source_symbols.back.is_empty()); + let index = results + .definitions + .iter() + .position(|row| row.path == "detached.arcadia") + .unwrap(); + assert_eq!( + results.definitions[index].membership, + query::Membership::Detached + ); + symbol( + &mut app, + nav::SymbolEvent::Choose { + stamp: results.stamp, + reference: false, + index, + }, + ); + reveal(&mut app); + assert_eq!(app.source_workspace.active, "detached.arcadia"); + assert_eq!(app.source_workspace.bundle(), loaded); + assert_eq!( + app.document.bundle().files.len(), + 3, + "choice must not adopt the detached source" + ); +} + +#[test] +fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() { + let mut app = fixture(); + let base = app.document.snapshot(); + let id = app.project().id; + app.document + .execute(Command::Rename(id, "Accepted one".into())) + .unwrap(); + let one = app.document.snapshot(); + app.document + .execute(Command::Rename(id, "Accepted two".into())) + .unwrap(); + let two = app.document.snapshot(); + assert!(app.document.undo()); + app.sync_source(); + show(&mut app, USES); + let prior_raw = app.source_workspace.text().to_owned(); + let invalid = format!("{prior_raw}\r\n? unfinished raw edit 雨\r\n"); + let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(invalid)), + ))); + app.source_workspace.applied = false; + let text = app.source_workspace.text(); + let alias = text.find("source @pump").unwrap() + "source @".len(); + app.source_editor + .move_to(selection(text, alias, alias + 4, true)); + let raw = app.source_workspace.bundle(); + let revision = app.document.revision(); + let results = finish(&mut app, query::Action::Definition); + assert_eq!(results.disposition, query::Disposition::Choices); + assert!(results.scope.partial() && !results.scope.validated); + let index = results + .definitions + .iter() + .position(|row| row.declaration.alias == "pump") + .unwrap(); + symbol( + &mut app, + nav::SymbolEvent::Choose { + stamp: results.stamp, + reference: false, + index, + }, + ); + reveal(&mut app); + symbol(&mut app, nav::SymbolEvent::Back); + reveal(&mut app); + assert!(Arc::ptr_eq(&one, &app.document.snapshot())); + assert_eq!(app.project(), &one.decoded.project); + assert_eq!(app.document.bundle(), &one.bundle); + assert_eq!(app.document.revision(), revision); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(!app.source_workspace.applied); + assert!(app.document.can_undo() && app.document.can_redo()); + // Actual round trips, not only Boolean history flags. + assert!(app.document.undo()); + assert_eq!(app.document.bundle(), &base.bundle); + assert!(app.document.redo()); + assert_eq!(app.document.bundle(), &one.bundle); + assert!(app.document.redo()); + assert_eq!(app.document.bundle(), &two.bundle); + assert!(app.source_workspace.undo(false)); + assert_eq!(app.source_workspace.text(), prior_raw); + assert!(app.source_workspace.undo(true)); + assert_eq!(app.source_workspace.bundle(), raw); +} + +#[test] +fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes() { + for change in 0..7 { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request.clone()); + let accepted = app.document.snapshot(); + match change { + 0 => show(&mut app, "project.arcadia"), + 1 => send( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), + ), + 2 => app.source_workspace.reset_navigation_session(), + 3 => app.source_editor.move_to(text_editor::Cursor { + position: text_editor::Position { line: 0, column: 0 }, + selection: None, + }), + 4 => app.source_interaction(), + 5 => { + app.source_symbols + .raw_interaction + .fetch_add(1, Ordering::SeqCst); + } + _ => app.page = Page::Diagram, + } + let after = app.source_workspace.bundle(); + let active = app.source_workspace.active.clone(); + let cursor = app.source_editor.cursor(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!( + app.source_symbols.in_flight.is_none(), + "case {change}: release worker slot" + ); + assert!( + app.source_symbols.results.is_none(), + "case {change}: stale result surfaced" + ); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.active, active); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), after); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_new_results() { + let mut app = fixture(); + let request = begin(&mut app, query::Action::References); + let ready = completion(&app, request.clone()); + let mut unrelated = ready.clone(); + unrelated.request.job = Id::new_v4(); + send(&mut app, Message::SourceSymbolsReady(unrelated)); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!(app.source_symbols.results.is_none()); + send(&mut app, Message::SourceSymbolsReady(ready.clone())); + let newer = begin(&mut app, query::Action::References); + let newer_ready = completion(&app, newer.clone()); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert_eq!(app.source_symbols.in_flight, Some(newer.job)); + send(&mut app, Message::SourceSymbolsReady(newer_ready)); + assert_eq!( + app.source_symbols.results.as_ref().unwrap().stamp, + newer.stamp + ); +} + +#[test] +fn close_during_busy_hides_panel_and_completion_does_not_reopen_it() { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request.clone()); + assert!(app.source_symbols.open); + symbol(&mut app, nav::SymbolEvent::Close); + assert!(!app.source_symbols.open); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!(app.source_symbols.request.is_none()); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(!app.source_symbols.open); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert_eq!(app.source_workspace.active, USES); + let next = begin(&mut app, query::Action::References); + assert_ne!(next.job, request.job); + assert!(app.source_symbols.open); +} + +#[test] +fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() { + let mut app = fixture(); + finish(&mut app, query::Action::Definition); + let ticket = app.source_reveal_ticket().unwrap(); + let destination = app.source_jump.unwrap(); + let before = app.source_editor.cursor(); + assert_ne!(before, destination); + for wrong in [None, Some(Id::new_v4())] { + send(&mut app, Message::SourceViewport(wrong)); + assert_eq!(app.source_editor.cursor(), before); + assert_eq!(app.source_reveal_ticket(), Some(ticket)); + } + app.source_symbols + .raw_interaction + .fetch_add(1, Ordering::SeqCst); + send(&mut app, Message::SourceViewport(Some(ticket))); + assert_eq!( + app.source_editor.cursor(), + before, + "raw input arrived before App event dispatch" + ); + app.queue_source_reveal(destination, false); + let fresh = app.source_reveal_ticket().unwrap(); + assert_ne!(ticket, fresh); + send(&mut app, Message::SourceViewport(Some(ticket))); + assert_eq!(app.source_editor.cursor(), before); + send(&mut app, Message::SourceViewport(Some(fresh))); + assert_eq!(app.source_editor.cursor(), destination); + assert!(app.source_reveal_ticket().is_none() && app.source_jump.is_none()); +} + +#[test] +fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources() { + for pending in [false, true] { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let request = begin(&mut app, query::Action::References); + let ready = completion(&app, request.clone()); + if !pending { + send(&mut app, Message::SourceSymbolsReady(ready.clone())); + assert!(app.source_symbols.results.is_some()); + } + app.source_navigation.open = true; + send(&mut app, Message::Escape); + assert!(!app.source_symbols.open && !app.source_navigation.open); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.notice.is_none()); + assert!(app.source_symbols.request.is_none()); + if pending { + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + send(&mut app, Message::SourceSymbolsReady(ready)); + } + assert!(app.source_symbols.in_flight.is_none()); + assert!(!app.source_symbols.open); + assert!(app.source_symbols.results.is_none()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(app.source_symbols.back.is_empty()); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn escape_dismisses_modal_before_underlying_source_results() { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let results = finish(&mut app, query::Action::References); + app.hosted.visible = true; + send(&mut app, Message::Escape); + assert!(!app.hosted.visible); + assert!( + app.source_reveal_ticket().is_none(), + "modal dismissal must not focus the editor" + ); + assert!(app.source_symbols.open); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + send(&mut app, Message::Escape); + assert!(!app.source_symbols.open); + assert!(app.source_symbols.results.is_none()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); +} + +#[test] +fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice() { + for changed in [false, true] { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let origin = app.source_editor.cursor(); + finish(&mut app, query::Action::Definition); + reveal(&mut app); + symbol(&mut app, nav::SymbolEvent::Close); + assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + if changed { + // Use the workspace edit API to give the origin a new generation. + show(&mut app, USES); + let text = app.source_workspace.text().to_owned(); + send( + &mut app, + Message::SourceEdit(text_editor::Action::SelectAll), + ); + send( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( + Arc::new(format!("{text}// current cursor\r\n")), + ))), + ); + show(&mut app, DEFINITIONS); + } + let raw = app.source_workspace.bundle(); + symbol(&mut app, nav::SymbolEvent::Back); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_symbols.open, changed); + assert_eq!(app.source_symbols.notice.is_some(), changed); + reveal(&mut app); + if !changed { + assert_eq!(app.source_editor.cursor(), origin); + } + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + let mut app = fixture(); + symbol(&mut app, nav::SymbolEvent::Back); + assert!(app.source_symbols.open && app.source_symbols.notice.is_some()); +} + +#[test] +fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection() { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + app.source_navigation.open = true; + app.source_navigation.query = "送水 🚀".into(); + assert!(!app.source_symbols.open); + let mut messages = captured_input_subscription_messages(&app); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); + send(&mut app, messages.remove(0)); + assert!(!app.source_navigation.open && !app.source_symbols.open); + assert_eq!(app.source_navigation.query, "送水 🚀"); + assert!(app.source_reveal_ticket().is_some()); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + assert!(captured_input_subscription_messages(&app).is_empty()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn old_find_close_cannot_cancel_a_new_file_session_or_reopened_find_intent() { + for replacement in 0..3 { + let mut app = fixture(); + if replacement == 0 { + show(&mut app, "project.arcadia"); + } + send(&mut app, Message::SourceNavigation(nav::Event::Open(false))); + let old_context = app.source_symbol_context(); + let old_instance = app.source_find_instance; + let old_close = app.source_find_close_message(); + match replacement { + 0 => { + let project = app.project().clone(); + app.reset(project, None); + app.page = Page::Text; + assert_eq!(app.source_workspace.active, old_context.path); + assert_ne!(app.source_workspace.session(), old_context.session); + } + 1 => { + show(&mut app, DEFINITIONS); + assert_eq!(app.source_workspace.session(), old_context.session); + assert_ne!(app.source_workspace.active, old_context.path); + } + 2 => { + send(&mut app, old_close.clone()); + assert!(!app.source_navigation.open); + assert!(app.source_reveal_ticket().is_some()); + assert_eq!(app.source_symbol_context(), old_context); + } + _ => unreachable!(), + } + send(&mut app, Message::SourceNavigation(nav::Event::Open(false))); + assert_ne!(app.source_find_instance, old_instance); + send( + &mut app, + Message::SourceNavigation(nav::Event::Query("Current Find 雨".into())), + ); + let text = app.source_workspace.text(); + let (offset, len) = match replacement { + 0 => (text.find("project model").unwrap() + "project ".len(), 5), + 1 => ( + text.find("oa_activity pump").unwrap() + "oa_activity ".len(), + 4, + ), + _ => (text.find("source @pump").unwrap() + "source @".len(), 4), + }; + let cursor = selection(text, offset, offset + len, true); + app.source_editor.move_to(cursor); + let request = begin(&mut app, query::Action::References); + let ready = completion(&app, request); + app.queue_source_reveal(cursor, false); + let find_instance = app.source_find_instance; + let mut stale = vec![old_close, Message::SourceNavigation(nav::Event::Close)]; + if replacement != 2 { + // Independently require the session/path guard, even with a current + // invocation token. A token check alone is not the location contract. + stale.push(Message::SourceFindClose(old_context, find_instance)); + } + assert_stale_envelopes_preserve_current(&mut app, &stale); + assert!(app.source_navigation.open); + assert_eq!(app.source_navigation.query, "Current Find 雨"); + assert_eq!(app.source_find_instance, find_instance); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert_stale_envelopes_preserve_current(&mut app, &stale); + assert!(app.source_navigation.open); + assert_eq!(app.source_navigation.query, "Current Find 雨"); + let current_close = app.source_find_close_message(); + send(&mut app, current_close); + assert!(!app.source_navigation.open); + assert_eq!(app.source_navigation.query, "Current Find 雨"); + assert!(app.source_reveal_ticket().is_some()); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + } +} + +#[tokio::test] +async fn find_close_releases_removed_input_and_guarded_focus_respects_later_input() { + use iced::advanced::{Layout, layout, widget}; + use iced::futures::StreamExt; + use iced::widget::{column, text_input}; + + #[derive(Default)] + struct Focus(Vec<(Option, bool)>); + impl widget::Operation for Focus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: iced::Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + self.0.push((id.cloned(), state.is_focused())); + } + } + fn view(app: &App, later: bool) -> iced::Element<'static, Message, iced::Theme, ()> { + let mut content = column![ + text_input("Editor", "unchanged source") + .id(app.source_editor_id()) + .on_input(|s| Message::SourceNavigation(nav::Event::Query(s))) + ]; + if app.source_navigation.open { + content = content.push( + text_input("Find", "送水") + .id("source-find") + .on_input(|s| Message::SourceNavigation(nav::Event::Query(s))), + ); + } + if later { + content = content.push( + text_input("Other", "preserve focus") + .id("later-input") + .on_input(|s| Message::SourceNavigation(nav::Event::Query(s))), + ); + } + content.into() + } + fn run( + ui: &mut iced::Element<'_, Message, iced::Theme, ()>, + tree: &mut widget::Tree, + node: &layout::Node, + mut op: Box, + ) { + loop { + ui.as_widget_mut() + .operate(tree, Layout::new(node), &(), op.as_mut()); + match op.finish() { + widget::operation::Outcome::Chain(next) => op = next, + widget::operation::Outcome::None => break, + widget::operation::Outcome::Some(()) => panic!("focus must not produce a message"), + } + } + } + for escape in [false, true] { + for later in 0..3 { + let mut app = fixture(); + app.source_navigation.open = true; + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let mut ui = view(&app, false); + let mut tree = widget::Tree::new(ui.as_widget()); + let limits = layout::Limits::new(iced::Size::ZERO, iced::Size::new(500., 250.)); + let node = ui.as_widget_mut().layout(&mut tree, &(), &limits); + run( + &mut ui, + &mut tree, + &node, + Box::new(widget::operation::focusable::focus(widget::Id::new( + "source-find", + ))), + ); + let mut focus = Focus::default(); + ui.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut focus); + assert!( + focus + .0 + .contains(&(Some(widget::Id::new("source-find")), true)) + ); + + let close = if escape { + Message::Escape + } else { + app.source_find_close_message() + }; + send(&mut app, close); + assert!(!app.source_navigation.open); + let ticket = app + .source_reveal_ticket() + .expect("closing an open Find queues focus"); + ui = view(&app, later != 0); + tree.diff(ui.as_widget()); + let node = ui.as_widget_mut().layout(&mut tree, &(), &limits); + let task = app.update(Message::SourceViewport(Some(ticket))); + if later != 0 { + run( + &mut ui, + &mut tree, + &node, + Box::new(widget::operation::focusable::focus(widget::Id::new( + "later-input", + ))), + ); + } + if later == 2 { + app.source_symbols + .raw_interaction + .fetch_add(1, Ordering::SeqCst); + } + let mut actions = + iced_runtime::task::into_stream(task).expect("guarded widget operation"); + while let Some(action) = actions.next().await { + let iced_runtime::Action::Widget(op) = action else { + panic!("focus-only task"); + }; + run(&mut ui, &mut tree, &node, op); + } + let mut focus = Focus::default(); + ui.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut focus); + assert!( + !focus + .0 + .iter() + .any(|(id, _)| id.as_ref() == Some(&widget::Id::new("source-find"))) + ); + assert!( + focus + .0 + .contains(&(Some(app.source_editor_id()), later == 0)) + ); + if later != 0 { + assert!( + focus + .0 + .contains(&(Some(widget::Id::new("later-input")), true)) + ); + } + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } + } +} + +/// Feed the real App subscription recipes the status produced by a mounted +/// TextInput. Poll only immediately available output: no timer wait, worker, +/// file dialog or App task is executed by this helper. +fn captured_input_subscription_messages(app: &App) -> Vec { + use iced::advanced::{Layout, Shell, layout, subscription, widget}; + use iced::futures::{StreamExt, stream, task::noop_waker_ref}; + use iced::keyboard::{self, key::Named}; + use std::task::{Context, Poll}; + + let mut input: iced::Element<'_, (), iced::Theme, ()> = + iced::widget::text_input("Find / modal input", "Retain exact text 雨") + .on_input(|_| ()) + .into(); + let mut tree = widget::Tree::new(input.as_widget()); + let size = iced::Size::new(300., 40.); + let node = + input + .as_widget_mut() + .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); + tree.state + .downcast_mut::>() + .focus(); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(Named::Escape), + modified_key: keyboard::Key::Named(Named::Escape), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }); + let mut input_messages = Vec::new(); + let mut shell = Shell::new(&mut input_messages); + input.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::new(iced::Point::ORIGIN, size), + ); + assert!(shell.is_event_captured()); + assert!(input_messages.is_empty()); + assert!( + !tree + .state + .downcast_ref::>() + .is_focused() + ); + let event = subscription::Event::Interaction { + window: iced::window::Id::unique(), + event, + status: iced::event::Status::Captured, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let _entered = runtime.enter(); + let mut messages = Vec::new(); + for recipe in subscription::into_recipes(app.subscription()) { + let mut output = recipe.stream(Box::pin(stream::iter([event.clone()]))); + let mut context = Context::from_waker(noop_waker_ref()); + while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) { + assert!( + !matches!(message, Message::CheckpointTick), + "timer must remain pending" + ); + messages.push(message); + } + } + messages +} + +#[test] +fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pending_work() { + for pending in [false, true] { + for modal in 0..4 { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let request = begin(&mut app, query::Action::References); + let ready = completion(&app, request.clone()); + if !pending { + send(&mut app, Message::SourceSymbolsReady(ready.clone())); + } + let results = app.source_symbols.results.clone(); + app.source_navigation.open = true; + match modal { + 0 => {} + 1 => app.hosted.visible = true, + 2 => { + app.file_prompt = Some(FilePrompt { + operation: FileOperation::Save, + path: "unchanged-destination.arcadia".into(), + overwrite: false, + }) + } + 3 => app.pending = Some(Pending::Walkthrough), + _ => unreachable!(), + } + let mut messages = captured_input_subscription_messages(&app); + assert_eq!(messages.len(), 1, "pending={pending}, modal={modal}"); + assert!(matches!(messages[0], Message::Escape)); + send(&mut app, messages.remove(0)); + if modal != 0 { + assert!(!app.hosted.visible && app.file_prompt.is_none() && app.pending.is_none()); + assert!(app.source_symbols.open && app.source_navigation.open); + match (&results, &app.source_symbols.results) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), + (None, None) => {} + _ => panic!("modal Escape must retain the underlying results"), + } + let mut messages = captured_input_subscription_messages(&app); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); + send(&mut app, messages.remove(0)); + } + assert!(!app.source_symbols.open && !app.source_navigation.open); + assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none()); + if pending { + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + send(&mut app, Message::SourceSymbolsReady(ready)); + } + assert!(app.source_symbols.in_flight.is_none()); + assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + assert!( + captured_input_subscription_messages(&app).is_empty(), + "closed panel opts out" + ); + } + } +} + +#[test] +fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() { + let mut app = fixture(); + app.source_symbols.open = true; + let project = app.project().clone(); + assert!( + app.behavior + .creation + .update(&project, crate::behavior::creation::Event::Open(Layer::Oa)) + .unwrap() + .is_none() + ); + assert!(app.behavior.creation.has_pending()); + let messages = captured_input_subscription_messages(&app); + assert_eq!( + messages.len(), + 1, + "behavior and symbols share the same bridge" + ); + assert!(matches!(messages[0], Message::Escape)); + + let mut app = App::with_project(Project::empty()); + send(&mut app, Message::AddDiagram); + send( + &mut app, + Message::Architecture(crate::app::architecture_authoring::Event::Start( + Kind::Actor, + Some(oa_core::Position { x: 120., y: 160. }), + )), + ); + assert!(app.architecture.as_ref().unwrap().inline()); + // A retained Text panel must not add a second route to inline architecture. + app.page = Page::Text; + app.source_symbols.open = true; + let messages = captured_input_subscription_messages(&app); + assert_eq!(messages.len(), 1); + assert!(matches!( + messages[0], + Message::Architecture(crate::app::architecture_authoring::Event::CapturedEscape) + )); + app.architecture = None; + app.page = Page::Behavior; + assert!( + captured_input_subscription_messages(&app).is_empty(), + "off-page symbols opt out" + ); +} + +#[test] +fn saturated_raw_clock_never_authorizes_completion_or_reveal() { + let mut app = fixture(); + app.source_symbols + .raw_interaction + .store(u64::MAX, Ordering::SeqCst); + symbol(&mut app, nav::SymbolEvent::Query(query::Action::Definition)); + if let Some(request) = app.source_symbols.request.clone() { + let ready = completion(&app, request); + send(&mut app, Message::SourceSymbolsReady(ready)); + } + assert!(app.source_symbols.results.is_none()); + assert_eq!(app.source_workspace.active, USES); + let before = app.source_editor.cursor(); + let cursor = text_editor::Cursor { + position: text_editor::Position { line: 0, column: 0 }, + selection: None, + }; + app.queue_source_reveal(cursor, false); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), before); +} + +#[test] +fn back_restores_exact_reversed_utf8_selection_and_crlf_source() { + let mut app = fixture(); + let origin = app.source_editor.cursor(); + assert!( + origin.position.column > origin.selection.unwrap().column, + "reverse selection fixture" + ); + let raw = app.source_workspace.bundle(); + assert!(raw.files[USES].contains("\r\n")); + finish(&mut app, query::Action::Definition); + reveal(&mut app); + symbol(&mut app, nav::SymbolEvent::Back); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_jump, Some(origin)); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), origin); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(app.source_symbols.back.is_empty()); +} + +#[test] +fn back_after_origin_edit_uses_its_current_cursor_instead_of_the_old_range() { + let mut app = fixture(); + let old_origin = app.source_editor.cursor(); + finish(&mut app, query::Action::Definition); + reveal(&mut app); + show(&mut app, USES); + send( + &mut app, + Message::SourceEdit(text_editor::Action::SelectAll), + ); + send( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( + Arc::new("// changed\n@pump\n".into()), + ))), + ); + let current = text_editor::Cursor { + position: text_editor::Position { line: 1, column: 5 }, + selection: Some(text_editor::Position { line: 1, column: 1 }), + }; + app.source_editor.move_to(current); + show(&mut app, DEFINITIONS); + assert_ne!(old_origin, current); + let changed = app.source_workspace.bundle(); + symbol(&mut app, nav::SymbolEvent::Back); + assert_eq!(app.source_jump, Some(current)); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), current); + assert_eq!(app.source_workspace.bundle(), changed); + assert!( + app.source_symbols + .notice + .as_deref() + .is_some_and(|notice| notice.contains("changed")) + ); +} + +#[test] +fn reset_retains_worker_slot_until_old_completion_then_admits_new_session_query() { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request.clone()); + let old_session = app.source_workspace.session(); + let project = app.project().clone(); + app.reset(project, None); + assert_ne!(app.source_workspace.session(), old_session); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!(app.source_symbols.request.is_none()); + app.page = Page::Text; + let text = app.source_workspace.text(); + let alias = text.find("project model").unwrap() + "project ".len(); + app.source_editor + .move_to(selection(text, alias, alias + 5, false)); + symbol(&mut app, nav::SymbolEvent::Query(query::Action::Definition)); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!( + app.source_symbols.request.is_none(), + "reset must not create a second active worker" + ); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + let fresh = begin(&mut app, query::Action::Definition); + assert_eq!( + fresh.stamp.snapshot.workspace, + app.source_workspace.session() + ); + assert_ne!(fresh.stamp.snapshot.workspace, old_session); + let ready = completion(&app, fresh); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.results.is_some()); +} + +#[test] +fn back_keeps_the_latest_64_actual_locations_without_reusing_dropped_history() { + let mut app = fixture(); + let text: String = (0..80).map(|line| format!("// 行 {line}\r\n")).collect(); + let loaded = SourceBundle { + entry: "trail.arcadia".into(), + files: BTreeMap::from([("trail.arcadia".into(), text)]), + }; + app.source_workspace = Workspace::new(&loaded, false); + app.show_source_file(false); + let cursor = |line| text_editor::Cursor { + position: text_editor::Position { line, column: 0 }, + selection: Some(text_editor::Position { line, column: 1 }), + }; + app.source_editor.move_to(cursor(0)); + for line in 1..=66 { + app.source_symbol_jump( + "trail.arcadia", + query::Position { + line, + byte_column: 0, + }, + query::Position { + line, + byte_column: 1, + }, + ); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor(line)); + } + assert_eq!(app.source_symbols.back.len(), 64); + assert_eq!(app.source_symbols.back.first().unwrap().cursor, cursor(2)); + for line in (2..=65).rev() { + symbol(&mut app, nav::SymbolEvent::Back); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor(line)); + } + assert!(app.source_symbols.back.is_empty()); + symbol(&mut app, nav::SymbolEvent::Back); + assert_eq!(app.source_editor.cursor(), cursor(2)); + assert_eq!(app.source_workspace.bundle(), loaded); +} + +/// Replay frozen widget messages without rebuilding their context. In a real +/// Iced batch these can follow a reset or file switch before the next view diff. +fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message]) { + let interaction = app.source_symbols.interaction; + let raw_interaction = app.source_symbols.raw_interaction.load(Ordering::SeqCst); + let request = app.source_symbols.request.as_ref().map(|request| { + ( + request.job, + request.interaction, + request.raw_interaction, + request.stamp, + request.path.clone(), + request.offset, + request.cursor, + request.action, + ) + }); + let in_flight = app.source_symbols.in_flight; + let serial = app.source_symbols.serial; + let reveal = app.source_symbols.reveal.as_ref().map(|reveal| { + ( + reveal.ticket, + reveal.interaction, + reveal.raw_interaction, + reveal.snapshot, + reveal.path.clone(), + ) + }); + let jump = (app.source_jump, app.source_select_line); + let back: Vec<_> = app + .source_symbols + .back + .iter() + .map(|entry| { + ( + entry.session, + entry.path.clone(), + entry.generation, + entry.cursor, + ) + }) + .collect(); + let results = app.source_symbols.results.clone(); + let cache = app.source_symbols.cache.clone(); + let panel = (app.source_symbols.open, app.source_symbols.notice.clone()); + let key = app.source_key(); + let active = app.source_workspace.active.clone(); + let cursor = app.source_editor.cursor(); + let editor = app.source_editor.text(); + let raw = app.source_workspace.bundle(); + let accepted = app.document.snapshot(); + let history = ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty(), + ); + for message in messages.iter().cloned() { + send(app, message); + assert_eq!( + app.source_symbols.interaction, interaction, + "reject stale context before invalidating current intent" + ); + assert_eq!( + app.source_symbols.raw_interaction.load(Ordering::SeqCst), + raw_interaction + ); + assert_eq!( + app.source_symbols.request.as_ref().map(|request| ( + request.job, + request.interaction, + request.raw_interaction, + request.stamp, + request.path.clone(), + request.offset, + request.cursor, + request.action, + )), + request + ); + assert_eq!( + (app.source_symbols.in_flight, app.source_symbols.serial), + (in_flight, serial) + ); + assert_eq!( + app.source_symbols.reveal.as_ref().map(|reveal| ( + reveal.ticket, + reveal.interaction, + reveal.raw_interaction, + reveal.snapshot, + reveal.path.clone(), + )), + reveal + ); + assert_eq!((app.source_jump, app.source_select_line), jump); + assert_eq!( + app.source_symbols + .back + .iter() + .map(|entry| ( + entry.session, + entry.path.clone(), + entry.generation, + entry.cursor, + )) + .collect::>(), + back + ); + assert_eq!( + (app.source_symbols.open, app.source_symbols.notice.clone()), + panel + ); + match (&results, &app.source_symbols.results) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), + (None, None) => {} + _ => panic!("stale envelope replaced or cleared current results"), + } + match (&cache, &app.source_symbols.cache) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), + (None, None) => {} + _ => panic!("stale envelope replaced or cleared current snapshot"), + } + assert_eq!(app.source_key(), key); + assert_eq!(app.source_workspace.active, active); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_editor.text(), editor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.project(), &accepted.decoded.project); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert_eq!( + ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty() + ), + history + ); + } +} + +fn stale_envelope_trial(reset: bool) { + let mut app = fixture(); + if reset { + show(&mut app, "project.arcadia"); + let text = app.source_workspace.text(); + let alias = text.find("project model").unwrap() + "project ".len(); + app.source_editor + .move_to(selection(text, alias, alias + 5, false)); + } + let old_results = finish(&mut app, query::Action::References); + let old_context = app.source_symbol_context(); + let old_messages = [ + app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)), + app.source_symbol_message(nav::SymbolEvent::Back), + app.source_symbol_message(nav::SymbolEvent::Close), + app.source_symbol_message(nav::SymbolEvent::Choose { + stamp: old_results.stamp, + reference: false, + index: 0, + }), + ]; + if reset { + let project = app.project().clone(); + app.reset(project, None); + app.page = Page::Text; + let new_context = app.source_symbol_context(); + assert_eq!( + new_context.path, old_context.path, + "same path isolates the session guard" + ); + assert_ne!(new_context.session, old_context.session); + } else { + show(&mut app, DEFINITIONS); + let new_context = app.source_symbol_context(); + assert_ne!(new_context.path, old_context.path); + assert_eq!( + new_context.session, old_context.session, + "file switch isolates the path guard" + ); + assert_eq!(app.source_key(), old_results.stamp.snapshot); + } + let text = app.source_workspace.text(); + let (alias, length) = if reset { + (text.find("project model").unwrap() + "project ".len(), 5) + } else { + ( + text.find("oa_activity pump").unwrap() + "oa_activity ".len(), + 4, + ) + }; + let cursor = selection(text, alias, alias + length, false); + app.source_editor.move_to(cursor); + // A real jump creates useful current Back history before the next request. + let path = app.source_workspace.active.clone(); + app.source_symbol_jump( + &path, + query::Position { + line: cursor.position.line, + byte_column: cursor.position.column, + }, + query::Position { + line: cursor.selection.unwrap().line, + byte_column: cursor.selection.unwrap().column, + }, + ); + reveal(&mut app); + let current = begin(&mut app, query::Action::References); + let ready = completion(&app, current.clone()); + app.queue_source_reveal(cursor, false); + assert_stale_envelopes_preserve_current(&mut app, &old_messages); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert_eq!( + app.source_symbols.results.as_ref().unwrap().stamp, + current.stamp + ); + assert!(app.source_symbols.in_flight.is_none()); + // Replay once more with no busy-slot fallback and visible current results. + assert_stale_envelopes_preserve_current(&mut app, &old_messages); + let interaction = app.source_symbols.interaction; + symbol(&mut app, nav::SymbolEvent::Close); + assert_ne!(app.source_symbols.interaction, interaction); + assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + let next = begin(&mut app, query::Action::References); + assert_ne!( + next.job, current.job, + "a freshly wrapped current-context Query remains usable" + ); + let ready = completion(&app, next); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.results.is_some()); +} + +#[test] +fn old_symbol_widget_envelopes_after_reset_preserve_new_same_path_intent() { + stale_envelope_trial(true); +} + +#[test] +fn old_symbol_widget_envelopes_after_file_switch_preserve_same_session_intent() { + stale_envelope_trial(false); +} + +#[test] +#[ignore = "manual 100-module latency/process-memory trial; run alone with --ignored --nocapture --test-threads=1"] +fn source_symbols_100_modules_1000_definitions_10000_uses() { + let mut files = BTreeMap::new(); + let mut entry = "arcadia 7; project performance \"Current text trial\" {\n".to_owned(); + for module in 0..100 { + let path = format!("modules/m{module:03}.arcadia"); + entry.push_str(&format!(" include \"{path}\";\n")); + let mut text = String::new(); + for declaration in 0..10 { + text.push_str(&format!("oa_process p_{module}_{declaration} \"Process {module}/{declaration} 雨\" {{ steps [")); + for reference in 0..10 { + if reference != 0 { + text.push_str(", "); + } + text.push_str("@p_0_0"); + } + text.push_str("]; description \"@p_0_0 is literal documentation\"; }\n"); + } + files.insert(path, text); + } + entry.push_str("}\n"); + files.insert("project.arcadia".into(), entry); + let original = files.clone(); + let input_bytes: usize = files.values().map(String::len).sum(); + let drafts: Vec<_> = files + .iter() + .map(|(path, text)| query::DraftFile { path, text }) + .collect(); + let key = query::SnapshotKey { + workspace: Id::new_v4(), + revision: 1, + }; + let memory_before = process_memory(); + let started = Instant::now(); + let owned = query::admit_owned( + query::Input { + key, + entry: "project.arcadia", + files: &drafts, + applied: false, + validation: None, + }, + query::Limits::default(), + ) + .unwrap(); + let admitted = started.elapsed(); + let started = Instant::now(); + let snapshot = owned.capture().unwrap(); + let captured = started.elapsed(); + let path = "modules/m000.arcadia"; + let offset = files[path].find("p_0_0").unwrap(); + let started = Instant::now(); + let result = snapshot + .query(query::Query { + stamp: query::QueryStamp { + snapshot: key, + serial: 1, + }, + path, + byte_offset: offset, + action: query::Action::References, + }) + .unwrap(); + let queried = started.elapsed(); + let memory_after = process_memory(); + assert_eq!(files.len(), 101, "100 modules plus entry"); + assert_eq!( + files + .values() + .map(|text| text.matches("oa_process ").count()) + .sum::(), + 1000 + ); + assert_eq!(result.total_definitions, 1); + assert_eq!(result.total_references, 10_000); + assert_eq!(result.references.len() + result.omitted_references, 10_000); + assert!( + result.definitions.len() + result.references.len() <= query::Limits::default().result_rows + ); + assert!(result.scope.closure_complete && !result.scope.validated); + assert_eq!(files, original); + eprintln!( + "100-module current-draft trial: bytes={input_bytes}, admission={admitted:?}, capture={captured:?}, references={queried:?}, rows={}, omitted={}, process_memory_before={memory_before:?}, process_memory_after={memory_after:?}", + result.references.len(), + result.omitted_references + ); + // This fixture intentionally has process-to-process placeholder steps. It + // measures lexical draft navigation, not engineering validity or GUI latency. +} + +#[cfg(not(windows))] +fn process_memory() -> Option<(usize, usize)> { + None +} + +#[cfg(windows)] +fn process_memory() -> Option<(usize, usize)> { + #[repr(C)] + #[derive(Default)] + struct Counters { + cb: u32, + page_fault_count: u32, + peak_working_set: usize, + working_set: usize, + quota_peak_paged_pool: usize, + quota_paged_pool: usize, + quota_peak_non_paged_pool: usize, + quota_non_paged_pool: usize, + pagefile_usage: usize, + peak_pagefile_usage: usize, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> *mut std::ffi::c_void; + fn K32GetProcessMemoryInfo( + process: *mut std::ffi::c_void, + counters: *mut Counters, + size: u32, + ) -> i32; + } + let size = u32::try_from(std::mem::size_of::()).unwrap(); + let mut counters = Counters { + cb: size, + ..Counters::default() + }; + // Windows writes one correctly sized PROCESS_MEMORY_COUNTERS for this + // process. These are process-wide resident/peak bytes, not exact allocations. + let ok = unsafe { K32GetProcessMemoryInfo(GetCurrentProcess(), &mut counters, size) }; + (ok != 0).then_some((counters.working_set, counters.peak_working_set)) +} + +#[test] +fn combined_modal_completion_releases_worker_without_navigation_or_model_changes() { + for modal in 0..5 { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + match modal { + 0 => app.busy = true, + 1 => app.hosted.visible = true, + 2 => app.pending = Some(Pending::Walkthrough), + 3 => { + app.file_prompt = Some(FilePrompt { + operation: FileOperation::Save, + path: "unchanged.arcadia".into(), + overwrite: false, + }); + } + 4 => { + let project = app.project().clone(); + assert!( + app.behavior + .creation + .update(&project, crate::behavior::creation::Event::Open(Layer::Oa)) + .unwrap() + .is_none() + ); + assert!(app.behavior.has_pending()); + } + _ => unreachable!(), + } + assert!(!app.source_navigation_available()); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none(), "modal {modal}"); + assert!(app.source_symbols.request.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert!(app.source_reveal_ticket().is_none()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconciliation() { + let mut project = Project::empty(); + let record = project + .add_element(Kind::DataType, Layer::Oa, "Record", None) + .unwrap(); + let Some(SharedBody::DataType { definition, .. }) = + project.elements.get_mut(&record).unwrap().shared_mut() + else { + panic!("record datatype fixture"); + }; + *definition = DataTypeDefinition::Record { supertype: None }; + let mut app = App::with_project(project); + send(&mut app, Message::Select(record)); + let path = "/body/Oa/Shared/DataType/definition/Record/supertype"; + let open = app.draft.as_ref().unwrap().reference_open( + app.project(), + path, + iced::Rectangle { + x: 100., + y: 100., + width: 240., + height: 32., + }, + ); + let alias = app.project().elements[&record].alias.clone(); + let source_path = app + .source_workspace + .files + .iter() + .find(|(_, buffer)| buffer.text.contains(&format!(" {alias} "))) + .map(|(path, _)| path.clone()) + .unwrap(); + app.page = Page::Text; + show(&mut app, &source_path); + let text = app.source_workspace.text(); + let offset = text.find(&format!(" {alias} ")).unwrap() + 1; + app.source_editor + .move_to(selection(text, offset, offset + alias.len(), false)); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request); + app.page = Page::Diagram; + send(&mut app, Message::FieldReferenceOpen(open)); + assert!(app.reference_input_active(), "{}", app.status); + // Reproduce a queued completion observing a retained chooser before normal + // reconciliation catches the changed page. Neither may authorize navigation. + app.page = Page::Text; + assert!(!app.source_navigation_available()); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_reveal_ticket().is_none()); + assert!( + app.reference_input_active(), + "internal completion must precede chooser reconciliation and modal dispatch" + ); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); +} diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 0a5b9d95..e3cf8a0d 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -1157,6 +1157,17 @@ struct Line { label_at: Point, dashed: bool, } +impl Line { + fn label_bounds(&self) -> Rectangle { + Rectangle::new( + self.label_at, + Size::new( + (self.label.chars().count() as f32 * 6.8 + 8.).min(225.), + 25., + ), + ) + } +} struct Scene { boxes: Vec, lines: Vec, @@ -1691,17 +1702,7 @@ impl Scene { .or_else(|| { self.lines .iter() - .find(|line| { - !line.label.is_empty() - && Rectangle::new( - line.label_at, - Size::new( - (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), - 25., - ), - ) - .contains(point) - }) + .find(|line| !line.label.is_empty() && line.label_bounds().contains(point)) .map(|line| line.id) }) // The generous message tolerance must not steal the exposed diff --git a/crates/oa-desktop/src/behavior/canvas_workspace.rs b/crates/oa-desktop/src/behavior/canvas_workspace.rs index b6f4a97a..9c4af104 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace.rs @@ -21,6 +21,7 @@ pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool { && !app.delete_pending && app.pending.is_none() && app.model_menu.is_none() + && app.diagram_usages.is_none() && !app.reference_input_active() } diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 70e9ce6a..a0583746 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -5,6 +5,7 @@ use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; pub(crate) mod focus; +mod usage; mod workspace; pub(crate) use workspace::NAME_INPUT; pub use workspace::{Shelf, view}; diff --git a/crates/oa-desktop/src/behavior/scenario/usage.rs b/crates/oa-desktop/src/behavior/scenario/usage.rs new file mode 100644 index 00000000..824375b3 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/usage.rs @@ -0,0 +1,207 @@ +//! Navigation is derived from the same opaque-reference scene that is painted. +use super::*; +use crate::diagram_usage::{Surface, Target, Usage}; + +/// The renderer collapses whitespace before shortening. Require at least one +/// actual character of this field, rather than an ellipsis at its start. +fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool { + if value.trim().is_empty() { + return false; + } + let normalized = full.split_whitespace().collect::>().join(" "); + let normalized_prefix = prefix.split_whitespace().collect::>().join(" "); + let start = normalized_prefix.len() + + usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty()); + let painted = crate::views::short(full, 32); + let retained = normalized + .chars() + .zip(painted.chars()) + .take_while(|(a, b)| a == b) + .map(|(c, _)| c.len_utf8()) + .sum::(); + start < retained +} + +fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { + let mut result = Vec::new(); + let mut add = |id, bounds, role: &str| { + // Activation bars share the occurrence identity with a labelled message; + // they must not inflate its target count. + if !result + .iter() + .any(|u: &Usage| u.target == Target::ScenarioObject(id)) + { + result.push(Usage { + subject, + surface: Surface::Scenario(owner), + target: Target::ScenarioObject(id), + role: role.into(), + bounds, + }); + } + }; + for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) { + let Some(element) = p.elements.get(&item.id) else { + continue; + }; + if subject == item.id { + // A fragment's selectable header is useful even when its frame is + // much larger than the viewport. + let mut bounds = item.rect; + if item.marker == Some(Kind::Occurrence) { + bounds.height = bounds.height.min(38.); + } + add(item.id, bounds, "scenario object"); + } + let represented = match element.shared() { + Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")), + Some(SharedBody::Occurrence { body, .. }) => match body { + OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")), + OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")), + OccurrenceBody::SequenceReference { sequence } => { + sequence.map(|id| (id, "process reference")) + } + OccurrenceBody::ScenarioUse { scenario, .. } => { + scenario.map(|id| (id, "scenario reference")) + } + // An incomplete message fallback box only paints the occurrence + // name and a repair hint, not its exchange or operation. + _ => None, + }, + _ => None, + }; + if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) { + let _ = id; + add(item.id, item.rect, role); + } + } + for line in scene.lines.iter().filter(|line| !line.label.is_empty()) { + let Some(element) = p.elements.get(&line.id) else { + continue; + }; + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + }) = element.shared() + else { + continue; + }; + let bounds = line.label_bounds(); + if subject == line.id || *exchange == Some(subject) { + add(line.id, bounds, "message occurrence"); + } + // Replies derive their operation from the exact call, matching the + // operation label formatter rather than scanning raw reference fields. + if let Ok(operations::OperationResolution::Selected(operation)) = + operations::resolve(p, line.id) + && let Some(label) = crate::data_values::operation_label(p, element) + && let Some(prefix) = line.label.strip_suffix(&label) + { + let allocation = &p.relations[&operation.allocation]; + let operation_prefix = if matches!( + element.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + .. + }, + .. + }) + ) { + "Reply operation from Call: " + } else { + "Operation: " + }; + let fields = [ + (allocation.source(), format!("{prefix}{operation_prefix}")), + ( + allocation.target(), + format!( + "{prefix}{operation_prefix}{} / ", + p.named(allocation.source()) + ), + ), + ( + allocation.id, + format!( + "{prefix}{operation_prefix}{} / {} · ", + p.named(allocation.source()), + p.named(allocation.target()) + ), + ), + ]; + for (id, prefix) in fields { + if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) { + add(line.id, bounds, "operation label"); + } + } + } + } + if subject == owner { + result.insert( + 0, + Usage { + subject, + surface: Surface::Scenario(owner), + target: Target::Whole, + role: "scenario".into(), + bounds: Rectangle::new(Point::ORIGIN, scene.size), + }, + ); + } + result +} + +impl State { + pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec { + if !p + .elements + .get(&owner) + .is_some_and(|e| e.kind() == Kind::Scenario) + { + return vec![]; + } + usages(p, owner, subject, &Scene::sequence(p, owner, subject)) + } + + pub(crate) fn reveal_usage( + &mut self, + p: &Project, + document: Id, + owner: Id, + target: &Target, + ) -> bool { + if self.draft.is_some() + || !p + .elements + .get(&owner) + .is_some_and(|e| e.kind() == Kind::Scenario) + { + return false; + } + let id = match target { + Target::Whole => owner, + Target::ScenarioObject(id) => *id, + _ => return false, + }; + let Some(usage) = self + .diagram_usages(p, owner, id) + .into_iter() + .find(|u| &u.target == target) + else { + return false; + }; + // Preserve an existing mounted viewport across enter's owner reset. + let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); + self.enter(document, owner); + self.presentation.viewport = Some(viewport); + self.canvas_tool = Some((owner, CanvasTool::Select)); + let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport); + self.presentation.zoom(Some(zoom)); + self.presentation.pan = pan; + true + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/behavior/scenario/usage/tests.rs b/crates/oa-desktop/src/behavior/scenario/usage/tests.rs new file mode 100644 index 00000000..fdead29d --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/usage/tests.rs @@ -0,0 +1,276 @@ +use super::*; + +fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let layer = p.elements[&owner].layer(); + let id = p + .add_element(Kind::Occurrence, layer, name, Some(owner)) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }; + if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() { + if !steps.contains(&id) { + steps.push(id); + } + } + id +} + +fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet { + state + .diagram_usages(p, owner, subject) + .into_iter() + .filter_map(|u| match u.target { + Target::ScenarioObject(id) => Some(id), + _ => None, + }) + .collect() +} + +#[test] +fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() { + for (layer, work_kind, participant_kind) in [ + (Layer::Oa, Kind::Activity, Kind::Entity), + (Layer::Sa, Kind::Function, Kind::SystemActor), + ] { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::Scenario, layer, "Caller", None) + .unwrap(); + let other = p + .add_element(Kind::Scenario, layer, "Definition", None) + .unwrap(); + let work = p.add_element(work_kind, layer, "Respond", None).unwrap(); + let unused = p.add_element(work_kind, layer, "Respond", None).unwrap(); + let participant = p + .add_element(participant_kind, layer, "Operator", None) + .unwrap(); + let lanes = ["First role", "Second role"].map(|name| { + let id = p + .add_element(Kind::Lifeline, layer, name, Some(owner)) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(participant), + }; + id + }); + let first = add_occurrence( + &mut p, + owner, + "First action", + OccurrenceBody::Action { + action: Some(work), + lifeline: Some(lanes[0]), + }, + ); + let fragment = add_occurrence( + &mut p, + owner, + "Decision", + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + ); + let branch = add_occurrence( + &mut p, + fragment, + "When ready", + OccurrenceBody::Branch { + guard: "ready".into(), + occurrences: vec![], + }, + ); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = p.elements.get_mut(&fragment).unwrap().shared_mut() + { + branches.push(branch); + } + let nested = add_occurrence( + &mut p, + branch, + "Again", + OccurrenceBody::Action { + action: Some(work), + lifeline: Some(lanes[1]), + }, + ); + let inside = add_occurrence( + &mut p, + other, + "Hidden inside definition", + OccurrenceBody::Action { + action: Some(unused), + lifeline: None, + }, + ); + let use_a = add_occurrence( + &mut p, + owner, + "Use A", + OccurrenceBody::ScenarioUse { + scenario: Some(other), + bindings: vec![], + }, + ); + let use_b = add_occurrence( + &mut p, + owner, + "Use B", + OccurrenceBody::ScenarioUse { + scenario: Some(other), + bindings: vec![], + }, + ); + let before = p.clone(); + let state = State::default(); + assert_eq!( + targets(&state, &p, owner, work), + BTreeSet::from([first, nested]) + ); + assert_eq!( + targets(&state, &p, owner, participant), + BTreeSet::from(lanes) + ); + assert_eq!( + targets(&state, &p, owner, other), + BTreeSet::from([use_a, use_b]) + ); + assert!(targets(&state, &p, owner, unused).is_empty()); + assert!(targets(&state, &p, owner, inside).is_empty()); + assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch])); + assert_eq!( + state.diagram_usages(&p, owner, owner)[0].target, + Target::Whole + ); + assert_eq!(p, before); + } +} + +#[test] +fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() { + let f = super::super::direct_tests::fixture(); + let mut p = f.project; + let mut state = State::default(); + assert_eq!( + targets(&state, &p, f.owner, f.exchange), + BTreeSet::from([f.call, f.reply]) + ); + let before = p.clone(); + let target = Target::ScenarioObject(f.reply); + state.viewport(Size::new(900., 500.)); + assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target)); + let usage = state + .diagram_usages(&p, f.owner, f.reply) + .into_iter() + .find(|u| u.target == target) + .unwrap(); + let zoom = state + .presentation + .scale(Scene::sequence(&p, f.owner, f.reply).size); + let centered = Point::new( + usage.bounds.center_x() * zoom, + usage.bounds.center_y() * zoom, + ) + state.presentation.pan; + assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01); + assert!(zoom >= 0.5); + assert_eq!(p, before); + // Keeping an orphan semantic occurrence does not keep its painted target. + p.elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() + .retain(|id| *id != f.reply); + let camera = state.camera_boundary(); + assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target)); + assert!(camera == state.camera_boundary()); + assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange))); + // A message whose endpoints are incomplete paints only its repair box. + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { receiver, .. }, + .. + }) = p.elements.get_mut(&f.call).unwrap().shared_mut() + { + *receiver = None; + } + assert!(targets(&state, &p, f.owner, f.exchange).is_empty()); + assert_eq!( + targets(&state, &p, f.owner, f.call), + BTreeSet::from([f.call]) + ); +} + +#[test] +fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() { + assert!(label_field_visible( + "1. call · X · Operation: I / O", + "1. call · X · Operation: ", + "I" + )); + assert!(!label_field_visible( + "1234567890123456789012345678901Ixyz", + "1234567890123456789012345678901", + "Ixyz" + )); + assert!(!label_field_visible( + "A very long occurrence label · Operation: I", + "A very long occurrence label · Operation: ", + "I" + )); + assert!(label_field_visible( + "call\n\t· Operation: 界", + "call\n\t· Operation: ", + "界" + )); + assert!(!label_field_visible("Operation: ", "Operation: ", "")); +} + +#[test] +fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() { + let mut p = persistence::decode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + ))) + .unwrap(); + let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap(); + let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30)); + let (interface, item) = ( + p.relations[&allocation].source(), + p.relations[&allocation].target(), + ); + for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] { + p.elements.get_mut(&id).unwrap().name = name.into(); + } + p.relations.get_mut(&exchange).unwrap().name = "x".into(); + p.relations.get_mut(&allocation).unwrap().name = "A".into(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = p.elements.get_mut(&call).unwrap().shared_mut() + { + *operation = Some(OperationUse { + allocation: Some(allocation), + }); + } + assert!(matches!( + operations::resolve(&p, call), + Ok(operations::OperationResolution::Selected(_)) + )); + let state = State::default(); + let before = p.clone(); + assert_eq!( + targets(&state, &p, owner, interface), + BTreeSet::from([call]) + ); + assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call])); + assert!(targets(&state, &p, owner, allocation).is_empty()); + assert_eq!(p, before); + p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into(); + assert!(targets(&state, &p, owner, interface).is_empty()); + assert!(targets(&state, &p, owner, item).is_empty()); +} diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs index 52afffaa..7959b399 100644 --- a/crates/oa-desktop/src/behavior/state_canvas.rs +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -27,6 +27,8 @@ pub struct StateCanvas<'a> { pub diagram: Id, pub context: Context, pub selected: Id, + /// Presentation-only navigation marker; never an authoring placement. + pub navigation_focus: Option, pub tool: Tool, pub zoom: f32, pub pan: Vector, @@ -456,6 +458,171 @@ fn state_action_label(project: &Project, relation: &Relation) -> String { format!("{phase} / {}", project.named(target)) } +/// Match a typed label field by its structural offset, never by a display-name +/// substring (names may duplicate or also occur in free-form guards). +fn retained_label_field(full: &str, painted: &str, start: usize) -> bool { + start + < full + .chars() + .zip(painted.chars()) + .take_while(|(a, b)| a == b) + .map(|(c, _)| c.len_utf8()) + .sum::() +} + +fn transition_label_role( + project: &Project, + relation: &Relation, + subject: Id, + width: f32, +) -> Option<&'static str> { + let full = routing::state_transition_summary(project, relation); + transition_label_role_in_text(project, relation, subject, &short(&full, width, 10.)) +} + +fn transition_label_role_in_text( + project: &Project, + relation: &Relation, + subject: Id, + painted: &str, +) -> Option<&'static str> { + let RelationBody::StateTransition { + transition_kind, + triggers, + effects, + effect, + .. + } = &relation.body + else { + return None; + }; + if project.named(subject).trim().is_empty() { + return None; + } + let full = routing::state_transition_summary(project, relation); + let mut offset = format!("{transition_kind:?} · ").len(); + for id in triggers { + if *id == subject && retained_label_field(&full, painted, offset) { + return Some("transition trigger"); + } + offset += project.named(*id).len() + " OR ".len(); + } + if !effects.is_empty() { + // The canonical routing summary appends typed effects before optional + // authored effect prose. Derive its offset from that exact suffix. + let mut suffix = format!( + " / {}", + effects + .iter() + .map(|id| project.named(*id)) + .collect::>() + .join(", ") + ); + if !effect.is_empty() { + suffix.push_str(&format!(" / {effect}")); + } + if let Some(prefix) = full.strip_suffix(&suffix) { + offset = prefix.len() + " / ".len(); + for id in effects { + if *id == subject && retained_label_field(&full, painted, offset) { + return Some("transition effect"); + } + offset += project.named(*id).len() + ", ".len(); + } + } + } + None +} + +/// Actual state compartments and painted transition-reference labels. Generic +/// projected node/edge identities are handled by the shared saved-view query. +pub(crate) fn diagram_usages( + project: &Project, + diagram: &Diagram, + view: &ProjectedView, + subject: Id, +) -> Vec { + use crate::diagram_usage::{Surface, Target, Usage}; + if diagram.projection.state_machine().is_none() { + return vec![]; + } + let mut result = Vec::new(); + for node in &view.nodes { + for (relation, bounds) in state_action_rows(project, node) { + let label = state_action_label(project, relation); + let painted = short(&label, bounds.width - 12., 11.); + let role = if relation.id == subject { + Some("state action") + } else if relation.target() == subject + && retained_label_field( + &label, + &painted, + label.len() - project.named(subject).len(), + ) + { + Some("state action work") + } else { + None + }; + if let Some(role) = role { + result.push(Usage { + subject, + surface: Surface::Saved(diagram.id), + target: Target::StateRow { + placement: node.id, + relation: relation.id, + }, + role: role.into(), + bounds, + }); + } + } + for (relation, bounds) in internal_rows(project, node) { + let role = if relation.id == subject { + Some("internal transition") + } else { + transition_label_role(project, relation, subject, bounds.width - 12.) + }; + if let Some(role) = role { + result.push(Usage { + subject, + surface: Surface::Saved(diagram.id), + target: Target::StateRow { + placement: node.id, + relation: relation.id, + }, + role: role.into(), + bounds, + }); + } + } + } + for edge in routing::route_diagram(project, diagram, view) + .edges + .values() + { + let Some(relation) = project.relations.get(&edge.id) else { + continue; + }; + if edge.id == subject || edge.label.text.is_empty() { + continue; + } + // Routing already shortens external summaries before the canvas applies + // its width limit. Compare against precisely that doubly shortened text. + let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.); + if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) { + result.push(Usage { + subject, + surface: Surface::Saved(diagram.id), + target: Target::Edge(edge.id), + role: role.into(), + bounds: crate::diagram_usage::edge_focus_bounds(edge), + }); + } + } + result +} + impl StateCanvas<'_> { fn scale(&self) -> f32 { if self.zoom.is_finite() { @@ -1216,6 +1383,14 @@ impl canvas::Program for StateCanvas<'_> { overlay.with_clip(content, |frame| { frame.translate(self.offset()); frame.scale(self.scale()); + if let Some(bounds) = self.navigation_focus { + frame.stroke( + &Path::rectangle(bounds.position(), bounds.size()), + Stroke::default() + .with_color(style::TEAL) + .with_width(2.5 / self.scale()), + ); + } if let Some(at) = world && self.editable { @@ -1659,6 +1834,189 @@ mod tests { } } + #[test] + fn diagram_usage_state_rows_and_label_references_follow_actual_projection() { + use crate::diagram_usage::Target; + let mut f = fixture(); + let work = f + .project + .add_element(Kind::Function, Layer::Sa, "Act", None) + .unwrap(); + let event = f + .project + .add_element(Kind::Event, Layer::Sa, "E", None) + .unwrap(); + let unused = f + .project + .add_element(Kind::Event, Layer::Sa, "E", None) + .unwrap(); + let action = f + .project + .relate(RelationKind::StateAction, "Entry", f.a, work); + let internal = f + .project + .relate(RelationKind::StateTransition, "Internal", f.a, f.a); + let external = f + .project + .relate(RelationKind::StateTransition, "External", f.a, f.b); + for (id, kind) in [ + (internal, TransitionKind::Internal), + (external, TransitionKind::External), + ] { + if let RelationBody::StateTransition { + transition_kind, + triggers, + effects, + .. + } = &mut f.project.relations.get_mut(&id).unwrap().body + { + *transition_kind = kind; + *triggers = vec![event]; + *effects = vec![work]; + } + } + let before = f.project.clone(); + let diagram = &f.project.diagrams[&f.diagram]; + let view = projection::project_diagram(&f.project, diagram); + let placement = view.nodes.iter().find(|n| n.element == f.a).unwrap().id; + let action_target = Target::StateRow { + placement, + relation: action, + }; + let internal_target = Target::StateRow { + placement, + relation: internal, + }; + assert_eq!( + diagram_usages(&f.project, diagram, &view, action)[0].target, + action_target + ); + assert_eq!( + diagram_usages(&f.project, diagram, &view, internal)[0].target, + internal_target + ); + let events = diagram_usages(&f.project, diagram, &view, event); + assert_eq!(events.len(), 2); + assert!(events.iter().any(|u| u.target == internal_target)); + assert!(events.iter().any(|u| u.target == Target::Edge(external))); + assert!(diagram_usages(&f.project, diagram, &view, unused).is_empty()); + // External edge identity belongs to the shared saved-view adapter. + assert!(diagram_usages(&f.project, diagram, &view, external).is_empty()); + let work_usages = diagram_usages(&f.project, diagram, &view, work); + assert!(work_usages.iter().any(|u| u.target == action_target)); + assert!(work_usages.iter().any(|u| u.target == internal_target)); + let row = work_usages + .iter() + .find(|u| u.target == action_target) + .unwrap(); + let canvas = canvas(&f, Tool::Select, 1.); + let scene = canvas.scene(&State::default()).unwrap(); + assert_eq!( + canvas.hit(&scene, row.bounds.center()), + Some(Hit::StateAction(action)) + ); + assert_eq!(f.project, before); + f.project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&placement) + .unwrap() + .collapsed = true; + let diagram = &f.project.diagrams[&f.diagram]; + let folded = projection::project_diagram(&f.project, diagram); + assert!(diagram_usages(&f.project, diagram, &folded, action).is_empty()); + assert!(diagram_usages(&f.project, diagram, &folded, internal).is_empty()); + } + + #[test] + fn diagram_usage_transition_labels_do_not_match_prose_or_fields_beyond_cutoff() { + let mut f = fixture(); + let early = f + .project + .add_element(Kind::Event, Layer::Sa, "A", None) + .unwrap(); + let late = f + .project + .add_element(Kind::Event, Layer::Sa, "B", None) + .unwrap(); + let relation = f + .project + .relate(RelationKind::StateTransition, "Transition", f.a, f.b); + if let RelationBody::StateTransition { + triggers, guard, .. + } = &mut f.project.relations.get_mut(&relation).unwrap().body + { + *triggers = vec![early, late]; + *guard = "B".into(); + } + let relation = &f.project.relations[&relation]; + assert_eq!( + transition_label_role(&f.project, relation, early, 84.), + Some("transition trigger") + ); + assert_eq!(transition_label_role(&f.project, relation, late, 84.), None); + assert_eq!( + transition_label_role(&f.project, relation, late, 800.), + Some("transition trigger") + ); + assert!(!retained_label_field("1234A678", "1234…", 4)); + } + + #[test] + fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() { + use crate::diagram_usage::Target; + let mut f = fixture(); + let first = f + .project + .add_element(Kind::Event, Layer::Sa, &"A".repeat(35), None) + .unwrap(); + let second = f + .project + .add_element(Kind::Event, Layer::Sa, "Second", None) + .unwrap(); + let external = f + .project + .relate(RelationKind::StateTransition, "External", f.a, f.b); + let internal = f + .project + .relate(RelationKind::StateTransition, "Internal", f.a, f.a); + for (id, kind) in [ + (external, TransitionKind::External), + (internal, TransitionKind::Internal), + ] { + if let RelationBody::StateTransition { + transition_kind, + triggers, + .. + } = &mut f.project.relations.get_mut(&id).unwrap().body + { + *transition_kind = kind; + *triggers = vec![first, second]; + } + } + let diagram = &f.project.diagrams[&f.diagram]; + let view = projection::project_diagram(&f.project, diagram); + let routed = routing::route_diagram(&f.project, diagram, &view); + let edge = &routed.edges[&external]; + let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.); + // Independent painted-text oracle: 11 prefix characters + 35 As, then + // only the space before OR survives the routing limit. + assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35))); + assert!(!painted.contains("Second")); + let usages = diagram_usages(&f.project, diagram, &view, second); + assert!(!usages.iter().any(|u| u.target == Target::Edge(external))); + assert!(usages.iter().any( + |u| matches!(u.target, Target::StateRow { relation, .. } if relation == internal) + )); + assert!( + diagram_usages(&f.project, diagram, &view, first) + .iter() + .any(|u| u.target == Target::Edge(external)) + ); + } + fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> { StateCanvas { project: &f.project, @@ -1670,6 +2028,7 @@ mod tests { epoch: 3, }, selected: f.machine, + navigation_focus: None, tool, zoom, pan: Vector::new(20., 30.), diff --git a/crates/oa-desktop/src/behavior/state_view.rs b/crates/oa-desktop/src/behavior/state_view.rs index 9334efea..0c17c400 100644 --- a/crates/oa-desktop/src/behavior/state_view.rs +++ b/crates/oa-desktop/src/behavior/state_view.rs @@ -169,6 +169,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem diagram: id, context, selected: app.selected, + navigation_focus: app + .diagram_focus(crate::diagram_usage::Surface::Saved(id)) + .map(|usage| usage.bounds), tool: state.tool, zoom: state.zoom, pan: state.pan, diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index 19cf1a4e..be0bcc0c 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -5,6 +5,7 @@ use iced::widget::{Column, text_input}; use std::collections::BTreeSet; pub(crate) mod contextual; mod direct; +mod usages; mod view; use direct::Input; pub(crate) use direct::{ @@ -908,6 +909,13 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> { }) .collect() } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PreviewLink { + relation: Id, + source: OccurrenceEndpoint, + target: OccurrenceEndpoint, +} + struct GraphNode { id: Id, rect: Rectangle, @@ -916,26 +924,98 @@ struct GraphNode { control: Option, source: Option, preview: Vec<(OccurrenceEndpoint, String)>, - preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>, + preview_links: Vec, preview_total: usize, } impl GraphNode { + fn preview_link_points(&self, link: &PreviewLink) -> Option<[Point; 2]> { + if ![&link.source, &link.target] + .into_iter() + .all(|endpoint| self.preview.iter().any(|(shown, _)| shown == endpoint)) + { + return None; + } + let a = self.endpoint_rect(&link.source); + let b = self.endpoint_rect(&link.target); + Some([ + Point::new(a.x + a.width, a.center_y()), + Point::new(b.x, b.center_y()), + ]) + } + + fn preview_link_visible_segment(&self, link: &PreviewLink) -> Option<[Point; 2]> { + visible_preview_segment( + self.preview_link_points(link)?, + (0..self.preview.len()).map(|index| self.preview_rect(index)), + ) + } + + fn preview_rect(&self, index: usize) -> Rectangle { + Rectangle::new( + Point::new( + self.rect.x + 12. + (index % 2) as f32 * 125., + self.rect.y + 118. + (index / 2) as f32 * 44., + ), + Size::new(113., 32.), + ) + } + fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle { self.preview .iter() .position(|(candidate, _)| candidate == endpoint) - .map(|i| { - Rectangle::new( - Point::new( - self.rect.x + 12. + (i % 2) as f32 * 125., - self.rect.y + 118. + (i / 2) as f32 * 44., - ), - Size::new(113., 32.), - ) - }) + .map(|index| self.preview_rect(index)) .unwrap_or(self.rect) } } + +/// Preview tiles are painted after their arrows. Focus only a remaining line +/// segment, excluding opaque tile interiors and their one-pixel borders. +fn visible_preview_segment( + [a, b]: [Point; 2], + tiles: impl Iterator, +) -> Option<[Point; 2]> { + let delta = b - a; + let length = (delta.x * delta.x + delta.y * delta.y).sqrt(); + if !length.is_finite() || length <= 0.5 { + return None; + } + let mut hidden = Vec::new(); + for tile in tiles { + let mut low = 0_f32; + let mut high = 1_f32; + for (origin, step, min, max) in [ + (a.x, delta.x, tile.x - 1., tile.x + tile.width + 1.), + (a.y, delta.y, tile.y - 1., tile.y + tile.height + 1.), + ] { + if step.abs() <= f32::EPSILON { + if origin < min || origin > max { + high = -1.; + break; + } + } else { + let first = (min - origin) / step; + let last = (max - origin) / step; + low = low.max(first.min(last)); + high = high.min(first.max(last)); + } + } + if high > low { + hidden.push((low, high)); + } + } + hidden.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut cursor = 0_f32; + let mut best = (0_f32, 0_f32); + for (low, high) in hidden.into_iter().chain([(1., 1.)]) { + if low - cursor > best.1 - best.0 { + best = (cursor, low); + } + cursor = cursor.max(high); + } + ((best.1 - best.0) * length > 0.5).then(|| [a + delta * best.0, a + delta * best.1]) +} + struct GraphEdge { id: Id, source: Id, @@ -984,6 +1064,8 @@ struct Graph<'a> { ordered: bool, size: Size, selected: Id, + selected_preview: Option, + selected_preview_link: Option, scale: f32, } impl<'a> Graph<'a> { @@ -1132,17 +1214,16 @@ impl<'a> Graph<'a> { occurrence: r.target(), }) }) - .map(|r| { - ( - OccurrenceEndpoint { - references: vec![e.id], - occurrence: r.source(), - }, - OccurrenceEndpoint { - references: vec![e.id], - occurrence: r.target(), - }, - ) + .map(|r| PreviewLink { + relation: r.id, + source: OccurrenceEndpoint { + references: vec![e.id], + occurrence: r.source(), + }, + target: OccurrenceEndpoint { + references: vec![e.id], + occurrence: r.target(), + }, }) .collect(); } @@ -1317,6 +1398,8 @@ impl<'a> Graph<'a> { ordered: !order.is_empty(), size, selected, + selected_preview: None, + selected_preview_link: None, scale: 1., } } @@ -1546,39 +1629,41 @@ impl canvas::Program for Graph<'_> { let mini: BTreeMap<_, _> = n .preview .iter() - .enumerate() - .map(|(i, (id, _))| { - ( - id.clone(), - Rectangle::new( - Point::new( - n.rect.x + 12. + (i % 2) as f32 * 125., - n.rect.y + 118. + (i / 2) as f32 * 44., - ), - Size::new(113., 32.), - ), - ) - }) + .map(|(id, _)| (id.clone(), n.endpoint_rect(id))) .collect(); - for (a, b) in &n.preview_links { - let a = mini[a]; - let b = mini[b]; + for link in &n.preview_links { + let Some(points) = n.preview_link_points(link) else { + continue; + }; + let selected = self.selected_preview_link.as_ref() == Some(link); draw_arrow( &mut f, - &[ - Point::new(a.x + a.width, a.center_y()), - Point::new(b.x, b.center_y()), - ], - style::MUTED, - 1., + &points, + if selected { style::TEAL } else { style::MUTED }, + if selected { 3. } else { 1. }, ); } for (id, name) in &n.preview { let r = mini[id]; - f.fill_rectangle(r.position(), r.size(), Color::WHITE); + let selected = self.selected_preview.as_ref() == Some(id); + f.fill_rectangle( + r.position(), + r.size(), + if selected { + Color::from_rgb8(224, 247, 242) + } else { + Color::WHITE + }, + ); f.stroke( &Path::rectangle(r.position(), r.size()), - Stroke::default().with_color(style::BOUNDARY), + Stroke::default() + .with_color(if selected { + style::TEAL + } else { + style::BOUNDARY + }) + .with_width(if selected { 2.5 } else { 1. }), ); draw_text( &mut f, diff --git a/crates/oa-desktop/src/behavior/topology/usages.rs b/crates/oa-desktop/src/behavior/topology/usages.rs new file mode 100644 index 00000000..d4bef565 --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/usages.rs @@ -0,0 +1,274 @@ +//! Read-only usages of the actual process graph, including bounded reuse tiles. +use super::*; +use crate::diagram_usage::{Surface, Target, Usage}; + +/// The only presentation inputs needed by a background usage query. Drafts, +/// focus, camera, history and widget state never cross the worker boundary. +#[derive(Clone)] +pub(crate) struct UsageSnapshot { + identity: Option<(Id, Id)>, + expanded: BTreeSet, + contextual_expanded: BTreeSet, +} + +impl UsageSnapshot { + pub(crate) fn into_query_state(self) -> State { + State { + identity: self.identity, + expanded: self.expanded, + contextual_expanded: self.contextual_expanded, + ..State::default() + } + } +} + +impl State { + pub(crate) fn usage_snapshot(&self) -> UsageSnapshot { + UsageSnapshot { + identity: self.identity, + expanded: self.expanded.clone(), + contextual_expanded: self.contextual_expanded.clone(), + } + } +} + +fn owner_exists(p: &Project, owner: Id) -> bool { + p.elements + .get(&owner) + .is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) +} + +fn occurrence_role(p: &Project, occurrence: Id, subject: Id) -> Option<&'static str> { + let e = p.elements.get(&occurrence)?; + if occurrence == subject { + return Some("contribution"); + } + match e.shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::Action { + action: Some(id), .. + }, + .. + } if *id == subject => Some("action occurrence"), + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(id), .. + }, + .. + } if *id == subject => Some("exchange occurrence"), + SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence: Some(id) }, + .. + } if *id == subject => Some("reused process / chain"), + _ => None, + } +} + +impl Graph<'_> { + fn usage_bounds(&self, target: &Target) -> Option { + match target { + Target::Whole => Some(Rectangle::new(Point::ORIGIN, self.size)), + Target::ProcessOccurrence(endpoint) => { + let root = endpoint + .references + .first() + .copied() + .unwrap_or(endpoint.occurrence); + let node = self.nodes.iter().find(|node| node.id == root)?; + if endpoint.references.is_empty() { + Some(node.rect) + } else { + // endpoint_rect intentionally has a proxy fallback for other callers; + // a usage promises an actual tile and must never take that fallback. + node.preview + .iter() + .any(|(shown, _)| shown == endpoint) + .then(|| node.endpoint_rect(endpoint)) + } + } + Target::ProcessLink(id) => { + let edge = self.edges.iter().find(|edge| edge.id == *id)?; + if !edge.guard.is_empty() { + Some(edge.label) + } else { + let segment = edge + .points + .windows(2) + .nth(edge.points.len().saturating_sub(1) / 2)?; + let center = Point::new( + (segment[0].x + segment[1].x) / 2., + (segment[0].y + segment[1].y) / 2., + ); + Some(Rectangle::new( + center - iced::Vector::new(16., 16.), + Size::new(32., 32.), + )) + } + } + Target::ProcessPreviewLink { + relation, + source, + target, + } => { + let node = self + .nodes + .iter() + .find(|node| source.references.first() == Some(&node.id))?; + let link = node.preview_links.iter().find(|link| { + link.relation == *relation && link.source == *source && link.target == *target + })?; + let [a, b] = node.preview_link_visible_segment(link)?; + Some(Rectangle::new( + Point::new((a.x + b.x) / 2. - 16., (a.y + b.y) / 2. - 16.), + Size::new(32., 32.), + )) + } + _ => None, + } + } +} + +impl State { + /// Inactive owners use the same default preview policy as entering their canvas. + fn usage_graph<'a>(&self, p: &'a Project, owner: Id) -> Graph<'a> { + if self.identity.is_some_and(|(_, current)| current == owner) { + Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded) + } else { + Graph::new(p, owner, owner) + } + } + + pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec { + if !owner_exists(p, owner) + || !(p.elements.contains_key(&subject) || p.relations.contains_key(&subject)) + { + return vec![]; + } + let graph = self.usage_graph(p, owner); + let mut usages = vec![]; + let mut add = |target: Target, role: &str| { + if let Some(bounds) = graph.usage_bounds(&target) { + usages.push(Usage { + subject, + surface: Surface::Process(owner), + target, + role: role.into(), + bounds, + }); + } + }; + if subject == owner { + add(Target::Whole, "process / chain canvas"); + } + for node in &graph.nodes { + if let Some(role) = occurrence_role(p, node.id, subject) { + add( + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: node.id, + }), + role, + ); + } + for (endpoint, _) in &node.preview { + if let Some(role) = occurrence_role(p, endpoint.occurrence, subject) { + add( + Target::ProcessOccurrence(endpoint.clone()), + &format!("reused {role}"), + ); + } + } + for link in &node.preview_links { + if link.relation == subject { + add( + Target::ProcessPreviewLink { + relation: link.relation, + source: link.source.clone(), + target: link.target.clone(), + }, + "reused sequence link", + ); + } + } + } + for edge in &graph.edges { + let role = if edge.id == subject { + Some("process link") + } else { + match p.relations.get(&edge.id).map(|r| &r.body) { + Some(RelationBody::ContextualLink { + purpose: ContextualLinkPurpose::Exchange { exchange }, + .. + }) if *exchange == subject => Some("assembly exchange"), + Some(RelationBody::ContextualLink { + purpose: ContextualLinkPurpose::CommonWork { work }, + .. + }) if *work == subject => Some("common work"), + _ => None, + } + }; + if let Some(role) = role { + add(Target::ProcessLink(edge.id), role); + } + } + usages + } + + /// Returns success, not whether the camera changed. App must separately guard + /// its other drafts and revalidate the queried subject's current usage. + pub(crate) fn reveal_usage( + &mut self, + p: &Project, + document: Id, + owner: Id, + target: &Target, + ) -> bool { + if !owner_exists(p, owner) || self.draft.is_some() { + return false; + } + let same = self.identity == Some((document, owner)); + let graph = if same { + self.usage_graph(p, owner) + } else { + Graph::new(p, owner, owner) + }; + let Some(bounds) = graph.usage_bounds(target) else { + return false; + }; + let address = if let Target::ProcessOccurrence(endpoint) = target { + let Ok(resolved) = + oa_core::topology_context::TopologyIndex::new(p).resolve(owner, endpoint) + else { + return false; + }; + (!endpoint.references.is_empty()).then_some(resolved.address) + } else { + None + }; + let viewport = self.viewport.unwrap_or(Size::new(500., 300.)); + self.enter(document, owner); + if !same { + self.contextual_expanded.clear(); + } + self.selected_context = address; + self.tool = CanvasTool::Select; + self.viewport = Some(viewport); + let fit = ((viewport.width - 48.).max(1.) / bounds.width.max(1.)) + .min((viewport.height - 48.).max(1.) / bounds.height.max(1.)); + let scale = if matches!(target, Target::Whole) { + fit.clamp(0.0001, 1.) + } else { + fit.clamp(0.6, 1.25) + }; + self.zoom = Some(scale); + self.fit = false; + self.pan = iced::Vector::new( + viewport.width / 2. - bounds.center_x() * scale, + viewport.height / 2. - bounds.center_y() * scale, + ); + true + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/behavior/topology/usages/tests.rs b/crates/oa-desktop/src/behavior/topology/usages/tests.rs new file mode 100644 index 00000000..1109058d --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/usages/tests.rs @@ -0,0 +1,439 @@ +use super::*; + +fn add(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let e = occurrence(p, owner, name, body); + let id = e.id; + *p = execute_batch(p, &[Command::CreateElement(e)]).unwrap(); + id +} + +fn reused_fixture() -> (Project, Id, Id, Id, [Id; 2], Vec) { + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut p = f.p; + let sequence = p + .make_element(Kind::Process, Layer::Oa, "Reviewed checks", None) + .unwrap(); + let sequence_id = sequence.id; + p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap(); + let mut leaves = vec![]; + for name in [ + "Check one", + "Check two", + "Check three", + "Check four", + "Check five", + ] { + leaves.push(add( + &mut p, + sequence_id, + name, + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + )); + } + let first = add( + &mut p, + f.owner, + "First invocation", + OccurrenceBody::SequenceReference { + sequence: Some(sequence_id), + }, + ); + let second = add( + &mut p, + f.owner, + "Second invocation", + OccurrenceBody::SequenceReference { + sequence: Some(sequence_id), + }, + ); + (p, f.owner, sequence_id, f.work[0], [first, second], leaves) +} + +#[test] +fn process_usages_exclude_endpoint_coincidence_and_keep_actual_occurrences_and_links() { + for layer in [Layer::Oa, Layer::Sa] { + let f = super::super::direct_tests::fixture(layer); + let mut p = f.p; + let state = State::default(); + // Both exchange endpoints have Action contributions, but there is no + // Exchange occurrence and no exchange arrow in this process. + assert!(state.diagram_usages(&p, f.owner, f.exchange).is_empty()); + let direct = state.diagram_usages(&p, f.owner, f.work[0]); + assert_eq!(direct.len(), 1); + assert_eq!( + direct[0].target, + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: f.nodes[0] + }) + ); + let link = state.diagram_usages(&p, f.owner, f.link); + assert_eq!(link.len(), 1); + assert_eq!(link[0].target, Target::ProcessLink(f.link)); + let first = add( + &mut p, + f.owner, + "First exchange", + OccurrenceBody::Exchange { + exchange: Some(f.exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + ); + let second = add( + &mut p, + f.owner, + "Second exchange", + OccurrenceBody::Exchange { + exchange: Some(f.exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + ); + let usages = state.diagram_usages(&p, f.owner, f.exchange); + assert_eq!(usages.len(), 2); + assert_eq!( + usages + .iter() + .map(|usage| usage.target.clone()) + .collect::>(), + vec![ + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: first + }), + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: second + }), + ] + ); + } +} + +#[test] +fn process_usages_keep_caller_paths_and_exclude_unshown_fifth_preview() { + let (p, owner, sequence, work, calls, leaves) = reused_fixture(); + let before = p.clone(); + let mut state = State::default(); + let leaf = state.diagram_usages(&p, owner, leaves[0]); + assert_eq!(leaf.len(), 2); + for (usage, call) in leaf.iter().zip(calls) { + assert_eq!( + usage.target, + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![call], + occurrence: leaves[0] + }) + ); + } + assert_ne!(leaf[0].bounds, leaf[1].bounds); + assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty()); + assert_eq!(state.diagram_usages(&p, owner, sequence).len(), 2); + // One local action and four tiles in each of two caller boxes. + assert_eq!(state.diagram_usages(&p, owner, work).len(), 9); + state.enter(Id::new_v4(), owner); + assert!(state.expand(&p, owner, calls[0])); + let fifth = state.diagram_usages(&p, owner, leaves[4]); + assert_eq!(fifth.len(), 1); + assert_eq!( + fifth[0].target, + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![calls[0]], + occurrence: leaves[4] + }) + ); + // Another active owner must not leak its expansion to this queried canvas. + state.enter(Id::new_v4(), sequence); + assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty()); + assert_eq!(p, before); +} + +#[test] +fn process_usage_reveal_validates_before_state_changes_and_centers_the_exact_tile() { + let (p, owner, _, _, calls, leaves) = reused_fixture(); + let before = p.clone(); + let document = Id::new_v4(); + let mut state = State::default(); + state.enter(document, owner); + state.viewport(Size::new(880., 460.)); + state.pan = iced::Vector::new(-3000., -900.); + let hidden = Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![calls[0]], + occurrence: leaves[4], + }); + let prior = ( + state.identity, + state.pan, + state.zoom, + state.fit, + state.selected_context.clone(), + ); + assert!(!state.reveal_usage(&p, document, owner, &hidden)); + assert_eq!( + ( + state.identity, + state.pan, + state.zoom, + state.fit, + state.selected_context.clone() + ), + prior + ); + assert!(!state.reveal_usage(&p, document, Id::new_v4(), &Target::Whole)); + assert_eq!( + ( + state.identity, + state.pan, + state.zoom, + state.fit, + state.selected_context.clone() + ), + prior + ); + let endpoint = OccurrenceEndpoint { + references: vec![calls[1]], + occurrence: leaves[0], + }; + let target = Target::ProcessOccurrence(endpoint.clone()); + assert!(state.reveal_usage(&p, document, owner, &target)); + assert_eq!(state.selected_context.as_ref().unwrap().endpoint, endpoint); + let bounds = state + .diagram_usages(&p, owner, leaves[0]) + .into_iter() + .find(|usage| usage.target == target) + .unwrap() + .bounds; + let scale = state.zoom.unwrap(); + assert!(scale >= 0.6); + assert!((bounds.center_x() * scale + state.pan.x - 440.).abs() < 0.01); + assert!((bounds.center_y() * scale + state.pan.y - 230.).abs() < 0.01); + assert!(state.reveal_usage(&p, document, owner, &Target::Whole)); + assert!(state.selected_context.is_none()); + state + .update(&p, Event::Start(owner, Tool::Work, None)) + .unwrap(); + let draft = state.recovery_snapshot().unwrap(); + let camera = (state.pan, state.zoom, state.fit); + assert!(!state.reveal_usage(&p, document, owner, &target)); + assert_eq!(state.recovery_snapshot().unwrap(), draft); + assert_eq!((state.pan, state.zoom, state.fit), camera); + assert_eq!(p, before); +} + +#[test] +fn process_contextual_purpose_and_painted_preview_links_keep_distinct_identities() { + let (mut p, owner, sequence, work, calls, leaves) = reused_fixture(); + let source = OccurrenceEndpoint { + references: vec![calls[0]], + occurrence: leaves[0], + }; + let target = OccurrenceEndpoint { + references: vec![calls[1]], + occurrence: leaves[0], + }; + let relation = Relation { + id: Id::new_v4(), + alias: "same_check".into(), + name: "One common check".into(), + description: String::new(), + body: RelationBody::ContextualLink { + owner, + source, + target, + purpose: ContextualLinkPurpose::CommonWork { work }, + }, + }; + let contextual = relation.id; + p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap(); + let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else { + unreachable!() + }; + let local_link = relation.id; + p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap(); + let state = State::default(); + assert!( + state + .diagram_usages(&p, owner, work) + .iter() + .any(|usage| usage.target == Target::ProcessLink(contextual) + && usage.role == "common work") + ); + assert_eq!(state.diagram_usages(&p, owner, contextual).len(), 1); + let previews = state.diagram_usages(&p, owner, local_link); + assert_eq!(previews.len(), 2); + for (usage, call) in previews.iter().zip(calls) { + assert_eq!( + usage.target, + Target::ProcessPreviewLink { + relation: local_link, + source: OccurrenceEndpoint { + references: vec![call], + occurrence: leaves[0] + }, + target: OccurrenceEndpoint { + references: vec![call], + occurrence: leaves[1] + }, + } + ); + } + assert_eq!(state.diagram_usages(&p, sequence, local_link).len(), 1); +} + +#[test] +fn preview_arrow_focus_is_in_the_painted_gap_and_hidden_endpoints_never_fallback() { + let (mut p, owner, _, _, calls, leaves) = reused_fixture(); + let Command::CreateRelation(shown) = link(&p, leaves[0], leaves[1], "next") else { + unreachable!() + }; + let shown_id = shown.id; + p = execute_batch(&p, &[Command::CreateRelation(shown)]).unwrap(); + let Command::CreateRelation(hidden) = link(&p, leaves[0], leaves[4], "later") else { + unreachable!() + }; + let hidden_id = hidden.id; + p = execute_batch(&p, &[Command::CreateRelation(hidden)]).unwrap(); + let before = p.clone(); + let document = Id::new_v4(); + let mut state = State::default(); + state.enter(document, owner); + state.viewport(Size::new(800., 460.)); + let rows = state.diagram_usages(&p, owner, shown_id); + assert_eq!(rows.len(), 2); + let graph = Graph::new(&p, owner, owner); + for (row, call) in rows.iter().zip(calls) { + let node = graph.nodes.iter().find(|n| n.id == call).unwrap(); + // The painter places 113px tiles 125px apart, beginning 12px in. + // The 12px gap is centered 131px into the caller, 134px below its top. + assert_eq!( + row.bounds.center(), + Point::new(node.rect.x + 131., node.rect.y + 134.) + ); + assert!( + node.preview + .iter() + .all(|(e, _)| !node.endpoint_rect(e).contains(row.bounds.center())) + ); + } + assert_ne!(rows[0].bounds, rows[1].bounds); + assert!(state.diagram_usages(&p, owner, hidden_id).is_empty()); + assert!(state.reveal_usage(&p, document, owner, &rows[1].target)); + assert!( + state.selected_context.is_none(), + "an arrow is not an occurrence authoring context" + ); + let zoom = state.zoom.unwrap(); + assert!((rows[1].bounds.center_x() * zoom + state.pan.x - 400.).abs() < 0.01); + assert!((rows[1].bounds.center_y() * zoom + state.pan.y - 230.).abs() < 0.01); + assert!(state.expand(&p, owner, calls[0])); + let expanded = state.diagram_usages(&p, owner, hidden_id); + assert_eq!(expanded.len(), 1); + assert!( + matches!(&expanded[0].target, Target::ProcessPreviewLink { source, .. } + if source.references == vec![calls[0]]) + ); + let mut stale = rows[1].target.clone(); + if let Target::ProcessPreviewLink { source, .. } = &mut stale { + source.references = vec![Id::new_v4()]; + } + let prior = (state.pan, state.zoom, state.fit); + assert!(!state.reveal_usage(&p, document, owner, &stale)); + assert_eq!((state.pan, state.zoom, state.fit), prior); + assert_eq!(p, before); +} + +#[test] +fn visible_preview_segment_excludes_all_opaque_tiles_for_reverse_and_cross_row_arrows() { + let tiles = [ + Rectangle::new(Point::new(12., 118.), Size::new(113., 32.)), + Rectangle::new(Point::new(137., 118.), Size::new(113., 32.)), + Rectangle::new(Point::new(12., 162.), Size::new(113., 32.)), + Rectangle::new(Point::new(137., 162.), Size::new(113., 32.)), + ]; + for points in [ + [Point::new(250., 134.), Point::new(12., 134.)], + [Point::new(125., 134.), Point::new(137., 178.)], + [Point::new(250., 178.), Point::new(12., 134.)], + ] { + let [a, b] = visible_preview_segment(points, tiles.into_iter()).unwrap(); + for fraction in [0.1, 0.5, 0.9] { + let point = a + (b - a) * fraction; + assert!(tiles.iter().all(|tile| !tile.contains(point))); + } + } + assert!( + visible_preview_segment( + [Point::new(30., 134.), Point::new(100., 134.)], + tiles.into_iter() + ) + .is_none() + ); + assert!( + visible_preview_segment([Point::new(1., 1.), Point::new(1., 1.)], tiles.into_iter()) + .is_none() + ); +} + +#[test] +fn expanded_nested_tiles_do_not_invent_links_the_preview_painter_does_not_emit() { + let (mut p, owner, sequence, work, calls, _) = reused_fixture(); + let nested = p + .make_element(Kind::Process, Layer::Oa, "Nested checks", None) + .unwrap(); + let nested_id = nested.id; + p = execute_batch(&p, &[Command::CreateElement(nested)]).unwrap(); + let leaves: Vec<_> = ["Nested one", "Nested two"] + .into_iter() + .map(|name| { + add( + &mut p, + nested_id, + name, + OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + ) + }) + .collect(); + let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else { + unreachable!() + }; + let relation_id = relation.id; + p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap(); + let reference = add( + &mut p, + sequence, + "Nested invocation", + OccurrenceBody::SequenceReference { + sequence: Some(nested_id), + }, + ); + let mut state = State::default(); + state.enter(Id::new_v4(), owner); + state.expanded.insert(calls[0]); + state.contextual_expanded.insert(OccurrenceEndpoint { + references: vec![calls[0]], + occurrence: reference, + }); + let shown = state.diagram_usages(&p, owner, leaves[0]); + assert_eq!(shown.len(), 1); + assert!( + matches!(&shown[0].target, Target::ProcessOccurrence(endpoint) + if endpoint.references == vec![calls[0], reference]) + ); + assert!(state.diagram_usages(&p, owner, relation_id).is_empty()); + assert_eq!(state.diagram_usages(&p, nested_id, relation_id).len(), 1); +} diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 7fe6fbcc..55bdfd44 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -183,6 +183,32 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele &state.expanded, &state.contextual_expanded, ); + graph.selected_preview = state + .qualified_selection(p, owner) + .filter(|address| { + address + .endpoint + .references + .first() + .copied() + .unwrap_or(address.endpoint.occurrence) + == app.selected + }) + .map(|address| address.endpoint.clone()); + graph.selected_preview_link = app + .diagram_focus(crate::diagram_usage::Surface::Process(owner)) + .and_then(|usage| match &usage.target { + crate::diagram_usage::Target::ProcessPreviewLink { + relation, + source, + target, + } => Some(PreviewLink { + relation: *relation, + source: source.clone(), + target: target.clone(), + }), + _ => None, + }); graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.)); graph.camera_pan = state.pan; graph.pending_insertion = state diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index a53a1650..8ebb4356 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -47,6 +47,8 @@ pub struct DiagramCanvas<'a> { pub view_index: Option<&'a oa_core::view_options::ViewIndex>, pub diagram: Id, pub selected: Id, + /// Presentation-only navigation marker; never used by hit tests or edits. + pub navigation_focus: Option, pub zoom: f32, pub pan: Vector, pub context: Context, @@ -1323,6 +1325,14 @@ impl canvas::Program for DiagramCanvas<'_> { f.fill(&Path::circle(center, 1.5), color); } } + if let Some(bounds) = self.navigation_focus { + f.stroke( + &Path::rectangle(bounds.position(), bounds.size()), + Stroke::default() + .with_color(style::TEAL) + .with_width(2.5 / self.zoom), + ); + } if let Some(edge) = scene.routed.edges.get(&self.selected) && !edge.custom_suspended { @@ -2038,6 +2048,7 @@ mod tests { allocation: None, diagram, selected: p.id, + navigation_focus: None, zoom: 1., pan: Vector::ZERO, }; @@ -2113,6 +2124,7 @@ mod tests { allocation: None, diagram, selected: p.id, + navigation_focus: None, zoom: 0.6, pan: Vector::new(10., 20.), }; @@ -2229,6 +2241,7 @@ mod tests { diagram, context, selected: p.id, + navigation_focus: None, selected_bend: None, zoom, pan: Vector::new(20., 30.), @@ -2399,6 +2412,7 @@ mod tests { view_index: None, diagram, selected: edge, + navigation_focus: None, zoom, pan: Vector::new(150., 300.), context: Context { @@ -2475,6 +2489,7 @@ mod tests { view_index: None, diagram, selected: edge, + navigation_focus: None, zoom: 0.5, pan: Vector::new(150., 300.), context: Context { @@ -2572,6 +2587,7 @@ mod tests { view_index: None, diagram, selected: edge, + navigation_focus: None, zoom: 1., pan: Vector::new(150., 300.), context: Context { @@ -2700,6 +2716,7 @@ mod tests { view_index: None, diagram, selected: edge, + navigation_focus: None, zoom: 0.7, pan: Vector::new(350., 350.), context: Context { @@ -2754,6 +2771,7 @@ mod tests { view_index: None, diagram, selected: p.id, + navigation_focus: None, zoom: 1., pan: Vector::new(100., 400.), context: Context { @@ -2828,6 +2846,7 @@ mod tests { view_index: None, diagram, selected: edge, + navigation_focus: None, zoom: 1., pan: Vector::new(100., 400.), context: Context { @@ -2861,6 +2880,7 @@ mod tests { view_index: None, diagram, selected, + navigation_focus: None, zoom: 1., pan: Vector::ZERO, context: Context { diff --git a/crates/oa-desktop/src/diagram/lifecycle_tests.rs b/crates/oa-desktop/src/diagram/lifecycle_tests.rs index f04a7a1f..0cf38cec 100644 --- a/crates/oa-desktop/src/diagram/lifecycle_tests.rs +++ b/crates/oa-desktop/src/diagram/lifecycle_tests.rs @@ -57,6 +57,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> { view_index: None, diagram: app.active_diagram.unwrap(), selected: app.selected, + navigation_focus: None, zoom: 1., pan: app.pan, context: app.route_context().unwrap(), diff --git a/crates/oa-desktop/src/diagram/pointer_tests.rs b/crates/oa-desktop/src/diagram/pointer_tests.rs index cc1c7a62..c48d4730 100644 --- a/crates/oa-desktop/src/diagram/pointer_tests.rs +++ b/crates/oa-desktop/src/diagram/pointer_tests.rs @@ -65,6 +65,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> { view_index: None, diagram: app.active_diagram.unwrap(), selected: app.selected, + navigation_focus: None, zoom, pan: app.pan, context: app.route_context().unwrap(), diff --git a/crates/oa-desktop/src/diagram/port_hit_tests.rs b/crates/oa-desktop/src/diagram/port_hit_tests.rs index 0847f8cf..49fbc949 100644 --- a/crates/oa-desktop/src/diagram/port_hit_tests.rs +++ b/crates/oa-desktop/src/diagram/port_hit_tests.rs @@ -6,6 +6,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> { view_index: None, diagram, selected: Id::nil(), + navigation_focus: None, zoom, pan: Vector::new(17., 23.), context: Context { diff --git a/crates/oa-desktop/src/diagram/port_labels.rs b/crates/oa-desktop/src/diagram/port_labels.rs index c15b4950..04fcfeda 100644 --- a/crates/oa-desktop/src/diagram/port_labels.rs +++ b/crates/oa-desktop/src/diagram/port_labels.rs @@ -181,6 +181,7 @@ mod tests { view_index: None, diagram: app.active_diagram.unwrap(), selected: app.selected, + navigation_focus: None, zoom: 1., pan: Vector::ZERO, context: app.route_context().unwrap(), diff --git a/crates/oa-desktop/src/diagram_usage.rs b/crates/oa-desktop/src/diagram_usage.rs new file mode 100644 index 00000000..6c0562c4 --- /dev/null +++ b/crates/oa-desktop/src/diagram_usage.rs @@ -0,0 +1,472 @@ +//! Read-only identities for objects actually represented by a diagram renderer. +use iced::Rectangle; +use oa_core::{Id, OccurrenceEndpoint, Project, projection, routing, view_options::ViewIndex}; +pub(crate) mod view; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Context { + pub document: Id, + pub revision: u64, + pub subject: Id, +} + +#[derive(Debug, Clone)] +pub(crate) enum Event { + Open(Context), + Close(Id), + Search(Id, String), + Page(Id, usize), + Destination(Id, Option), + Choose(Id, Usage), + Move(Id, i8), + Activate(Id), +} + +/// One completed immutable accepted-model lookup. The token owns one worker slot. +#[derive(Debug, Clone)] +pub(crate) struct Completion { + pub token: Id, + pub context: Context, + pub result: Result, String>, +} + +pub(crate) struct Panel { + pub token: Id, + pub context: Context, + pub usages: Vec, + pub loading: bool, + pub search: String, + pub page: usize, + pub cursor: usize, + pub destination: Option, + pub notice: Option, +} + +impl Panel { + pub fn new(context: Context, usages: Vec) -> Self { + Self { + token: Id::new_v4(), + context, + usages, + loading: false, + search: String::new(), + page: 0, + cursor: 0, + destination: None, + notice: None, + } + } + + pub fn matches<'a>(&'a self, p: &Project) -> Vec<&'a Usage> { + let query = self.search.to_lowercase(); + if query.is_empty() { + return self.usages.iter().collect(); + } + self.usages + .iter() + .filter(|usage| { + let surface = usage.surface; + let searchable = format!( + "{} {} {} {} {}", + surface.category(p), + p.named(surface.id()), + alias(p, surface.id()), + target_name(p, usage), + usage.role + ); + searchable.to_lowercase().contains(&query) + }) + .collect() + } + + pub fn choices(&self, p: &Project) -> Vec { + if self.loading { + return Vec::new(); + } + let matches = self.matches(p); + if let Some(surface) = self.destination { + matches + .into_iter() + .filter(|u| u.surface == surface) + .map(|u| Event::Choose(self.token, u.clone())) + .collect() + } else { + let mut seen = std::collections::BTreeSet::new(); + matches + .into_iter() + .filter(|u| seen.insert(u.surface)) + .map(|u| Event::Destination(self.token, Some(u.surface))) + .collect() + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Surface { + Saved(Id), + Process(Id), + Scenario(Id), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Target { + Whole, + Placement(Id), + Port { + port: Id, + placement: Id, + }, + Edge(Id), + StateRow { + placement: Id, + relation: Id, + }, + AllocationGrouping { + relation: Id, + work_placement: Id, + performer_placement: Id, + }, + ProcessOccurrence(OccurrenceEndpoint), + ProcessLink(Id), + ProcessPreviewLink { + relation: Id, + source: OccurrenceEndpoint, + target: OccurrenceEndpoint, + }, + ScenarioObject(Id), +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Usage { + pub subject: Id, + pub surface: Surface, + pub target: Target, + /// Concise, truthful explanation, e.g. "action occurrence" or "entry action". + pub role: String, + /// Actual current renderer coordinates. Recompute before activation. + pub bounds: Rectangle, +} + +/// A navigation highlight is presentation state, never an edit/Delete target. +pub(crate) struct Focus { + pub context: Context, + pub usage: Usage, + pub selected: Id, +} + +impl Surface { + pub fn id(self) -> Id { + match self { + Self::Saved(id) | Self::Process(id) | Self::Scenario(id) => id, + } + } + + pub fn category(self, p: &Project) -> &'static str { + match self { + Self::Saved(id) + if p.diagrams + .get(&id) + .is_some_and(|d| d.projection.state_machine().is_some()) => + { + "State views" + } + Self::Saved(_) => "Architecture", + Self::Process(_) => "Processes and chains", + Self::Scenario(_) => "Scenarios", + } + } +} + +pub(crate) fn collect( + p: &Project, + subject: Id, + process: &crate::behavior::topology::State, + scenario: &crate::behavior::scenario::State, +) -> Vec { + let mut usages = saved_usages(p, subject); + for element in p.elements.values() { + match element.kind() { + oa_core::Kind::Process | oa_core::Kind::FunctionalChain => { + usages.extend(process.diagram_usages(p, element.id, subject)); + } + oa_core::Kind::Scenario => { + usages.extend(scenario.diagram_usages(p, element.id, subject)); + } + _ => {} + } + } + usages.sort_by_cached_key(|u| { + ( + u.surface.category(p), + p.named(u.surface.id()).to_lowercase(), + u.surface, + target_name(p, u).to_lowercase(), + ) + }); + usages + .dedup_by(|a, b| a.subject == b.subject && a.surface == b.surface && a.target == b.target); + usages +} + +pub(crate) fn alias(p: &Project, id: Id) -> &str { + p.elements + .get(&id) + .map(|e| e.alias.as_str()) + .or_else(|| p.relations.get(&id).map(|r| r.alias.as_str())) + .or_else(|| p.diagrams.get(&id).map(|d| d.alias.as_str())) + .or_else(|| p.traces.get(&id).map(|t| t.alias.as_str())) + .or_else(|| { + p.diagrams + .values() + .find_map(|d| d.nodes.get(&id).map(|n| n.alias.as_str())) + }) + .unwrap_or("") +} + +pub(crate) fn target_name(p: &Project, usage: &Usage) -> String { + let id = match &usage.target { + Target::Whole => usage.surface.id(), + Target::Placement(id) => p + .diagrams + .get(&usage.surface.id()) + .and_then(|d| d.nodes.get(id)) + .map_or(*id, |n| n.element), + Target::Port { port, .. } => *port, + Target::Edge(id) + | Target::StateRow { relation: id, .. } + | Target::AllocationGrouping { relation: id, .. } + | Target::ProcessLink(id) + | Target::ScenarioObject(id) => *id, + Target::ProcessOccurrence(endpoint) => { + return endpoint + .references + .iter() + .copied() + .chain([endpoint.occurrence]) + .map(|id| format!("{} (@{})", p.named(id), alias(p, id))) + .collect::>() + .join(" / "); + } + Target::ProcessPreviewLink { + relation, source, .. + } => { + return source + .references + .iter() + .copied() + .chain([*relation]) + .map(|id| format!("{} (@{})", p.named(id), alias(p, id))) + .collect::>() + .join(" / "); + } + }; + format!("{} (@{})", p.named(id), alias(p, id)) +} + +/// Saved view membership is the renderer's final projection, including folds, +/// projection-kind exclusions and explicit view filters. Stored route metadata +/// and merely having both relation endpoints placed do not imply visibility. +pub(crate) fn saved_usages(p: &Project, subject: Id) -> Vec { + saved_usages_for(p, subject, None) +} + +/// Revalidate only the requested saved surface before activation. The optional +/// filter is applied before projection and routing, never after a whole lookup. +pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option) -> Vec { + if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) { + return Vec::new(); + } + let index = ViewIndex::new(p); + let mut usages = Vec::new(); + for diagram in p + .diagrams + .values() + .filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id)) + { + let view = projection::project_diagram_with_index(p, diagram, &index); + let surface = Surface::Saved(diagram.id); + if subject == diagram.id || diagram.projection.state_machine() == Some(subject) { + usages.push(Usage { + subject, + surface, + target: Target::Whole, + role: if subject == diagram.id { + "diagram" + } else { + "state machine view" + } + .into(), + bounds: routed_bounds(routing::route_diagram(p, diagram, &view).bounds), + }); + } + for node in &view.nodes { + if subject == node.element || subject == node.id { + usages.push(Usage { + subject, + surface, + target: Target::Placement(node.id), + role: "node".into(), + bounds: Rectangle { + x: node.position.x, + y: node.position.y, + // Reveal the labelled header, not the empty interior of + // a large expanded container at the minimum zoom. + width: node.size.width.min(420.), + height: node.size.height.min(64.), + }, + }); + } + } + for port in &view.ports { + if subject == port.element { + usages.push(Usage { + subject, + surface, + target: Target::Port { + port: port.element, + placement: port.owner, + }, + role: "port".into(), + bounds: Rectangle { + x: port.position.x - 12., + y: port.position.y - 12., + width: 24., + height: 24., + }, + }); + } + } + // Allocation arrows are suppressed in this projection. A direct + // relation is represented only by its actual visible containment chain; + // aggregate summaries never manufacture inherited relations. + if diagram.projection == oa_core::ProjectionKind::Allocation + && let Some(relation) = p.relations.get(&subject) + && relation.kind() == oa_core::RelationKind::Allocation + { + let placements: std::collections::BTreeMap<_, _> = + view.nodes.iter().map(|node| (node.id, node)).collect(); + for work in view.nodes.iter().filter(|n| n.element == relation.target()) { + let mut parent = work.visual_parent; + let mut seen = std::collections::BTreeSet::new(); + while let Some(id) = parent.filter(|id| seen.insert(*id)) { + let Some(performer) = placements.get(&id) else { + break; + }; + if performer.element == relation.source() { + usages.push(Usage { + subject, + surface, + target: Target::AllocationGrouping { + relation: subject, + work_placement: work.id, + performer_placement: performer.id, + }, + role: "allocation grouping".into(), + bounds: Rectangle { + x: work.position.x, + y: work.position.y, + width: work.size.width.min(420.), + height: work.size.height.min(64.), + }, + }); + break; + } + parent = performer.visual_parent; + } + } + } + if view.edges.iter().any(|edge| edge.id == subject) + && let Some(edge) = routing::route_diagram(p, diagram, &view) + .edges + .get(&subject) + { + usages.push(Usage { + subject, + surface, + target: Target::Edge(subject), + role: "relationship".into(), + bounds: edge_focus_bounds(edge), + }); + } + if diagram.projection.state_machine().is_some() { + usages.extend(crate::behavior::state_canvas::diagram_usages( + p, diagram, &view, subject, + )); + } + } + usages +} + +pub(crate) fn routed_bounds(bounds: routing::RoutingBounds) -> Rectangle { + Rectangle { + x: bounds.position.x, + y: bounds.position.y, + width: bounds.size.width, + height: bounds.size.height, + } +} + +/// Focus a named edge at its label. For an unlabelled line, focus its middle +/// segment instead of shrinking a long connector to fit the whole diagram. +pub(crate) fn edge_focus_bounds(edge: &routing::RoutedEdge) -> Rectangle { + if !edge.label.text.is_empty() { + return routed_bounds(edge.label.bounds); + } + let point = edge + .points + .get(edge.points.len() / 2) + .copied() + .unwrap_or(edge.label.center); + Rectangle { + x: point.x - 40., + y: point.y - 24., + width: 80., + height: 48., + } +} + +/// Whole-canvas navigation fits its entire extent; an individual target keeps +/// the readable minimum zoom of `camera` rather than shrinking unrelated space. +pub(crate) fn camera_for( + target: &Target, + bounds: Rectangle, + viewport: iced::Size, +) -> (f32, iced::Vector) { + if !matches!(target, Target::Whole) { + return camera(bounds, viewport); + } + let available = iced::Size::new( + (viewport.width - 64.).max(1.), + (viewport.height - 64.).max(1.), + ); + let zoom = (available.width / bounds.width.max(1.)) + .min(available.height / bounds.height.max(1.)) + .clamp(0.0001, 1.25); + let pan = iced::Vector::new( + viewport.width / 2. - bounds.center_x() * zoom, + viewport.height / 2. - bounds.center_y() * zoom, + ); + (zoom, pan) +} + +/// Return a readable, centered camera without modifying authored geometry. +pub(crate) fn camera(bounds: Rectangle, viewport: iced::Size) -> (f32, iced::Vector) { + let available = iced::Size::new( + (viewport.width - 64.).max(1.), + (viewport.height - 64.).max(1.), + ); + let zoom = (available.width / bounds.width.max(1.)) + .min(available.height / bounds.height.max(1.)) + .clamp(0.5, 1.25); + let pan = iced::Vector::new( + viewport.width / 2. - bounds.center_x() * zoom, + viewport.height / 2. - bounds.center_y() * zoom, + ); + (zoom, pan) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod coverage_tests; diff --git a/crates/oa-desktop/src/diagram_usage/coverage_tests.rs b/crates/oa-desktop/src/diagram_usage/coverage_tests.rs new file mode 100644 index 00000000..21da370b --- /dev/null +++ b/crates/oa-desktop/src/diagram_usage/coverage_tests.rs @@ -0,0 +1,386 @@ +//! Independent allocation membership oracles. Expected targets and coordinates +//! come from handwritten fixtures, never from the usage predicate under test. +use super::{Surface, Target, Usage, saved_usages, saved_usages_for}; +use iced::Rectangle; +use oa_core::{ + Id, Kind, Layer, Position, Project, ProjectionKind, RelationKind, Size, projection, validation, +}; + +struct AllocationFixture { + project: Project, + diagram: Id, + performer: Id, + work: Id, + relation: Id, + performer_node: Id, + work_node: Id, +} + +fn assert_valid(project: &Project) { + let errors: Vec<_> = validation::validate(project) + .into_iter() + .filter(|issue| issue.severity == validation::Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "fixture must be structurally valid: {errors:?}" + ); +} + +fn fixture() -> AllocationFixture { + let mut project = Project::empty(); + let performer = project + .add_element(Kind::Entity, Layer::Oa, "Rescue team", None) + .unwrap(); + let work = project + .add_element(Kind::Activity, Layer::Oa, "Dispatch", None) + .unwrap(); + let relation = project.relate(RelationKind::Allocation, "Performs", performer, work); + let diagram = project.ensure_diagram(Layer::Oa); + project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation; + let performer_node = project.place(diagram, performer, Position { x: 20., y: 40. }); + let work_node = project.place(diagram, work, Position { x: 240., y: 320. }); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&work_node) + .unwrap() + .size = Size { + width: 320., + height: 110., + }; + assert_valid(&project); + AllocationFixture { + project, + diagram, + performer, + work, + relation, + performer_node, + work_node, + } +} + +fn expected(f: &AllocationFixture) -> Usage { + Usage { + subject: f.relation, + surface: Surface::Saved(f.diagram), + target: Target::AllocationGrouping { + relation: f.relation, + work_placement: f.work_node, + performer_placement: f.performer_node, + }, + role: "allocation grouping".into(), + bounds: Rectangle { + x: 240., + y: 320., + width: 320., + height: 64., + }, + } +} + +/// Two semantic decomposition levels, both summaries of the one allocated leaf. +/// No Allocation relation targets either summary. +fn nest_under_summaries(f: &mut AllocationFixture) -> (Id, Id, Id, Id) { + let top = f + .project + .add_element(Kind::Activity, Layer::Oa, "Respond", None) + .unwrap(); + let middle = f + .project + .add_element(Kind::Activity, Layer::Oa, "Coordinate", Some(top)) + .unwrap(); + f.project.elements.get_mut(&f.work).unwrap().parent = Some(middle); + let top_node = f + .project + .place(f.diagram, top, Position { x: 70., y: 120. }); + let middle_node = f + .project + .place(f.diagram, middle, Position { x: 130., y: 210. }); + assert_valid(&f.project); + (top, middle, top_node, middle_node) +} + +#[test] +fn allocation_grouping_has_exact_relation_placements_and_readable_work_geometry() { + let f = fixture(); + let before = f.project.clone(); + let view = projection::project_view(&f.project, f.diagram); + let work = view + .nodes + .iter() + .find(|node| node.id == f.work_node) + .unwrap(); + assert_eq!(work.visual_parent, Some(f.performer_node)); + assert_eq!(work.position, Position { x: 240., y: 320. }); + assert_eq!( + work.size, + Size { + width: 320., + height: 110. + } + ); + assert!(!view.edges.iter().any(|edge| edge.id == f.relation)); + + assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]); + assert_eq!( + saved_usages_for(&f.project, f.relation, Some(f.diagram)), + vec![expected(&f)] + ); + assert!(saved_usages_for(&f.project, f.relation, Some(Id::new_v4())).is_empty()); + // The semantic work and its placement remain ordinary node selections. + for subject in [f.work, f.work_node] { + let usages = saved_usages(&f.project, subject); + assert_eq!(usages.len(), 1); + assert_eq!(usages[0].target, Target::Placement(f.work_node)); + } + assert_eq!(f.project, before); +} + +#[test] +fn allocation_grouping_walks_two_visible_summary_levels_without_inventing_allocations() { + let mut f = fixture(); + let (top, middle, top_node, middle_node) = nest_under_summaries(&mut f); + let before = f.project.clone(); + let view = projection::project_view(&f.project, f.diagram); + for (node_id, parent, summary) in [ + (f.work_node, Some(middle_node), false), + (middle_node, Some(top_node), true), + (top_node, Some(f.performer_node), true), + ] { + let node = view.nodes.iter().find(|node| node.id == node_id).unwrap(); + assert_eq!(node.visual_parent, parent); + assert_eq!(node.allocation_summary, summary); + } + assert_eq!(f.project.relations.len(), 1); + assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]); + for (element, node) in [(top, top_node), (middle, middle_node)] { + let usages = saved_usages(&f.project, element); + assert_eq!(usages.len(), 1); + assert_eq!(usages[0].target, Target::Placement(node)); + } + assert_eq!(f.project, before); +} + +#[test] +fn folded_summary_or_performer_does_not_substitute_a_visible_ancestor_for_the_leaf() { + let mut f = fixture(); + let (_, _, top_node, middle_node) = nest_under_summaries(&mut f); + for folded in [f.performer_node, top_node, middle_node] { + let mut project = f.project.clone(); + project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&folded) + .unwrap() + .collapsed = true; + assert_valid(&project); + let before = project.clone(); + let view = projection::project_view(&project, f.diagram); + assert!(view.nodes.iter().any(|node| node.id == folded)); + assert!(!view.nodes.iter().any(|node| node.id == f.work_node)); + assert!(saved_usages(&project, f.relation).is_empty()); + assert_eq!(project, before); + } +} + +#[test] +fn absent_endpoint_placements_and_hidden_performer_do_not_create_grouping_targets() { + let mut f = fixture(); + let (_, _, top_node, _) = nest_under_summaries(&mut f); + for absent in [f.performer_node, f.work_node] { + let mut project = f.project.clone(); + project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .remove(&absent); + assert_valid(&project); + let before = project.clone(); + let view = projection::project_view(&project, f.diagram); + // The aggregate card remains visible even when the actual leaf or its + // actual performer has no placement. It cannot stand in for that end. + assert!( + view.nodes + .iter() + .any(|node| node.id == top_node && node.allocation_summary) + ); + assert!(saved_usages(&project, f.relation).is_empty()); + assert_eq!(project, before); + } + + let outer = f + .project + .add_element(Kind::Entity, Layer::Oa, "Organization", None) + .unwrap(); + f.project.elements.get_mut(&f.performer).unwrap().parent = Some(outer); + let outer_node = f + .project + .place(f.diagram, outer, Position { x: -40., y: -60. }); + f.project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&outer_node) + .unwrap() + .collapsed = true; + assert_valid(&f.project); + let before = f.project.clone(); + let view = projection::project_view(&f.project, f.diagram); + assert!(view.nodes.iter().any(|node| node.id == outer_node)); + assert!(!view.nodes.iter().any(|node| node.id == f.performer_node)); + assert!(saved_usages(&f.project, f.relation).is_empty()); + assert_eq!(f.project, before); +} + +#[test] +fn collapsed_leaf_and_edge_filter_still_leave_the_actual_nesting_visible() { + let mut f = fixture(); + let diagram = f.project.diagrams.get_mut(&f.diagram).unwrap(); + diagram.nodes.get_mut(&f.work_node).unwrap().collapsed = true; + // hidden_edges controls arrows. Allocation is drawn as nesting, which is + // still visible; do not mistake the filter for a hidden node/grouping. + diagram.view_options.hidden_edges.push(f.relation); + assert_valid(&f.project); + let before = f.project.clone(); + let view = projection::project_view(&f.project, f.diagram); + let work = view + .nodes + .iter() + .find(|node| node.id == f.work_node) + .unwrap(); + assert!(work.collapsed); + assert_eq!(work.visual_parent, Some(f.performer_node)); + assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]); + assert_eq!(f.project, before); +} + +#[test] +fn other_projections_do_not_report_allocation_nesting_when_only_an_arrow_or_nothing_is_drawn() { + let mut f = fixture(); + f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Overview; + assert_valid(&f.project); + let before = f.project.clone(); + let view = projection::project_view(&f.project, f.diagram); + assert!(view.edges.iter().any(|edge| edge.id == f.relation)); + assert!(view.nodes.iter().all(|node| node.visual_parent.is_none())); + let usages = saved_usages(&f.project, f.relation); + assert_eq!(usages.len(), 1); + assert_eq!(usages[0].subject, f.relation); + assert_eq!(usages[0].surface, Surface::Saved(f.diagram)); + assert_eq!(usages[0].target, Target::Edge(f.relation)); + assert_eq!(f.project, before); + + f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::FunctionalDataflow; + assert_valid(&f.project); + let before = f.project.clone(); + let view = projection::project_view(&f.project, f.diagram); + assert!(view.nodes.iter().any(|node| node.id == f.work_node)); + assert!(!view.nodes.iter().any(|node| node.id == f.performer_node)); + assert!(saved_usages(&f.project, f.relation).is_empty()); + assert_eq!(f.project, before); +} + +#[test] +fn repeated_saved_views_keep_allocation_placement_identity_and_geometry_independent() { + let mut f = fixture(); + // The first diagram is Allocation, so this creates a different Overview. + let second = f.project.ensure_diagram(Layer::Oa); + assert_ne!(second, f.diagram); + f.project.diagrams.get_mut(&second).unwrap().projection = ProjectionKind::Allocation; + let second_performer = f + .project + .place(second, f.performer, Position { x: 900., y: 600. }); + let second_work = f + .project + .place(second, f.work, Position { x: 1000., y: 700. }); + f.project + .diagrams + .get_mut(&second) + .unwrap() + .nodes + .get_mut(&second_work) + .unwrap() + .size = Size { + width: 520., + height: 130., + }; + assert_ne!(second_performer, f.performer_node); + assert_ne!(second_work, f.work_node); + assert_valid(&f.project); + let before = f.project.clone(); + let other = Usage { + subject: f.relation, + surface: Surface::Saved(second), + target: Target::AllocationGrouping { + relation: f.relation, + work_placement: second_work, + performer_placement: second_performer, + }, + role: "allocation grouping".into(), + bounds: Rectangle { + x: 1000., + y: 700., + width: 420., + height: 64., + }, + }; + let usages = saved_usages(&f.project, f.relation); + assert_eq!(usages.len(), 2); + assert!(usages.contains(&expected(&f))); + assert!(usages.contains(&other)); + assert_eq!( + saved_usages_for(&f.project, f.relation, Some(second)), + vec![other] + ); + assert_eq!(f.project, before); +} + +#[test] +fn role_derived_nesting_without_a_direct_allocation_does_not_invent_one() { + let mut f = fixture(); + f.project.relations.remove(&f.relation); + let role = f + .project + .add_element(Kind::Role, Layer::Oa, "Dispatcher", None) + .unwrap(); + let realization = f.project.relate( + RelationKind::RoleRealization, + "Realizes dispatcher", + f.performer, + role, + ); + let assignment = f + .project + .relate(RelationKind::RoleAssignment, "Dispatches", role, f.work); + assert_valid(&f.project); + let before = f.project.clone(); + let view = projection::project_view(&f.project, f.diagram); + let work = view + .nodes + .iter() + .find(|node| node.id == f.work_node) + .unwrap(); + assert_eq!(work.visual_parent, Some(f.performer_node)); + assert!(work.allocation_summary); + assert!( + f.project + .relations + .values() + .all(|relation| relation.kind() != RelationKind::Allocation) + ); + for subject in [f.relation, realization, assignment] { + assert!(saved_usages(&f.project, subject).is_empty()); + } + let usages = saved_usages(&f.project, f.work); + assert_eq!(usages.len(), 1); + assert_eq!(usages[0].target, Target::Placement(f.work_node)); + assert_eq!(f.project, before); +} diff --git a/crates/oa-desktop/src/diagram_usage/tests.rs b/crates/oa-desktop/src/diagram_usage/tests.rs new file mode 100644 index 00000000..f492bfe9 --- /dev/null +++ b/crates/oa-desktop/src/diagram_usage/tests.rs @@ -0,0 +1,196 @@ +use super::*; +use oa_core::{Kind, Layer, Position, RelationKind}; + +#[test] +fn saved_membership_respects_filters_and_does_not_change_project() { + let mut p = Project::empty(); + let first = p.add(Kind::Activity, "Same name", 20., 20.); + let second = p.add(Kind::Activity, "Same name", 340., 20.); + let relation = p.relate(RelationKind::Interaction, "Handoff", first, second); + let shown = p.ensure_diagram(Layer::Oa); + let mut hidden = p.diagrams[&shown].clone(); + hidden.id = Id::new_v4(); + hidden.alias = "filtered_copy".into(); + hidden.nodes = hidden + .nodes + .into_values() + .map(|mut node| { + node.id = Id::new_v4(); + node.alias = format!("filtered_{}", node.alias); + (node.id, node) + }) + .collect(); + hidden.view_options.hidden_edges.push(relation); + let hidden_id = hidden.id; + p.diagrams.insert(hidden_id, hidden); + let before = p.clone(); + + let hits = saved_usages(&p, relation); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].surface, Surface::Saved(shown)); + assert_eq!(hits[0].target, Target::Edge(relation)); + let hits = saved_usages(&p, first); + assert_eq!(hits.len(), 2); + assert!(hits.iter().all(|hit| match hit.target { + Target::Placement(node) => p.diagrams[&hit.surface.id()].nodes[&node].element == first, + _ => false, + })); + assert_eq!(p, before); +} + +#[test] +fn folded_child_is_not_a_visible_node_and_placement_identity_stays_exact() { + let mut p = Project::empty(); + let parent = p.add(Kind::Activity, "Container", 20., 20.); + let child = p + .add_element(Kind::Activity, Layer::Oa, "Nested", Some(parent)) + .unwrap(); + let diagram = p.ensure_diagram(Layer::Oa); + let child_node = p.place(diagram, child, Position { x: 50., y: 100. }); + let parent_node = p.diagrams[&diagram] + .nodes + .values() + .find(|n| n.element == parent) + .unwrap() + .id; + let hits = saved_usages(&p, child_node); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].target, Target::Placement(child_node)); + p.diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&parent_node) + .unwrap() + .collapsed = true; + let before = p.clone(); + assert!(saved_usages(&p, child).is_empty()); + assert!(saved_usages(&p, child_node).is_empty()); + assert_eq!(saved_usages(&p, parent).len(), 1); + assert_eq!(p, before); +} + +#[test] +fn ports_are_found_at_the_visible_owner_and_unplaced_elements_do_not_match_names() { + let mut p = Project::empty(); + let owner = p.add(Kind::Function, "Compute", 20., 20.); + let port = Id::new_v4(); + let element = oa_core::ports::make_port( + &p, + port, + owner, + "Input", + oa_core::PortContract::FunctionInput { + incoming_items: vec![], + }, + ) + .unwrap(); + p = oa_core::execute_batch(&p, &[oa_core::Command::CreateElement(element)]).unwrap(); + let unrelated = p + .add_element(Kind::Function, Layer::Sa, "Compute", None) + .unwrap(); + let hits = saved_usages(&p, port); + assert_eq!(hits.len(), 1); + let view = projection::project_view(&p, hits[0].surface.id()); + let painted = view + .ports + .iter() + .find(|shown| shown.element == port) + .unwrap(); + assert_eq!( + hits[0].target, + Target::Port { + port, + placement: painted.owner + } + ); + assert_eq!( + hits[0].bounds.center(), + iced::Point::new(painted.position.x, painted.position.y) + ); + assert!(saved_usages(&p, unrelated).is_empty()); + assert!(saved_usages(&p, Id::new_v4()).is_empty()); +} + +#[test] +fn reveal_camera_centers_target_without_whole_view_shrink() { + let bounds = Rectangle { + x: 100_000., + y: -50_000., + width: 224., + height: 94., + }; + let viewport = iced::Size::new(700., 500.); + let (zoom, pan) = camera(bounds, viewport); + assert_eq!(zoom, 1.25); + assert!((bounds.center_x() * zoom + pan.x - 350.).abs() < 0.01); + assert!((bounds.center_y() * zoom + pan.y - 250.).abs() < 0.01); +} + +#[test] +fn oversized_container_reveal_targets_its_labelled_header() { + let mut p = Project::empty(); + let container = p.add(Kind::Activity, "Large enclosing activity", 40., 70.); + let diagram = p.ensure_diagram(Layer::Oa); + let node = p + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .values_mut() + .next() + .unwrap(); + node.size = oa_core::Size { + width: 4000., + height: 4000., + }; + let before = p.clone(); + let hits = saved_usages(&p, container); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].bounds.x, 40.); + assert_eq!(hits[0].bounds.y, 70.); + assert!(hits[0].bounds.width <= 420.); + assert!(hits[0].bounds.height <= 64.); + let (zoom, pan) = camera(hits[0].bounds, iced::Size::new(500., 300.)); + assert!((hits[0].bounds.center_x() * zoom + pan.x - 250.).abs() < 0.01); + assert!(70. * zoom + pan.y >= 0.); + assert_eq!(p, before); +} + +#[test] +fn state_machine_owner_finds_its_view_without_a_machine_placement() { + let mut p = Project::empty(); + let machine = p + .add_element(Kind::StateMachine, Layer::Sa, "Monitor", None) + .unwrap(); + let unrelated = p + .add_element(Kind::StateMachine, Layer::Sa, "Other", None) + .unwrap(); + let diagram = p.ensure_diagram(Layer::Sa); + p.diagrams.get_mut(&diagram).unwrap().projection = + oa_core::ProjectionKind::StateMachine { machine }; + let before = p.clone(); + let hits = saved_usages(&p, machine); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].surface, Surface::Saved(diagram)); + assert_eq!(hits[0].target, Target::Whole); + assert_eq!(hits[0].role, "state machine view"); + assert!(saved_usages(&p, unrelated).is_empty()); + assert_eq!(p, before); +} + +#[test] +fn whole_diagram_camera_fits_large_bounds_instead_of_individual_object_scale() { + let bounds = Rectangle { + x: 2000., + y: -4000., + width: 100_000., + height: 80_000., + }; + let (zoom, pan) = camera_for(&Target::Whole, bounds, iced::Size::new(500., 300.)); + assert!(zoom < 0.01); + assert!(bounds.x * zoom + pan.x >= 0.); + assert!(bounds.y * zoom + pan.y >= 0.); + assert!((bounds.x + bounds.width) * zoom + pan.x <= 500.); + assert!((bounds.y + bounds.height) * zoom + pan.y <= 300.); +} diff --git a/crates/oa-desktop/src/diagram_usage/view.rs b/crates/oa-desktop/src/diagram_usage/view.rs new file mode 100644 index 00000000..e68faf1a --- /dev/null +++ b/crates/oa-desktop/src/diagram_usage/view.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::{ + app::{App, Message}, + style, + views::{action, short}, +}; +use iced::{ + Element, Fill, + widget::{self, column, container, row, text, text_input, tooltip}, +}; +use std::collections::BTreeSet; + +const PAGE_SIZE: usize = 6; + +pub(crate) fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> { + let Some(panel) = &app.diagram_usages else { + return base; + }; + let p = app.project(); + let token = panel.token; + let message = |event| Message::DiagramUsage(event); + let matched = panel.matches(p); + let mut surfaces = Vec::new(); + let mut seen = BTreeSet::new(); + for usage in &matched { + if seen.insert(usage.surface) { + surfaces.push(usage.surface); + } + } + let targets: Vec<_> = matched + .iter() + .copied() + .filter(|usage| Some(usage.surface) == panel.destination) + .collect(); + let count = if panel.destination.is_some() { + targets.len() + } else { + surfaces.len() + }; + let last = count.saturating_sub(1) / PAGE_SIZE; + let page = panel.page.min(last); + let total_surfaces = panel + .usages + .iter() + .map(|u| u.surface) + .collect::>() + .len(); + let mut content = column![ + row![ + text("Show in diagrams").size(22).width(Fill), + action("Close", message(Event::Close(token))) + ] + .spacing(12), + tooltip( + text(format!( + "{} · @{}", + short(p.named(panel.context.subject), 48), + short(alias(p, panel.context.subject), 48) + )) + .size(14), + container( + widget::scrollable( + text(format!( + "{}\n@{}", + p.named(panel.context.subject), + alias(p, panel.context.subject) + )) + .size(13) + ) + .height(220) + ) + .padding(10) + .width(620) + .style(style::card), + tooltip::Position::Bottom + ), + text(if panel.loading { + "Searching rendered objects…".into() + } else { + format!( + "{} diagram destinations · {} representations", + total_surfaces, + panel.usages.len() + ) + }) + .size(12) + .color(style::MUTED), + text_input("Filter diagram, occurrence, or alias", &panel.search) + .id(format!("diagram-usage-filter-{}", panel.token)) + .padding(9) + .on_input(move |value| Message::DiagramUsage(Event::Search(token, value))) + .on_submit(message(Event::Activate(token))), + ] + .spacing(10); + if let Some(notice) = &panel.notice { + content = content.push(text(notice).size(13)); + } + if let Some(surface) = panel.destination { + content = content.push( + row![ + action("All diagrams", message(Event::Destination(token, None))), + text(short(p.named(surface.id()), 55)).size(15), + ] + .spacing(10), + ); + for (index, usage) in targets + .into_iter() + .enumerate() + .skip(page * PAGE_SIZE) + .take(PAGE_SIZE) + { + let name = target_name(p, usage); + content = content.push(tooltip( + action( + &format!("{} · {}", usage.role, short(&name, 70)), + message(Event::Choose(token, usage.clone())), + ) + .width(Fill) + .style(if panel.cursor == index { + style::selected + } else { + style::nav + }), + container(text(name).size(13)) + .padding(10) + .max_width(680) + .style(style::card), + tooltip::Position::Top, + )); + } + } else { + for (index, surface) in surfaces + .into_iter() + .enumerate() + .skip(page * PAGE_SIZE) + .take(PAGE_SIZE) + { + let targets = matched.iter().filter(|u| u.surface == surface).count(); + let name = format!( + "{} · {} (@{}) · {} representations", + surface.category(p), + p.named(surface.id()), + alias(p, surface.id()), + targets + ); + let layer = p + .diagrams + .get(&surface.id()) + .map(|d| d.layer) + .or_else(|| p.layer_of(surface.id())) + .map(|l| l.to_string()) + .unwrap_or_default(); + content = content.push(tooltip( + action( + &format!( + "{} · {} · {} · {}", + surface.category(p), + layer, + short(p.named(surface.id()), 34), + targets + ), + message(Event::Destination(token, Some(surface))), + ) + .width(Fill) + .style(if panel.cursor == index { + style::selected + } else { + style::nav + }), + container(text(name).size(13)) + .padding(10) + .max_width(680) + .style(style::card), + tooltip::Position::Top, + )); + } + } + if panel.loading { + content = content.push(text("Finding diagram representations…").size(13)); + } else if count == 0 { + content = content.push(text(if panel.usages.is_empty() { + "No currently rendered representation found. Folded or filtered objects and unexpanded reuse contents are excluded." + } else { "No results match this filter." }).size(13)); + } + let mut paging = row![ + text(format!( + "Page {} of {} · {} matches", + page + 1, + last + 1, + count + )) + .size(12) + .width(Fill) + ] + .spacing(8); + if page > 0 { + paging = paging.push(action("Previous", message(Event::Page(token, page - 1)))); + } + if page < last { + paging = paging.push(action("Next", message(Event::Page(token, page + 1)))); + } + content = content.push(paging).push(text("↑ ↓ choose · Enter opens · Escape closes. Source and saved view settings are preserved.").size(12).color(style::MUTED)); + widget::stack![ + base, + widget::opaque( + container( + container(content) + .padding(22) + .width((app.window_size.width - 80.).clamp(620., 820.)) + .style(style::card) + ) + .center(Fill) + .style(|_| container::Style { + background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()), + ..Default::default() + }) + ) + ] + .into() +} diff --git a/crates/oa-desktop/src/document.rs b/crates/oa-desktop/src/document.rs index 8369645d..e68c312a 100644 --- a/crates/oa-desktop/src/document.rs +++ b/crates/oa-desktop/src/document.rs @@ -10,6 +10,10 @@ use std::{fmt, sync::Arc}; const HISTORY_LIMIT: usize = 100; const HISTORY_BYTES: usize = 32 * 1024 * 1024; +#[path = "document_alias_rename.rs"] +mod alias_rename; +pub use alias_rename::PreparedAliasRename; + struct HistoryEntry { snapshot: Arc, bytes: usize, @@ -91,6 +95,8 @@ impl From> for DocumentError { } pub struct Document { + // Distinguishes equal accepted snapshots belonging to different documents. + alias_owner: Arc<()>, current: Arc, past: Vec, future: Vec, @@ -121,6 +127,7 @@ impl Document { let current = Arc::new(source_project::compile_bundle(&bundle, limits)?); let current_bytes = estimated_bytes(¤t); Ok(Self { + alias_owner: Arc::new(()), current, current_bytes, past: Vec::new(), @@ -394,8 +401,17 @@ impl Document { return false; } let bytes = estimated_bytes(&compiled); + self.commit_snapshot(Arc::new(compiled), bytes) + } + + // Only validated internal paths supply a changed snapshot and its exact + // estimate. The ordinary commit wrapper checks bundle inequality; the alias + // path checks its private changed flag and exact accepted-snapshot authority. + // Alias preparation shares this algorithm while retaining the live save and + // history state, and does not clone/recompile the candidate on adoption. + fn commit_snapshot(&mut self, compiled: Arc, bytes: usize) -> bool { self.past.push(HistoryEntry { - snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)), + snapshot: std::mem::replace(&mut self.current, compiled), bytes: std::mem::replace(&mut self.current_bytes, bytes), }); self.future.clear(); diff --git a/crates/oa-desktop/src/document_alias_rename.rs b/crates/oa-desktop/src/document_alias_rename.rs new file mode 100644 index 00000000..aa98e63b --- /dev/null +++ b/crates/oa-desktop/src/document_alias_rename.rs @@ -0,0 +1,233 @@ +//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do +//! not authorize adoption; only this module can mint a prepared rename. +use super::*; +use std::collections::BTreeMap; + +/// A pinned intent minted by its owning Document. Move it to a worker and call +/// `prepare`; preparation has no access to the live Document or its histories. +#[derive(Debug)] +pub struct AliasRenameRequest { + owner: Arc<()>, + before: Arc, + revision: u64, + limits: ProjectLimits, + target: Id, + old_alias: String, + new_alias: String, +} + +#[derive(Debug)] +struct PreparedData { + request: AliasRenameRequest, + after: Arc, + before_bytes: usize, + after_bytes: usize, + changed: bool, +} + +/// Immutable, Clone-compatible transport for preview and approval. Neither +/// mutable candidate access nor deserialization can manufacture this authority. +#[derive(Debug, Clone)] +pub struct PreparedAliasRename(Arc); + +impl PreparedAliasRename { + pub fn before(&self) -> &CompiledSourceProject { + &self.0.request.before + } + + pub fn after(&self) -> &CompiledSourceProject { + &self.0.after + } + + pub fn target(&self) -> Id { + self.0.request.target + } + + pub fn old_alias(&self) -> &str { + &self.0.request.old_alias + } + + pub fn new_alias(&self) -> &str { + &self.0.request.new_alias + } + + pub fn revision(&self) -> u64 { + self.0.request.revision + } + + pub fn changed(&self) -> bool { + self.0.changed + } +} + +fn refusal(message: &str) -> DocumentError { + ModelError(message.into()).into() +} + +fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> { + if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES { + return Err(refusal( + "This rename exceeds the model Undo budget. No rename was applied.", + )); + } + Ok(()) +} + +impl Document { + pub fn alias_rename_request( + &self, + target: Id, + replacement: String, + ) -> Result { + if self.revision == u64::MAX { + return Err(refusal( + "The document revision is exhausted. Reopen before renaming.", + )); + } + let old_alias = self + .project() + .identities() + .into_iter() + .find_map(|(id, alias)| (id == target).then(|| alias.to_owned())) + .ok_or_else(|| refusal("The alias target is no longer present."))?; + // A candidate has a nonzero estimate. Avoid expensive preparation when + // even the current snapshot leaves no room for its immediate Undo. + if old_alias != replacement && self.current_bytes >= HISTORY_BYTES { + return Err(refusal( + "This rename exceeds the model Undo budget. No rename was applied.", + )); + } + Ok(AliasRenameRequest { + owner: self.alias_owner.clone(), + before: self.current.clone(), + revision: self.revision, + limits: self.limits, + target, + old_alias, + new_alias: replacement, + }) + } + + /// App must separately consume its current approval and check workspace, + /// invocation and draft freshness. This checks accepted Document authority. + pub fn commit_alias_rename( + &mut self, + prepared: &PreparedAliasRename, + ) -> Result { + let data = &prepared.0; + let request = &data.request; + if self.revision == u64::MAX + || self.revision != request.revision + || !Arc::ptr_eq(&self.alias_owner, &request.owner) + || !Arc::ptr_eq(&self.current, &request.before) + { + return Err(refusal( + "This rename preview is stale. Preview the current alias again.", + )); + } + if !data.changed { + return Ok(false); + } + if self.current_bytes != data.before_bytes { + return Err(refusal( + "The rename history estimate is stale. Preview again.", + )); + } + check_history_capacity(self.current_bytes, data.after_bytes)?; + Ok(self.commit_snapshot(data.after.clone(), data.after_bytes)) + } +} + +impl AliasRenameRequest { + /// Run on the preparation worker. The typed command and source updater are + /// the only candidate construction path, followed by alias-only invariants. + pub fn prepare(self) -> Result { + let before_project = &self.before.decoded.project; + let candidate = prepare_command( + before_project, + &Command::SetAlias(self.target, self.new_alias.clone()), + )?; + let mut expected = before_project.clone(); + assign_expected_alias(&mut expected, self.target, &self.new_alias)?; + let identities: BTreeMap<_, _> = before_project + .identities() + .into_iter() + .map(|(id, _)| (id, id)) + .collect(); + if candidate.project != expected || candidate.identity_map != identities { + return Err(refusal( + "Alias preparation changed unrelated model data or identities.", + )); + } + let after = source_project::update_bundle_with_identity_map( + &self.before, + &candidate.project, + &candidate.identity_map, + self.limits, + )?; + let before = &self.before; + if after.decoded.project != expected + || before.bundle.entry != after.bundle.entry + || !before.bundle.files.keys().eq(after.bundle.files.keys()) + || before.index.lexical_owners != after.index.lexical_owners + || !before + .index + .declarations + .iter() + .map(|(id, loc)| (id, &loc.path)) + .eq(after + .index + .declarations + .iter() + .map(|(id, loc)| (id, &loc.path))) + { + return Err(refusal( + "Alias preparation changed source membership or ownership.", + )); + } + let changed = before.bundle != after.bundle; + let before_bytes = estimated_bytes(before); + let after_bytes = estimated_bytes(&after); + if changed { + check_history_capacity(before_bytes, after_bytes)?; + } + Ok(PreparedAliasRename(Arc::new(PreparedData { + request: self, + after: Arc::new(after), + before_bytes, + after_bytes, + changed, + }))) + } +} + +// Independent full-model expectation: SetAlias may change exactly one header +// field, across every supported identity family. Do not reuse command execution +// to derive the expected result, which would hide collateral command changes. +fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> { + if project.id == id { + project.alias = alias.to_owned(); + } else if let Some(record) = project.elements.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.relations.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.diagrams.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.artifact_links.get_mut(&id) { + record.alias = alias.to_owned(); + } else if let Some(record) = project.traces.get_mut(&id) { + record.alias = alias.to_owned(); + } else { + project + .diagrams + .values_mut() + .find_map(|diagram| diagram.nodes.get_mut(&id)) + .ok_or_else(|| refusal("The alias target is no longer present."))? + .alias = alias.to_owned(); + } + Ok(()) +} + +#[cfg(test)] +#[path = "document_alias_rename_tests.rs"] +mod tests; diff --git a/crates/oa-desktop/src/document_alias_rename_tests.rs b/crates/oa-desktop/src/document_alias_rename_tests.rs new file mode 100644 index 00000000..5e773f46 --- /dev/null +++ b/crates/oa-desktop/src/document_alias_rename_tests.rs @@ -0,0 +1,459 @@ +use super::*; + +fn fixture() -> Document { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), r#"arcadia 7; +project mission "Испытание" id "10000000-0000-4000-8000-000000000001" { + include "work.arcadia"; include "views.arcadia"; +} +"#.replace('\n', "\r\n")), + ("work.arcadia".into(), r#"// @assess and assess are explanatory prose. +oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; } +oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence + assess; } +sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {} +interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; } +trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; } +artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; } +"#.replace('\n', "\r\n")), + ("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity + transfer; waypoints [{ x 4.20e2; y -16; }]; }]; } +node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; } +"#.replace('\n', "\r\n")), + ]), + }; + let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap(); + assert_eq!( + document.bundle(), + &bundle, + "fixture must need no migration or ID insertion" + ); + document +} + +fn id(number: u8) -> Id { + Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap() +} + +fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename { + document + .alias_rename_request(target, replacement.into()) + .unwrap() + .prepare() + .unwrap() +} + +// Capture every live Document state relevant to refusal/no-op. Arc identity also +// catches replacing an equal snapshot or saved marker behind the user's back. +struct Unchanged { + current: Arc, + saved: Option>, + past: Vec<(Arc, usize)>, + future: Vec<(Arc, usize)>, + revision: u64, + current_bytes: usize, + recovered: bool, +} + +impl Unchanged { + fn capture(document: &Document) -> Self { + Self { + current: document.snapshot(), + saved: document.saved.clone(), + past: document + .past + .iter() + .map(|h| (h.snapshot.clone(), h.bytes)) + .collect(), + future: document + .future + .iter() + .map(|h| (h.snapshot.clone(), h.bytes)) + .collect(), + revision: document.revision, + current_bytes: document.current_bytes, + recovered: document.recovered_unsaved, + } + } + + fn assert(&self, document: &Document) { + assert!(Arc::ptr_eq(&self.current, &document.current)); + match (&self.saved, &document.saved) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), + (None, None) => {} + _ => panic!("saved baseline changed"), + } + for (before, after) in [ + (&self.past, &document.past), + (&self.future, &document.future), + ] { + assert_eq!(before.len(), after.len()); + for ((snapshot, bytes), entry) in before.iter().zip(after) { + assert!(Arc::ptr_eq(snapshot, &entry.snapshot)); + assert_eq!(*bytes, entry.bytes); + } + } + assert_eq!(self.revision, document.revision); + assert_eq!(self.current_bytes, document.current_bytes); + assert_eq!(self.recovered, document.recovered_unsaved); + } +} + +#[test] +fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() { + // Literal role-specific replacements are independent of the updater/tokenizer. + // They deliberately exclude prose, historical review text and URI spelling. + type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]); + let cases: &[AliasCase<'_>] = &[ + ( + 1, + "mission", + "renamed", + &[("project mission ", "project renamed ")], + ), + ( + 2, + "assess", + "renamed", + &[ + ("oa_activity assess ", "oa_activity renamed "), + ("\r\n assess;", "\r\n renamed;"), + ("@assess;", "@renamed;"), + ], + ), + ( + 5, + "transfer", + "renamed", + &[ + ("interaction transfer ", "interaction renamed "), + ("\r\n transfer;", "\r\n renamed;"), + ], + ), + ( + 6, + "review", + "renamed", + &[ + ("trace review ", "trace renamed "), + ("owner @review;", "owner @renamed;"), + ], + ), + ( + 7, + "evidence", + "renamed", + &[("artifact evidence ", "artifact renamed ")], + ), + ( + 8, + "overview", + "renamed", + &[ + ("diagram overview ", "diagram renamed "), + ("diagram @overview;", "diagram @renamed;"), + ], + ), + ( + 9, + "placement", + "renamed", + &[("node placement ", "node renamed ")], + ), + ]; + for &(number, old, new, replacements) in cases { + let mut document = fixture(); + let original = document.snapshot(); + let mut expected_model = document.project().clone(); + // Explicit per-family oracle, separate from production expectation helper. + match number { + 1 => expected_model.alias = new.into(), + 2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(), + 5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(), + 6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(), + 7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(), + 8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(), + 9 => { + expected_model + .diagrams + .get_mut(&id(8)) + .unwrap() + .nodes + .get_mut(&id(9)) + .unwrap() + .alias = new.into() + } + _ => unreachable!(), + } + let mut expected_bundle = document.bundle().clone(); + for source in expected_bundle.files.values_mut() { + for &(before, after) in replacements { + *source = source.replace(before, after); + } + } + let state = Unchanged::capture(&document); + let prepared = prepare(&document, id(number), new); + state.assert(&document); + assert!(prepared.changed()); + assert_eq!(prepared.old_alias(), old); + assert_eq!(prepared.new_alias(), new); + assert_eq!(prepared.target(), id(number)); + assert_eq!(prepared.revision(), 0); + assert_eq!(prepared.before().bundle, original.bundle); + assert_eq!(prepared.after().bundle, expected_bundle); + assert_eq!(prepared.after().decoded.project, expected_model); + assert!(document.commit_alias_rename(&prepared).unwrap()); + assert!( + Arc::ptr_eq(&document.current, &prepared.0.after), + "adopt the exact prepared Arc" + ); + assert_eq!(document.past.len(), 1); + assert!(document.dirty()); + let after = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&prepared.clone()).is_err()); + after.assert(&document); + assert!(document.undo()); + assert!(Arc::ptr_eq(&document.current, &original)); + assert!(!document.dirty()); + assert!(!document.can_undo()); + assert!(document.redo()); + assert_eq!(document.bundle(), &expected_bundle); + assert_eq!(document.project(), &expected_model); + document.mark_saved(document.snapshot()); + let reopened = + Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), &expected_model); + assert_eq!(reopened.bundle(), &expected_bundle); + assert!(!reopened.dirty()); + } +} + +#[test] +fn highlighted_keyword_alias_is_valid_without_normalizing_input() { + assert!(matches!( + language::tokenize("oa_activity")[0].class, + language::TokenClass::Keyword + )); + let mut document = fixture(); + let prepared = prepare(&document, id(2), "oa_activity"); + document.commit_alias_rename(&prepared).unwrap(); + assert_eq!(document.project().elements[&id(2)].alias, "oa_activity"); + assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба"); +} + +#[test] +fn invalid_and_identical_aliases_preserve_live_state_and_redo() { + let mut document = fixture(); + let first = prepare(&document, id(2), "changed"); + document.commit_alias_rename(&first).unwrap(); + assert!(document.undo()); + document.invalidate_saved_baseline(); + document.mark_recovered(); + let state = Unchanged::capture(&document); + for replacement in ["", "has space", "project", "assist", " assess", "assess "] { + assert!( + document + .alias_rename_request(id(2), replacement.into()) + .unwrap() + .prepare() + .is_err() + ); + state.assert(&document); + } + assert!( + document + .alias_rename_request(Id::nil(), "new".into()) + .is_err() + ); + let no_op = prepare(&document, id(2), "assess"); + assert!(!no_op.changed()); + assert!(!document.commit_alias_rename(&no_op).unwrap()); + state.assert(&document); + assert!(document.redo()); + assert_eq!(document.project().elements[&id(2)].alias, "changed"); +} + +#[test] +fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() { + let mut document = fixture(); + let old = prepare(&document, id(2), "renamed"); + let mut second = fixture(); + let second_state = Unchanged::capture(&second); + assert!(second.commit_alias_rename(&old).is_err()); + second_state.assert(&second); + // Even an identical shared accepted Arc cannot confer this Document's owner. + second.current = document.current.clone(); + let second_state = Unchanged::capture(&second); + assert!(second.commit_alias_rename(&old).is_err()); + second_state.assert(&second); + document + .execute(Command::Rename(id(2), "Later name".into())) + .unwrap(); + let edited = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&old).is_err()); + edited.assert(&document); + assert!(document.undo()); + assert!(Arc::ptr_eq(&document.current, &old.0.request.before)); + let undone = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&old).is_err()); + undone.assert(&document); + document.revision = u64::MAX - 1; + let last = prepare(&document, id(2), "renamed"); + document + .execute(Command::Rename(id(2), "Exhaust revision".into())) + .unwrap(); + document.undo(); + assert!(Arc::ptr_eq(&document.current, &last.0.request.before)); + let exhausted = Unchanged::capture(&document); + assert!(document.commit_alias_rename(&last).is_err()); + assert!( + document + .alias_rename_request(id(2), "renamed".into()) + .is_err() + ); + assert!( + document + .alias_rename_request(id(2), "assess".into()) + .is_err() + ); + exhausted.assert(&document); +} + +#[test] +fn preview_retains_latest_saved_baseline_and_recovery_state() { + for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] { + let mut document = fixture(); + let prepared = prepare(&document, id(2), "renamed"); + let original = document.snapshot(); + match baseline { + "saved_before" => document.mark_saved(original.clone()), + "unknown" => document.invalidate_saved_baseline(), + "raw_repair" => { + let mut raw = document.bundle().clone(); + raw.files + .insert("project.arcadia".into(), "invalid repair source".into()); + document.bind_repair_baseline(raw); + } + "recovered" => document.mark_recovered(), + _ => unreachable!(), + } + let saved = document.saved.clone(); + let recovered = document.recovered_unsaved; + assert!(document.commit_alias_rename(&prepared).unwrap()); + assert_eq!(document.saved, saved); + assert_eq!(document.recovered_unsaved, recovered); + assert!(document.dirty()); + assert!(document.undo()); + assert_eq!(document.saved, saved); + assert_eq!(document.recovered_unsaved, recovered); + assert_eq!(document.dirty(), baseline != "saved_before"); + } + // A completed save after Preview remains authoritative, including when it + // clears recovery; a worker must not restore its earlier state on Confirm. + let mut document = fixture(); + document.mark_recovered(); + document.invalidate_saved_baseline(); + let prepared = prepare(&document, id(2), "renamed"); + document.mark_saved(document.snapshot()); + document.commit_alias_rename(&prepared).unwrap(); + assert!(!document.recovered_unsaved); + document.undo(); + assert!(!document.dirty()); + // Undo from saved C to B, then a new B->C rename can correctly finish clean. + document.redo(); + document.mark_saved(document.snapshot()); + document.undo(); + let return_to_saved = prepare(&document, id(2), "renamed"); + document.commit_alias_rename(&return_to_saved).unwrap(); + assert!(!document.dirty()); + assert!(!document.can_redo()); +} + +#[test] +fn mutable_public_preview_copies_cannot_change_prepared_authority() { + let mut document = fixture(); + let prepared = prepare(&document, id(2), "renamed"); + let exact = prepared.after().clone(); + let mut counterfeit = prepared.after().clone(); + counterfeit.decoded.project.name = "Injected unrelated edit".into(); + counterfeit.index.declarations.clear(); + let unchanged = Unchanged::capture(&document); + assert!(document.apply_compiled(counterfeit).is_err()); + unchanged.assert(&document); + let mut unrelated = exact.decoded.project.clone(); + unrelated.name = "A self-consistent unrelated candidate".into(); + let forged_dto = source_project::compile_bundle( + &source_project::format_bundle(&unrelated), + ProjectLimits::default(), + ) + .unwrap(); + assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project); + // No API converts either DTO to PreparedAliasRename. Mutating owned copies + // leaves the opaque capability and its exact adopted model/source intact. + document.commit_alias_rename(&prepared).unwrap(); + assert_eq!(document.project(), &exact.decoded.project); + assert_eq!(document.bundle(), &exact.bundle); +} + +#[test] +fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() { + let mut document = fixture(); + let mut bundle = document.bundle().clone(); + let padding = HISTORY_BYTES / 2 - document.current_bytes; + bundle + .files + .get_mut("views.arcadia") + .unwrap() + .push_str(&format!("//{}\r\n", "x".repeat(padding - 4))); + document.apply_bundle(bundle).unwrap(); + assert_eq!(document.current_bytes, HISTORY_BYTES / 2); + // A same-length alias retains the exact estimate; older history is evictable. + let boundary = prepare(&document, id(2), "rating"); + assert_eq!( + boundary.0.before_bytes + boundary.0.after_bytes, + HISTORY_BYTES + ); + let unchanged = Unchanged::capture(&document); + assert!( + document + .alias_rename_request(id(2), "longer_alias".into()) + .unwrap() + .prepare() + .is_err() + ); + unchanged.assert(&document); + document.commit_alias_rename(&boundary).unwrap(); + assert_eq!(document.past.len(), 1); + assert!(document.undo()); + assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before)); + assert!(!document.can_undo()); + assert!(document.redo()); + assert!(Arc::ptr_eq(&document.current, &boundary.0.after)); + assert!(check_history_capacity(usize::MAX, 1).is_err()); + assert!(check_history_capacity(HISTORY_BYTES, 1).is_err()); +} + +#[test] +fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() { + let mut document = fixture(); + let prepared = prepare(&document, id(2), "renamed"); + // Inject cache corruption to falsify the commit-side check independently of + // the preparation admission. Normal edits also change revision and Arc. + document.current_bytes = HISTORY_BYTES; + let unchanged = Unchanged::capture(&document); + assert!( + document + .alias_rename_request(id(2), "renamed".into()) + .is_err() + ); + assert!(document.commit_alias_rename(&prepared).is_err()); + // No-op intent needs no Undo reservation even at the admission boundary. + assert!( + document + .alias_rename_request(id(2), "assess".into()) + .is_ok() + ); + unchanged.assert(&document); +} diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index cbec975f..1960a38c 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -805,7 +805,7 @@ fn article(topic: Topic) -> Article { const { Article { summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.", - keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity", + 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", @@ -818,10 +818,31 @@ fn article(topic: Topic) -> Article { "Save all writes the entry and its included UTF-8 files. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.", ], ), + explain( + "Follow definitions and lexical references", + "Place the caret on a declaration alias or an @reference. Choose Definition (F12) to locate its declaration, or References (Shift+F12) to list lexical uses. Choose a row when a definition is ambiguous, draft or detached. Lexical uses exclude comments and strings; they do not establish typed relationships or resolve an ambiguous alias to one declaration. Back (Alt+Left) returns to the previous source location; if that draft changed, it restores the current cursor instead of an obsolete selection. These shortcuts work while the source editor has focus; the Text toolbar also offers the actions.", + ), + explain( + "Read the navigation scope", + "Queries inspect bounded snapshots of currently loaded drafts, without applying them or loading missing included files. The panel identifies partial scope, draft evidence, detached or unverified files, and alias or ID collisions. Counts show total, displayed and omitted rows. A missing result in partial scope does not establish that the whole project has no match. If sources change, stale rows are disabled; run Definition or References again. Row locations use one-based lines and UTF-8 byte columns. Navigation preserves your source drafts and the accepted model.", + ), + explain( + "Find the record in diagrams", + "Choose Show in diagrams from the Text toolbar on a unique verified alias in clean sources, or from the chosen model-tree record's context menu. Choose a diagram destination, then an exact representation. Results follow visible projection: ports, state rows, repeated caller tiles and their drawn sequence links remain distinct. Hidden tiles and nested links that are not drawn are excluded. A direct allocation can be represented by visible grouping; aggregate summaries do not invent allocation relations. A visual marker locates the port, row or grouped work while keeping the queried semantic record selected for model actions. Moving the camera preserves that marker. Finish pending edits first; navigation preserves accepted geometry, sources and history.", + ), explain( "Names, aliases and IDs serve different purposes", "The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.", ), + walkthrough( + "Rename a source alias", + &[ + "Place the caret on a declaration alias or live @reference, then choose Rename alias or press F2 in the source editor. First finish or revert pending source, property and behavior drafts; close or reattach any extra loaded detached files. Accepted edits do not need to be saved first.", + "Edit Source alias and choose Preview (Enter also previews). Pasting line breaks, tabs or other control characters is rejected without changing the field; choose Preview again after correcting the paste. Review the changed files, hunks and lines; long lines have additional segments. The engineering name and UUID stay unchanged. Comments and quoted prose keep their original spelling. This preview covers the rename; a later Save can include earlier accepted edits too.", + "Choose Rename alias to accept the complete preview, or Cancel/Escape to leave everything unchanged. An invalid, unchanged, stale or oversized preview cannot be confirmed. After editing the replacement, choose Preview again. If the source context changes, close the form and invoke Rename again on the current alias.", + "Use Undo rename immediately, or the model Undo/Redo controls, to restore the complete source/model snapshot. Ctrl+Z in Text still undoes source drafting. Save all and reopen use the normal project workflow; an unconfirmed preview is not saved.", + ], + ), example( "Readable references", "For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.", diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index e632d62d..5d802195 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -9,6 +9,7 @@ mod context_menu; mod data_values; mod data_view; mod diagram; +mod diagram_usage; mod document; mod explorer; mod explorer_view; @@ -26,6 +27,7 @@ mod recovery; mod route_edit; mod semantic_review; mod source_navigation; +mod source_rename; mod source_tree; mod source_workspace; mod style; diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index b07a2f08..a804c37c 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -281,6 +281,11 @@ pub fn popup(app: &App) -> Option> { } entries.push(item("Properties", Message::EditProperties(id), true)); entries.push(item("Open source", Message::SourceForSelection, true)); + entries.push(item( + "Show in diagrams…", + app.diagram_usage_message(id), + true, + )); entries.push(item( "Connections & references", Message::InspectorTab(crate::navigation::InspectorTab::Connections), diff --git a/crates/oa-desktop/src/navigation.rs b/crates/oa-desktop/src/navigation.rs index 3439b7e6..014e7a20 100644 --- a/crates/oa-desktop/src/navigation.rs +++ b/crates/oa-desktop/src/navigation.rs @@ -126,28 +126,8 @@ fn groups(p: &Project, selected: Id) -> Vec { ] } -pub fn diagrams_containing(p: &Project, selected: Id) -> Vec { - let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]); - p.diagrams - .values() - .filter(|d| { - let placed = |id| { - let e = p.elements.get(&id); - let owner = e - .filter(|e| e.kind() == Kind::Port) - .and_then(|e| e.parent) - .unwrap_or(id); - let kind = p.elements.get(&owner).map(|e| e.kind()); - kind.is_none_or(|kind| d.projection.includes(kind)) - && d.nodes.values().any(|n| n.element == owner || n.id == id) - }; - placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed)) - }) - .map(|d| d.id) - .collect() -} - -pub fn view(p: &Project, selected: Id) -> Element<'_, Message> { +pub fn view(app: &crate::app::App, selected: Id) -> Element<'_, Message> { + let p = app.project(); let mut content = column![ text(short(p.named(selected), 50)).size(18), text("Follow ownership, usage and engineering relationships.") @@ -203,25 +183,8 @@ pub fn view(p: &Project, selected: Id) -> Element<'_, Message> { content = content.push(container(entry).padding(8).style(style::subtle)); } } - let diagrams = diagrams_containing(p, selected); - if !diagrams.is_empty() { - content = content.push(label("SHOWN IN DIAGRAMS")); - for id in diagrams { - content = content.push( - action( - &short(p.named(id), 42), - Message::RevealInDiagram(id, selected), - ) - .width(Fill), - ); - } - } else if p.elements.contains_key(&selected) { - content = content.push( - text("This element has no diagram placement yet.") - .size(12) - .color(style::MUTED), - ); - } + content = + content.push(action("Show in diagrams…", app.diagram_usage_message(selected)).width(Fill)); if count == 0 { content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED)); } diff --git a/crates/oa-desktop/src/source_navigation.rs b/crates/oa-desktop/src/source_navigation.rs index 4b7c50b1..ced9ac1b 100644 --- a/crates/oa-desktop/src/source_navigation.rs +++ b/crates/oa-desktop/src/source_navigation.rs @@ -1,4 +1,9 @@ //! Literal source search and line navigation, independent of accepted model state. +mod controller; +pub mod interaction; +pub mod query; +pub mod symbols; +pub use controller::*; use iced::widget::text_editor::{Cursor, Position}; #[derive(Debug, Clone)] diff --git a/crates/oa-desktop/src/source_navigation/controller.rs b/crates/oa-desktop/src/source_navigation/controller.rs new file mode 100644 index 00000000..620a6988 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation/controller.rs @@ -0,0 +1,126 @@ +//! Ephemeral editor navigation; no model commands or filesystem operations. +use super::query; +use iced::widget::text_editor::{Cursor, Position}; +use oa_core::Id; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; + +#[derive(Debug, Clone)] +pub enum SymbolEvent { + DiagramUsages, + Query(query::Action), + Back, + Close, + Choose { + stamp: query::QueryStamp, + reference: bool, + index: usize, + }, +} + +/// Identity of the mounted source view that emitted an action. Message queues +/// may outlive that view; request-time stamping alone cannot detect replay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SymbolContext { + pub session: Id, + pub path: String, +} + +#[derive(Debug, Clone)] +pub struct HistoryEntry { + pub session: Id, + pub path: String, + pub generation: u64, + pub cursor: Cursor, +} + +#[derive(Debug, Clone)] +pub struct Request { + pub job: Id, + pub interaction: Id, + pub raw_interaction: u64, + pub stamp: query::QueryStamp, + pub path: String, + pub offset: usize, + pub cursor: Cursor, + pub action: query::Action, +} + +#[derive(Debug, Clone)] +pub struct Completion { + pub request: Request, + pub result: Result<(Arc, Arc), String>, +} + +pub struct Reveal { + pub ticket: Id, + pub interaction: Id, + pub raw_interaction: u64, + pub snapshot: query::SnapshotKey, + pub path: String, +} + +pub struct SymbolState { + pub open: bool, + pub interaction: Id, + pub raw_interaction: Arc, + pub serial: u64, + pub in_flight: Option, + pub request: Option, + pub cache: Option>, + pub results: Option>, + pub notice: Option, + pub back: Vec, + pub reveal: Option, +} + +impl Default for SymbolState { + fn default() -> Self { + Self { + open: false, + interaction: Id::new_v4(), + raw_interaction: Arc::new(AtomicU64::new(0)), + serial: 0, + in_flight: None, + request: None, + cache: None, + results: None, + notice: None, + back: Vec::new(), + reveal: None, + } + } +} + +/// Iced uses zero-based UTF-8 byte columns. Reject stale or split-codepoint +/// coordinates; do not normalize CRLF or silently clamp query destinations. +pub fn byte_offset(text: &str, position: Position) -> Option { + let start = if position.line == 0 { + 0 + } else { + text.match_indices('\n').nth(position.line - 1)?.0 + 1 + }; + let end = text[start..] + .find('\n') + .map_or(text.len(), |end| start + end); + let offset = start.checked_add(position.column)?; + (offset <= end && text.is_char_boundary(offset)).then_some(offset) +} + +pub fn cursor_offset(text: &str, cursor: Cursor) -> Option { + let position = cursor.selection.map_or(cursor.position, |selection| { + if (selection.line, selection.column) < (cursor.position.line, cursor.position.column) { + selection + } else { + cursor.position + } + }); + byte_offset(text, position) +} + +pub fn exact_cursor(text: &str, cursor: Cursor) -> bool { + byte_offset(text, cursor.position).is_some() + && cursor + .selection + .is_none_or(|position| byte_offset(text, position).is_some()) +} diff --git a/crates/oa-desktop/src/source_navigation/interaction.rs b/crates/oa-desktop/src/source_navigation/interaction.rs new file mode 100644 index 00000000..394a1c49 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation/interaction.rs @@ -0,0 +1,284 @@ +//! Raw input invalidation and focus checks at widget-operation execution time. +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget}; +use iced::{Element, Event, Length, Rectangle, Size, Task, Vector, keyboard, mouse, touch}; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +/// Exhaustion fails closed: a saturated clock can never authorize navigation. +/// Replace the clock with a new workspace/controller, rather than wrapping it. +pub fn is_current(clock: &AtomicU64, expected: u64) -> bool { + expected != u64::MAX && clock.load(Ordering::Acquire) == expected +} + +fn invalidate(clock: &AtomicU64, event: &Event) { + if matches!( + event, + Event::Keyboard(keyboard::Event::KeyPressed { .. }) + | Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. }) + | Event::Touch(touch::Event::FingerPressed { .. }) + | Event::Window(iced::window::Event::Unfocused) + ) { + let _ = clock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| { + Some(value.saturating_add(1)) + }); + } +} + +/// Transparent, including overlays: invalidate before the child can capture input. +pub fn observe<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>( + content: Element<'a, Message, Theme, Renderer>, + clock: Arc, +) -> Element<'a, Message, Theme, Renderer> { + Element::new(Observed { content, clock }) +} + +struct Observed<'a, Message, Theme, Renderer> { + content: Element<'a, Message, Theme, Renderer>, + clock: Arc, +} + +impl Widget + for Observed<'_, Message, Theme, Renderer> +{ + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn tag(&self) -> widget::tree::Tag { + self.content.as_widget().tag() + } + fn state(&self) -> widget::tree::State { + self.content.as_widget().state() + } + fn children(&self) -> Vec { + self.content.as_widget().children() + } + fn diff(&self, tree: &mut widget::Tree) { + self.content.as_widget().diff(tree); + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content.as_widget_mut().layout(tree, renderer, limits) + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content + .as_widget() + .draw(tree, renderer, theme, style, layout, cursor, viewport); + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(tree, layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + invalidate(&self.clock, event); + self.content.as_widget_mut().update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(tree, layout, cursor, viewport, renderer) + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let clock = self.clock.clone(); + self.content + .as_widget_mut() + .overlay(tree, layout, renderer, viewport, translation) + .map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock }))) + } +} + +struct ObservedOverlay<'a, Message, Theme, Renderer> { + content: overlay::Element<'a, Message, Theme, Renderer>, + clock: Arc, +} +impl overlay::Overlay + for ObservedOverlay<'_, Message, Theme, Renderer> +{ + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + self.content.as_overlay_mut().layout(renderer, bounds) + } + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + self.content + .as_overlay() + .draw(renderer, theme, style, layout, cursor); + } + fn operate( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_overlay_mut() + .operate(layout, renderer, operation); + } + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + invalidate(&self.clock, event); + self.content + .as_overlay_mut() + .update(event, layout, cursor, renderer, clipboard, shell); + } + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content + .as_overlay() + .mouse_interaction(layout, cursor, renderer) + } + fn overlay<'a>( + &'a mut self, + layout: Layout<'a>, + renderer: &Renderer, + ) -> Option> { + let clock = self.clock.clone(); + self.content + .as_overlay_mut() + .overlay(layout, renderer) + .map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock }))) + } + fn index(&self) -> f32 { + self.content.as_overlay().index() + } +} + +/// Inspect all focus owners, then focus only the mounted target. Iced executes +/// Operation::Chain synchronously, without an intervening input event. Both +/// traversals recheck the raw clock; no result message authorizes later focus. +pub fn guarded_focus( + target: widget::Id, + clock: Arc, + expected: u64, +) -> Task { + widget::operate(GuardedFocus::new(target, clock, expected)) +} + +struct GuardedFocus { + target: widget::Id, + clock: Arc, + expected: u64, + target_count: usize, + foreign_focus: bool, + apply: bool, +} +impl GuardedFocus { + fn new(target: widget::Id, clock: Arc, expected: u64) -> Self { + Self { + target, + clock, + expected, + target_count: 0, + foreign_focus: false, + apply: false, + } + } +} +impl widget::Operation for GuardedFocus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + if is_current(&self.clock, self.expected) { + visit(self); + } + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + if !is_current(&self.clock, self.expected) { + return; + } + if id == Some(&self.target) { + self.target_count += 1; + if self.apply { + state.focus(); + } + } else if state.is_focused() { + self.foreign_focus = true; + } + } + fn finish(&self) -> widget::operation::Outcome { + if !self.apply + && self.target_count == 1 + && !self.foreign_focus + && is_current(&self.clock, self.expected) + { + widget::operation::Outcome::Chain(Box::new(Self { + apply: true, + ..Self::new(self.target.clone(), self.clock.clone(), self.expected) + })) + } else { + widget::operation::Outcome::None + } + } +} + +#[cfg(all(test, debug_assertions))] +mod tests; diff --git a/crates/oa-desktop/src/source_navigation/interaction/tests.rs b/crates/oa-desktop/src/source_navigation/interaction/tests.rs new file mode 100644 index 00000000..ed9b83f3 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation/interaction/tests.rs @@ -0,0 +1,190 @@ +use super::*; +use iced::advanced::widget::operation::{Focusable, Outcome}; +use iced::widget::{column, text_input}; +use iced::{Point, Theme}; + +#[derive(Default)] +struct FocusMap(Vec<(Option, Rectangle, bool)>); +impl widget::Operation for FocusMap { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable(&mut self, id: Option<&widget::Id>, bounds: Rectangle, state: &mut dyn Focusable) { + self.0.push((id.cloned(), bounds, state.is_focused())); + } +} + +struct Mounted { + element: Element<'static, (), Theme, ()>, + tree: widget::Tree, + node: layout::Node, +} +impl Mounted { + fn new(clock: Arc) -> Self { + let content = column![ + text_input("Editor", "source") + .id("editor") + .on_input(|_| ()) + .padding(12), + text_input("Find", "") + .id("find") + .on_input(|_| ()) + .padding(12), + ] + .spacing(10) + .into(); + let mut element = observe(content, clock); + let mut tree = widget::Tree::new(element.as_widget()); + let node = element.as_widget_mut().layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(400., 200.)), + ); + Self { + element, + tree, + node, + } + } + fn pass(&mut self, operation: &mut dyn widget::Operation) { + self.element.as_widget_mut().operate( + &mut self.tree, + Layout::new(&self.node), + &(), + operation, + ); + } + fn run(&mut self, operation: impl widget::Operation + 'static) { + let mut next: Box = Box::new(operation); + loop { + self.pass(next.as_mut()); + match next.finish() { + Outcome::Chain(chained) => next = chained, + Outcome::None => break, + Outcome::Some(()) => panic!("focus must not publish an application message"), + } + } + } + fn focus(&mut self) -> FocusMap { + let mut focus = FocusMap::default(); + self.pass(&mut focus); + focus + } + fn click(&mut self, id: &str) { + let bounds = self + .focus() + .0 + .into_iter() + .find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned()))) + .unwrap() + .1; + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + self.element.as_widget_mut().update( + &mut self.tree, + &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Layout::new(&self.node), + mouse::Cursor::Available(bounds.center()), + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &Rectangle::with_size(Size::new(400., 200.)), + ); + assert!(shell.is_event_captured()); + assert!( + messages.is_empty(), + "focus-only click must not rely on App messages" + ); + } + fn focused(&mut self, id: &str) -> bool { + self.focus() + .0 + .into_iter() + .find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned()))) + .unwrap() + .2 + } +} + +#[test] +fn captured_find_click_invalidates_without_app_message_and_preserves_focus() { + let clock = Arc::new(AtomicU64::new(0)); + let mut ui = Mounted::new(clock.clone()); + ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0)); + assert!(ui.focused("editor")); + ui.click("find"); + assert!(!is_current(&clock, 0)); + assert!(ui.focused("find")); + ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0)); + assert!(ui.focused("find")); + // Even a matching clock cannot authorize stealing another widget's focus. + ui.run(GuardedFocus::new( + "editor".into(), + clock.clone(), + clock.load(Ordering::Acquire), + )); + assert!(ui.focused("find")); + assert!(!ui.focused("editor")); +} + +#[test] +fn focus_rechecks_clock_between_inspection_and_application_and_missing_target_is_inert() { + let clock = Arc::new(AtomicU64::new(0)); + let mut ui = Mounted::new(clock.clone()); + let mut operation = GuardedFocus::new("editor".into(), clock.clone(), 0); + ui.pass(&mut operation); + let Outcome::Chain(mut apply) = >::finish(&operation) + else { + panic!("mounted, unopposed target must be eligible"); + }; + clock.store(1, Ordering::Release); + ui.pass(apply.as_mut()); + assert!(!ui.focused("editor")); + ui.run(GuardedFocus::new("editor".into(), clock.clone(), 1)); + assert!(ui.focused("editor")); + ui.run(GuardedFocus::new("removed-editor".into(), clock, 1)); + assert!(ui.focused("editor")); +} + +#[test] +fn raw_navigation_inputs_advance_but_passive_geometry_does_not_and_exhaustion_refuses() { + let clock = AtomicU64::new(0); + let events = [ + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft), + modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowLeft), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }), + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, + }), + Event::Touch(touch::Event::FingerPressed { + id: touch::Finger(1), + position: Point::ORIGIN, + }), + Event::Window(iced::window::Event::Unfocused), + ]; + for (index, event) in events.iter().enumerate() { + invalidate(&clock, event); + assert_eq!(clock.load(Ordering::Acquire), index as u64 + 1); + } + invalidate( + &clock, + &Event::Mouse(mouse::Event::CursorMoved { + position: Point::ORIGIN, + }), + ); + invalidate(&clock, &Event::Window(iced::window::Event::Focused)); + assert!(is_current(&clock, 5)); + clock.store(u64::MAX - 1, Ordering::Release); + invalidate(&clock, &events[0]); + assert!(!is_current(&clock, u64::MAX)); + invalidate(&clock, &events[0]); + assert_eq!(clock.load(Ordering::Acquire), u64::MAX); + assert!(!is_current(&clock, 0)); +} diff --git a/crates/oa-desktop/src/source_navigation/query.rs b/crates/oa-desktop/src/source_navigation/query.rs new file mode 100644 index 00000000..316224b3 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation/query.rs @@ -0,0 +1,1131 @@ +//! Owned, bounded queries over exact current source buffers. +//! +//! Includes are inspected from these bytes only. A previously validated bundle +//! is an equality witness, never a source of missing text or navigation spans. +//! The caller must advance the workspace key on every indexed-input change and +//! reject asynchronous results whose key, query serial or byte readback changed. + +use super::symbols::{self, Index}; +use oa_core::Id; +use oa_core::language::{self, OutlineLimits, Span, TokenClass, TokenSpan}; +use oa_core::source_project::{self, IncludeLocation, ProjectLimits}; +use std::collections::BTreeMap; + +pub use super::symbols::{Evidence, Identity, Membership, Position, SnapshotKey}; + +#[derive(Debug, Clone, Copy)] +pub struct DraftFile<'a> { + pub path: &'a str, + pub text: &'a str, +} + +/// The caller asserts that this complete source bundle passed explicit semantic +/// validation. Capture independently compares its entry, membership and bytes. +#[derive(Debug, Clone, Copy)] +pub struct ValidatedSources<'a> { + pub entry: &'a str, + pub files: &'a [DraftFile<'a>], +} + +#[derive(Debug, Clone, Copy)] +pub struct Input<'a> { + pub key: SnapshotKey, + pub entry: &'a str, + pub files: &'a [DraftFile<'a>], + pub applied: bool, + pub validation: Option>, +} + +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub index: symbols::Limits, + pub include_depth: usize, + pub include_edges: usize, + pub result_rows: usize, + /// Upper bound on copied result strings, including quoted name input before + /// JSON decoding. Row/vector overhead is separately bounded by result_rows. + pub result_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + index: symbols::Limits { + files: 256, + source_bytes: 64 * 1024 * 1024, + path_bytes: 64 * 1024, + tokens: 1_000_000, + records: 250_000, + }, + include_depth: 32, + include_edges: 1024, + result_rows: 200, + result_bytes: 256 * 1024, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdmissionError { + Index(symbols::BuildError), + Outline { + file: usize, + error: language::OutlineLimit, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScopeIssueKind { + MissingEntry, + InvalidIncludes, + MissingInclude, + IncludeCycle, + IncludeDepth, + IncludeBudget, +} + +/// File indices refer to Snapshot::file. Include spans retain the original +/// quoted include location; diagnostics do not copy unbounded source strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScopeIssue { + pub kind: ScopeIssueKind, + pub file: Option, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Scope { + pub closure_complete: bool, + pub applied: bool, + pub validated: bool, + pub included_files: usize, + pub detached_files: usize, + pub unverified_files: usize, + pub issues: Vec, +} + +impl Scope { + pub fn partial(&self) -> bool { + !self.closure_complete || !self.applied + } + + fn index_scope(&self) -> symbols::Scope { + if !self.applied { + symbols::Scope::Partial(symbols::PartialReason::UnappliedWorkspace) + } else if !self.closure_complete { + let missing = self.issues.iter().any(|issue| { + matches!( + issue.kind, + ScopeIssueKind::MissingEntry | ScopeIssueKind::MissingInclude + ) + }); + symbols::Scope::Partial(if missing { + symbols::PartialReason::MissingFiles + } else { + symbols::PartialReason::IncludesUnverified + }) + } else { + symbols::Scope::CompleteProject + } + } +} + +#[derive(Debug)] +struct File { + path: String, + text: String, + tokens: Vec, + declarations: Vec, + membership: Membership, + evidence: Evidence, +} + +/// Immutable and Send: callers can retain it in an Arc for asynchronous work. +/// No Workspace, accepted Project, filesystem handle or mutation is retained. +#[derive(Debug)] +pub struct Snapshot { + key: SnapshotKey, + entry: String, + files: Vec, + scope: Scope, + limits: Limits, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QueryStamp { + pub snapshot: SnapshotKey, + pub serial: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Definition, + References, +} + +#[derive(Debug, Clone, Copy)] +pub struct Query<'a> { + pub stamp: QueryStamp, + pub path: &'a str, + pub byte_offset: usize, + pub action: Action, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Disposition { + NoSymbol, + NoDefinition, + /// All candidates require an explicit choice, including a sole draft or + /// detached candidate. Truncation can never imply unique resolution. + Choices, + Direct, + LexicalReferences, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Declaration { + pub alias: String, + pub kind: String, + pub name: Option, + pub span: Span, + pub authored_id: Option, + pub identity: Identity, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefinitionRow { + pub path: String, + /// The alias token to select, rather than an enclosing declaration body. + pub span: Span, + pub position: Position, + pub end_position: Position, + pub declaration: Declaration, + pub membership: Membership, + pub evidence: Evidence, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReferenceRow { + pub path: String, + pub alias: String, + pub span: Span, + /// Separate live @ token: intervening comments are never selected as a hit. + pub marker: Span, + pub position: Position, + pub end_position: Position, + /// Innermost tolerant declaration containing this occurrence, if available. + /// This describes its lexical location, not its semantic target or owner. + pub context: Option, + pub membership: Membership, + pub evidence: Evidence, +} + +/// Exact lexical occurrence that answered the query, independent of row limits. +/// The optional @ marker is separate from the alias; comments between them do +/// not become part of either hit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Origin { + pub span: Span, + pub marker: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Results { + pub stamp: QueryStamp, + pub action: Action, + pub origin_path: String, + pub origin_offset: usize, + pub origin: Option, + pub alias: Option, + pub scope: Scope, + pub disposition: Disposition, + pub definitions: Vec, + pub references: Vec, + pub total_definitions: usize, + pub total_references: usize, + pub omitted_definitions: usize, + pub omitted_references: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueryError { + Index(symbols::BuildError), + Query(symbols::QueryError), + MissingFile, + ResultEnvelopeLimit, +} + +impl From for QueryError { + fn from(error: symbols::QueryError) -> Self { + Self::Query(error) + } +} + +fn charge( + total: &mut usize, + value: usize, + limit: usize, + resource: symbols::Resource, +) -> Result<(), AdmissionError> { + let next = + total + .checked_add(value) + .ok_or(AdmissionError::Index(symbols::BuildError::Limit { + resource, + limit, + observed_at_least: usize::MAX, + }))?; + if next > limit { + return Err(AdmissionError::Index(symbols::BuildError::Limit { + resource, + limit, + observed_at_least: next, + })); + } + *total = next; + Ok(()) +} + +/// No path normalization, outline extraction, accepted-source copy or index +/// allocation takes place until aggregate raw input admission succeeds. +fn admit_raw(input: Input<'_>, limits: Limits) -> Result<(), AdmissionError> { + charge( + &mut 0, + input.files.len(), + limits.index.files, + symbols::Resource::Files, + )?; + let mut bytes = 0; + let mut paths = 0; + charge( + &mut paths, + input.entry.len(), + limits.index.path_bytes, + symbols::Resource::PathBytes, + )?; + for file in input.files { + charge( + &mut bytes, + file.text.len(), + limits.index.source_bytes, + symbols::Resource::SourceBytes, + )?; + charge( + &mut paths, + file.path.len(), + limits.index.path_bytes, + symbols::Resource::PathBytes, + )?; + } + Ok(()) +} + +struct Extracted { + tokens: Vec, + declarations: Vec, +} + +#[derive(Debug)] +struct OwnedFile { + path: String, + text: String, +} + +#[derive(Debug, Default)] +struct ValidationProof { + admitted: bool, + file_count: usize, + matches: Vec, +} + +impl ValidationProof { + fn capture(input: Input<'_>, limits: Limits) -> Self { + let Some(validation) = input.validation else { + return Self::default(); + }; + if validation.entry != input.entry + || admit_raw( + Input { + files: validation.files, + entry: validation.entry, + validation: None, + ..input + }, + limits, + ) + .is_err() + { + return Self::default(); + } + let files: BTreeMap<_, _> = validation + .files + .iter() + .map(|file| (file.path, file.text)) + .collect(); + if files.len() != validation.files.len() { + return Self::default(); + } + Self { + admitted: true, + file_count: files.len(), + matches: input + .files + .iter() + .map(|file| files.get(file.path).is_some_and(|text| *text == file.text)) + .collect(), + } + } + + fn matches(&self, memberships: &[Membership], scope: &Scope) -> bool { + self.admitted + && scope.closure_complete + && scope.applied + && self.file_count == scope.included_files + && self.matches.len() == memberships.len() + && memberships + .iter() + .zip(&self.matches) + .all(|(membership, equal)| *membership != Membership::Included || *equal) + } +} + +/// Request-time data for spawn_blocking: raw admission plus one current-text +/// clone, with no lexer/parser work. The private equality proof contains only +/// booleans/counts, never a second accepted SourceBundle. These private fields +/// prevent changing the bytes after their equality evidence was captured. +#[derive(Debug)] +pub struct OwnedInput { + key: SnapshotKey, + entry: String, + files: Vec, + applied: bool, + validation: ValidationProof, + limits: Limits, +} + +pub fn admit_owned(input: Input<'_>, limits: Limits) -> Result { + admit_raw(input, limits)?; + let validation = ValidationProof::capture(input, limits); + Ok(OwnedInput { + key: input.key, + entry: input.entry.to_owned(), + files: input + .files + .iter() + .map(|file| OwnedFile { + path: file.path.to_owned(), + text: file.text.to_owned(), + }) + .collect(), + applied: input.applied, + validation, + limits, + }) +} + +impl OwnedInput { + /// Run on the blocking worker. Token/declaration/reference budgets are + /// enforced before extraction/index allocation, then strings are moved into + /// Snapshot without another copy. A limit error never returns partial data. + pub fn capture(self) -> Result { + let files: Vec<_> = self + .files + .iter() + .map(|file| DraftFile { + path: &file.path, + text: &file.text, + }) + .collect(); + let input = Input { + key: self.key, + entry: &self.entry, + files: &files, + applied: self.applied, + validation: None, + }; + let prepared = prepare(input, self.limits, Some(&self.validation))?; + Ok(finish( + self.key, + self.entry, + self.files, + self.limits, + prepared, + )) + } +} + +struct Prepared { + extracted: Vec, + memberships: Vec, + scope: Scope, +} + +#[cfg(test)] +pub fn capture(input: Input<'_>, limits: Limits) -> Result { + let prepared = prepare(input, limits, None)?; + let files = input + .files + .iter() + .map(|file| OwnedFile { + path: file.path.to_owned(), + text: file.text.to_owned(), + }) + .collect(); + Ok(finish( + input.key, + input.entry.to_owned(), + files, + limits, + prepared, + )) +} + +fn prepare( + input: Input<'_>, + limits: Limits, + proof: Option<&ValidationProof>, +) -> Result { + admit_raw(input, limits)?; + let mut extracted = Vec::with_capacity(input.files.len()); + let mut tokens_left = limits.index.tokens; + let mut records_left = limits.index.records; + for (file, draft) in input.files.iter().enumerate() { + // Remaining aggregate budgets are passed before this file is lexed. + // A stopped outline returns an error, never a complete zero-result file. + let outline = language::outline( + draft.text, + OutlineLimits { + max_source_bytes: limits.index.source_bytes, + max_tokens: tokens_left, + max_declarations: records_left, + }, + ) + .map_err(|error| AdmissionError::Outline { file, error })?; + tokens_left -= outline.tokens.len(); + records_left -= outline.declarations.len(); + extracted.push(Extracted { + tokens: outline.tokens, + declarations: outline + .declarations + .into_iter() + .map(|declaration| symbols::DeclarationInput { + span: declaration.declaration.span, + kind_span: declaration.kind_span, + alias_span: declaration.alias_span, + identity: declaration + .declaration + .id + .zip(declaration.id_literal_span) + .map(|(id, literal_span)| symbols::AuthoredIdentity { id, literal_span }), + }) + .collect(), + }); + } + // Reuse the reviewed live-reference counter and structural admission, rather + // than maintain a second lexer/reference recognizer. No text has been copied. + let inputs: Vec<_> = input + .files + .iter() + .zip(&extracted) + .map(|(file, outline)| symbols::FileInput { + path: file.path, + text: file.text, + tokens: &outline.tokens, + declarations: &outline.declarations, + syntax: symbols::SyntaxCoverage::FullFile, + membership: Membership::Unverified, + evidence: Evidence::Draft, + }) + .collect(); + Index::build( + input.key, + &inputs, + symbols::Scope::Partial(symbols::PartialReason::IncludesUnverified), + limits.index, + ) + .map_err(AdmissionError::Index)?; + let (memberships, mut scope) = inspect_closure(input, limits); + scope.validated = proof.map_or_else( + || validation_matches(input, &memberships, &scope, limits), + |proof| proof.matches(&memberships, &scope), + ); + Ok(Prepared { + extracted, + memberships, + scope, + }) +} + +fn finish( + key: SnapshotKey, + entry: String, + files: Vec, + limits: Limits, + prepared: Prepared, +) -> Snapshot { + let Prepared { + extracted, + memberships, + scope, + } = prepared; + let files = files + .into_iter() + .zip(extracted) + .zip(memberships) + .map(|((draft, outline), membership)| File { + path: draft.path, + text: draft.text, + tokens: outline.tokens, + declarations: outline.declarations, + membership, + evidence: if scope.validated && membership == Membership::Included { + Evidence::Validated + } else { + Evidence::Draft + }, + }) + .collect(); + Snapshot { + key, + entry, + files, + scope, + limits, + } +} + +fn validation_matches( + input: Input<'_>, + memberships: &[Membership], + scope: &Scope, + limits: Limits, +) -> bool { + if !scope.closure_complete || !input.applied { + return false; + } + ValidationProof::capture(input, limits).matches(memberships, scope) +} + +struct Frame { + file: usize, + depth: usize, + includes: std::vec::IntoIter, +} + +fn inspect_closure(input: Input<'_>, limits: Limits) -> (Vec, Scope) { + let paths: BTreeMap<_, _> = input + .files + .iter() + .enumerate() + .map(|(index, file)| (file.path, index)) + .collect(); + let mut memberships = vec![Membership::Unverified; input.files.len()]; + let mut states = vec![0_u8; input.files.len()]; + let mut issues = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut edges_left = limits.include_edges; + let mut pending = paths.get(input.entry).copied().map(|file| (file, 0, None)); + if pending.is_none() { + issues.push(ScopeIssue { + kind: ScopeIssueKind::MissingEntry, + file: None, + span: Span::default(), + }); + } + loop { + if let Some((file, depth, origin)) = pending.take() { + memberships[file] = Membership::Included; + let (at_file, at_span) = origin.unwrap_or((file, Span::default())); + let problem = if states[file] == 1 { + Some(ScopeIssueKind::IncludeCycle) + } else if depth > limits.include_depth { + Some(ScopeIssueKind::IncludeDepth) + } else { + None + }; + if let Some(kind) = problem { + issues.push(ScopeIssue { + kind, + file: Some(at_file), + span: at_span, + }); + } else if states[file] == 0 { + states[file] = 1; + let draft = input.files[file]; + let project_limits = ProjectLimits { + max_files: limits.index.files, + max_include_depth: limits.include_depth, + max_include_edges: edges_left, + model: language::ParseLimits { + max_source_bytes: limits.index.source_bytes, + max_graph_objects: limits.index.records, + max_records: limits.index.records, + max_step_references: limits.index.records, + }, + }; + match source_project::inspect_includes( + draft.path, + draft.text, + draft.path == input.entry, + project_limits, + ) { + Ok(includes) => { + edges_left -= includes.len(); + stack.push(Frame { + file, + depth, + includes: includes.into_iter(), + }); + } + Err(diagnostics) => { + let first = diagnostics.first(); + let kind = if first.is_some_and(|d| d.diagnostic.code == "project_limit") { + ScopeIssueKind::IncludeBudget + } else { + ScopeIssueKind::InvalidIncludes + }; + issues.push(ScopeIssue { + kind, + file: Some(file), + span: first.map_or(Span::default(), |d| d.diagnostic.span), + }); + states[file] = 2; + } + } + } + } + let Some(frame) = stack.last_mut() else { + break; + }; + if let Some(include) = frame.includes.next() { + if let Some(&file) = paths.get(include.target.as_str()) { + pending = Some(( + file, + frame.depth.saturating_add(1), + Some((frame.file, include.location.span)), + )); + } else { + issues.push(ScopeIssue { + kind: ScopeIssueKind::MissingInclude, + file: Some(frame.file), + span: include.location.span, + }); + } + } else { + states[frame.file] = 2; + stack.pop(); + } + } + let complete = issues.is_empty(); + if complete { + for membership in &mut memberships { + if *membership == Membership::Unverified { + *membership = Membership::Detached; + } + } + } + let scope = Scope { + closure_complete: complete, + applied: input.applied, + validated: false, + included_files: memberships + .iter() + .filter(|m| **m == Membership::Included) + .count(), + detached_files: memberships + .iter() + .filter(|m| **m == Membership::Detached) + .count(), + unverified_files: memberships + .iter() + .filter(|m| **m == Membership::Unverified) + .count(), + issues, + }; + (memberships, scope) +} + +impl Snapshot { + pub fn key(&self) -> SnapshotKey { + self.key + } + + #[cfg(test)] + pub fn scope(&self) -> &Scope { + &self.scope + } + + #[cfg(test)] + pub fn file(&self, index: usize) -> Option> { + self.files.get(index).map(|file| DraftFile { + path: &file.path, + text: &file.text, + }) + } + + /// Exact loaded file-set/byte comparison, independent of input order. The + /// evidence outcome is rechecked too; irrelevant old witness text is not kept. + /// The key check is mandatory even if bytes happen to match after Undo. + pub fn matches(&self, input: Input<'_>) -> bool { + if input.key != self.key + || input.entry != self.entry + || input.applied != self.scope.applied + || input.files.len() != self.files.len() + || admit_raw(input, self.limits).is_err() + { + return false; + } + let mut memberships = Vec::with_capacity(input.files.len()); + for draft in input.files { + let Some(file) = self + .files + .iter() + .find(|file| file.path == draft.path && file.text == draft.text) + else { + return false; + }; + // Duplicate paths are not the same file set, even if their bytes match. + if input + .files + .iter() + .filter(|other| other.path == draft.path) + .count() + != 1 + { + return false; + } + memberships.push(file.membership); + } + validation_matches(input, &memberships, &self.scope, self.limits) == self.scope.validated + } + + fn index(&self) -> Result, QueryError> { + let inputs: Vec<_> = self + .files + .iter() + .map(|file| symbols::FileInput { + path: &file.path, + text: &file.text, + tokens: &file.tokens, + declarations: &file.declarations, + syntax: symbols::SyntaxCoverage::FullFile, + membership: file.membership, + evidence: file.evidence, + }) + .collect(); + Index::build( + self.key, + &inputs, + self.scope.index_scope(), + self.limits.index, + ) + .map_err(QueryError::Index) + } + + pub fn query(&self, query: Query<'_>) -> Result { + if query.stamp.snapshot != self.key { + return Err(symbols::QueryError::StaleSnapshot.into()); + } + let file = self + .files + .iter() + .position(|file| file.path == query.path) + .ok_or(QueryError::MissingFile)?; + let index = self.index()?; + let hit = index.hit(file, query.byte_offset)?; + 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, + bytes: self.limits.result_bytes, + }; + if !budget.strings(query.path.len().saturating_add(alias.map_or(0, str::len))) { + return Err(QueryError::ResultEnvelopeLimit); + } + let mut result = Results { + stamp: query.stamp, + 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, + definitions: Vec::new(), + references: Vec::new(), + total_definitions: 0, + total_references: 0, + omitted_definitions: 0, + omitted_references: 0, + }; + let Some(alias) = alias else { + return Ok(result); + }; + result.total_definitions = index + .definitions_for_alias(alias, symbols::Domain::Loaded) + .count(); + for handle in index.definitions_for_alias(alias, symbols::Domain::Loaded) { + let definition = index.definition(handle)?; + let source = &self.files[definition.declaration.file]; + let display = self.display(&index, handle)?; + if !budget.row(source.path.len().saturating_add(display.bytes())) { + break; + } + result.definitions.push(DefinitionRow { + path: source.path.clone(), + span: definition.alias_location.span, + position: Position { + line: 0, + byte_column: 0, + }, + end_position: Position { + line: 0, + byte_column: 0, + }, + declaration: display.owned(), + membership: source.membership, + evidence: source.evidence, + }); + } + result.omitted_definitions = result.total_definitions - result.definitions.len(); + result.disposition = if result.total_definitions == 0 { + Disposition::NoDefinition + } else { + Disposition::Choices + }; + if query.action == Action::Definition + && result.total_definitions == 1 + && result.definitions.len() == 1 + && self.scope.validated + && self.files[file].membership == Membership::Included + { + let only = &result.definitions[0]; + if only.membership == Membership::Included + && only.evidence == Evidence::Validated + && !matches!(only.declaration.identity, Identity::Collision { .. }) + { + result.disposition = Disposition::Direct; + } + } + if query.action == Action::References { + result.disposition = Disposition::LexicalReferences; + result.total_references = index + .references_for_alias(alias, symbols::Domain::Loaded) + .count(); + for handle in index.references_for_alias(alias, symbols::Domain::Loaded) { + let reference = index.reference(handle)?; + let source = &self.files[reference.alias_location.file]; + let context = self.context(&index, reference.alias_location)?; + let bytes = source + .path + .len() + .saturating_add(alias.len()) + .saturating_add(context.as_ref().map_or(0, Display::bytes)); + if !budget.row(bytes) { + break; + } + result.references.push(ReferenceRow { + path: source.path.clone(), + alias: alias.to_owned(), + span: reference.alias_location.span, + marker: reference.at.span, + position: Position { + line: 0, + byte_column: 0, + }, + end_position: Position { + line: 0, + byte_column: 0, + }, + context: context.map(Display::owned), + membership: source.membership, + evidence: source.evidence, + }); + } + result.omitted_references = result.total_references - result.references.len(); + } + self.locate_rows(&mut result); + Ok(result) + } + + fn locate_rows(&self, result: &mut Results) { + // Index admission already proved every row span and UTF-8 boundary. + // Scan each selected source prefix once instead of rescanning a large + // buffer for every occurrence. Scratch has at most two entries per row. + for file in &self.files { + let mut offsets = Vec::new(); + for (row, definition) in result.definitions.iter().enumerate() { + if definition.path == file.path { + offsets.push((definition.span.start, true, row, false)); + offsets.push((definition.span.end, true, row, true)); + } + } + for (row, reference) in result.references.iter().enumerate() { + if reference.path == file.path { + offsets.push((reference.span.start, false, row, false)); + offsets.push((reference.span.end, false, row, true)); + } + } + offsets.sort_unstable_by_key(|&(offset, _, _, _)| offset); + let (mut scanned, mut line, mut line_start) = (0, 0, 0); + for (offset, definition, row, end) in offsets { + for (relative, byte) in file.text.as_bytes()[scanned..offset].iter().enumerate() { + if *byte == b'\n' { + line += 1; + line_start = scanned + relative + 1; + } + } + scanned = offset; + let position = Position { + line, + byte_column: offset - line_start, + }; + match (definition, end) { + (true, false) => result.definitions[row].position = position, + (true, true) => result.definitions[row].end_position = position, + (false, false) => result.references[row].position = position, + (false, true) => result.references[row].end_position = position, + } + } + } + } + + fn display<'a>( + &'a self, + index: &Index<'a>, + handle: symbols::DefinitionHandle, + ) -> Result, QueryError> { + let definition = index.definition(handle)?; + let file = &self.files[definition.declaration.file]; + let alias_end = definition.alias_location.span.end; + let next = file + .tokens + .partition_point(|token| token.span.start < alias_end); + // The coordinated outline has already proved the declaration and alias + // roles. This optional decoration only decodes its immediate name token. + let name = (definition.kind != "node") + .then(|| { + file.tokens[next..].iter().find(|token| { + !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) + }) + }) + .flatten() + .filter(|token| { + token.class == TokenClass::String + && token.span.end <= definition.declaration.span.end + }) + .map(|token| &file.text[token.span.start..token.span.end]); + Ok(Display { + alias: definition.alias, + kind: definition.kind, + quoted_name: name, + span: definition.declaration.span, + authored_id: definition.authored_id, + identity: index.identity(handle)?, + }) + } + + fn context<'a>( + &'a self, + index: &Index<'a>, + location: symbols::Location, + ) -> Result>, QueryError> { + let file = &self.files[location.file]; + let inner = file + .declarations + .iter() + .filter(|declaration| { + declaration.span.start <= location.span.start + && location.span.end <= declaration.span.end + }) + .min_by_key(|declaration| declaration.span.end - declaration.span.start); + let Some(inner) = inner else { + return Ok(None); + }; + let Some(symbols::Hit::Definition(handle)) = + index.hit(location.file, inner.alias_span.start)? + else { + return Ok(None); + }; + self.display(index, handle).map(Some) + } +} + +struct Display<'a> { + alias: &'a str, + kind: &'a str, + quoted_name: Option<&'a str>, + span: Span, + authored_id: Option, + identity: Identity, +} + +impl Display<'_> { + fn bytes(&self) -> usize { + self.alias + .len() + .saturating_add(self.kind.len()) + .saturating_add(self.quoted_name.map_or(0, str::len)) + } + + fn owned(self) -> Declaration { + Declaration { + alias: self.alias.to_owned(), + kind: self.kind.to_owned(), + name: self + .quoted_name + .and_then(|name| serde_json::from_str(name).ok()), + span: self.span, + authored_id: self.authored_id, + identity: self.identity, + } + } +} + +struct RowBudget { + rows: usize, + bytes: usize, +} + +impl RowBudget { + fn strings(&mut self, bytes: usize) -> bool { + if bytes > self.bytes { + return false; + } + self.bytes -= bytes; + true + } + + fn row(&mut self, bytes: usize) -> bool { + if self.rows == 0 || !self.strings(bytes) { + return false; + } + self.rows -= 1; + true + } +} + +#[cfg(test)] +#[path = "query_tests.rs"] +mod tests; diff --git a/crates/oa-desktop/src/source_navigation/query_tests.rs b/crates/oa-desktop/src/source_navigation/query_tests.rs new file mode 100644 index 00000000..0b6bd7e1 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation/query_tests.rs @@ -0,0 +1,1048 @@ +use super::*; + +const PROJECT: &str = "00000000-0000-0000-0000-000000000001"; +const PUMP: &str = "00000000-0000-0000-0000-000000000002"; +const LINK: &str = "00000000-0000-0000-0000-000000000003"; + +#[derive(Clone)] +struct Fixture { + entry: String, + files: Vec<(String, String)>, +} + +impl Fixture { + fn standard() -> Self { + Self { + entry: "project.arcadia".into(), + files: vec![ + ( + "project.arcadia".into(), + format!( + "arcadia 7;\r\nproject model \"Harbour\" id \"{PROJECT}\" {{\r\n include \"模块/活動.arcadia\";\r\n include \"links.arcadia\";\r\n}}\r\n" + ), + ), + ( + "模块/活動.arcadia".into(), + format!( + "// 原文 remains exact\r\noa_activity pump \"泵 🚀\" id \"{PUMP}\" {{}}\r\n" + ), + ), + ( + "links.arcadia".into(), + format!( + "// 航海\r\ninteraction connection \"流れ\" id \"{LINK}\" {{\r\n source @ // separator @pump\r\n pump;\r\n description \"船 @pump\"; target @pump;\r\n}}\r\n" + ), + ), + ], + } + } + + fn borrowed(&self) -> Vec> { + self.files + .iter() + .map(|(path, text)| DraftFile { path, text }) + .collect() + } + + fn text(&self, path: &str) -> &str { + &self + .files + .iter() + .find(|(candidate, _)| candidate == path) + .unwrap() + .1 + } + + fn text_mut(&mut self, path: &str) -> &mut String { + &mut self + .files + .iter_mut() + .find(|(candidate, _)| candidate == path) + .unwrap() + .1 + } + + fn snapshot(&self, validated: Option<&Self>, applied: bool, limits: Limits) -> Snapshot { + let current = self.borrowed(); + let old = validated.map(Self::borrowed); + capture( + Input { + key: key(1), + entry: &self.entry, + files: ¤t, + applied, + validation: old.as_ref().map(|files| ValidatedSources { + entry: &validated.unwrap().entry, + files, + }), + }, + limits, + ) + .unwrap() + } +} + +fn key(revision: u64) -> SnapshotKey { + SnapshotKey { + workspace: Id::from_u128(100), + revision, + } +} + +fn query(snapshot: &Snapshot, path: &str, byte_offset: usize, action: Action) -> Results { + snapshot + .query(Query { + stamp: QueryStamp { + snapshot: snapshot.key(), + serial: 37, + }, + path, + byte_offset, + action, + }) + .unwrap() +} + +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(); + let original = fixture.files.clone(); + let snapshot = fixture.snapshot(Some(&fixture), true, Limits::default()); + assert!(snapshot.scope().closure_complete && snapshot.scope().validated); + assert_eq!( + ( + snapshot.scope().included_files, + snapshot.scope().detached_files + ), + (3, 0) + ); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&fixture), + Action::Definition, + ); + assert_eq!(result.disposition, Disposition::Direct); + assert_eq!( + (result.total_definitions, result.omitted_definitions), + (1, 0) + ); + let definition = &result.definitions[0]; + assert_eq!(definition.path, "模块/活動.arcadia"); + assert_eq!(definition.declaration.kind, "oa_activity"); + assert_eq!(definition.declaration.name.as_deref(), Some("泵 🚀")); + assert_eq!( + definition.declaration.authored_id, + Some(Id::parse_str(PUMP).unwrap()) + ); + assert_eq!( + definition.position, + Position { + line: 1, + byte_column: "oa_activity ".len() + } + ); + assert_eq!( + &fixture.text(&definition.path)[definition.span.start..definition.span.end], + "pump" + ); + let uses = query( + &snapshot, + &definition.path, + definition.span.start, + Action::References, + ); + assert_eq!(uses.disposition, Disposition::LexicalReferences); + assert_eq!((uses.total_references, uses.omitted_references), (2, 0)); + assert_eq!( + uses.references[0].position, + Position { + line: 3, + byte_column: 3 + } + ); + let target_prefix = " description \"船 @pump\"; target @"; + assert_ne!(target_prefix.len(), target_prefix.chars().count()); + assert_eq!( + uses.references[1].position, + Position { + line: 4, + byte_column: target_prefix.len() + } + ); + for row in &uses.references { + let text = fixture.text(&row.path); + assert_eq!(&text[row.span.start..row.span.end], "pump"); + assert_eq!(&text[row.marker.start..row.marker.end], "@"); + let context = row.context.as_ref().unwrap(); + assert_eq!(context.alias, "connection"); + assert_eq!(context.kind, "interaction"); + assert_eq!(context.name.as_deref(), Some("流れ")); + assert_eq!(row.evidence, Evidence::Validated); + } + assert_eq!( + uses.stamp, + QueryStamp { + snapshot: key(1), + serial: 37 + } + ); + assert_eq!( + fixture.files, original, + "no stabilization, newline rewrite or accepted adoption" + ); + for (index, (path, text)) in original.iter().enumerate() { + let owned = snapshot.file(index).unwrap(); + assert_eq!((owned.path, owned.text), (path.as_str(), text.as_str())); + } +} + +#[test] +fn comments_strings_and_comment_gap_are_not_navigation_hits() { + let fixture = Fixture::standard(); + let snapshot = fixture.snapshot(None, true, Limits::default()); + let text = fixture.text("links.arcadia"); + for offset in [ + text.find("separator @pump").unwrap() + "separator ".len(), + text.find("船 @pump").unwrap() + "船 ".len(), + text.find("@ //").unwrap() + 1, + ] { + let result = query(&snapshot, "links.arcadia", offset, Action::Definition); + assert_eq!(result.disposition, Disposition::NoSymbol); + assert_eq!(result.total_definitions, 0); + } + assert_eq!( + query( + &snapshot, + "links.arcadia", + text.find(" pump;").unwrap() + 3, + Action::Definition + ) + .total_definitions, + 1 + ); + let inside_utf8 = text.find('船').unwrap() + 1; + assert_eq!( + snapshot.query(Query { + stamp: QueryStamp { + snapshot: key(1), + serial: 1 + }, + path: "links.arcadia", + byte_offset: inside_utf8, + action: Action::Definition + }), + Err(QueryError::Query(symbols::QueryError::InvalidBytePosition)) + ); +} + +#[test] +fn dirty_but_parseable_text_has_current_names_and_no_validated_fallback() { + let original = Fixture::standard(); + let mut dirty = original.clone(); + *dirty.text_mut("模块/活動.arcadia") = dirty + .text("模块/活動.arcadia") + .replace("泵 🚀", "Edited 泵\\nwith detail"); + let snapshot = dirty.snapshot(Some(&original), true, Limits::default()); + assert!(snapshot.scope().closure_complete); + assert!(!snapshot.scope().validated); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&dirty), + Action::Definition, + ); + assert_eq!(result.disposition, Disposition::Choices); + assert_eq!( + result.definitions[0].declaration.name.as_deref(), + Some("Edited 泵\nwith detail") + ); + assert_eq!(result.definitions[0].evidence, Evidence::Draft); + assert_eq!( + snapshot.file(1).unwrap().text, + dirty.text("模块/活動.arcadia") + ); + assert_ne!( + snapshot.file(1).unwrap().text, + original.text("模块/活動.arcadia") + ); +} + +#[test] +fn invalid_include_syntax_never_borrows_the_old_reachable_file_set() { + let original = Fixture::standard(); + let mut invalid = original.clone(); + *invalid.text_mut("project.arcadia") = invalid + .text("project.arcadia") + .replace("include \"links.arcadia\";", "include \"links.arcadia\""); + let snapshot = invalid.snapshot(Some(&original), true, Limits::default()); + assert!(snapshot.scope().partial()); + assert!(!snapshot.scope().validated); + assert!( + snapshot + .scope() + .issues + .iter() + .any(|issue| issue.kind == ScopeIssueKind::InvalidIncludes) + ); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&invalid), + Action::Definition, + ); + assert_eq!(result.total_definitions, 1); + assert_eq!(result.disposition, Disposition::Choices); + assert_eq!(result.definitions[0].membership, Membership::Unverified); +} + +#[test] +fn missing_include_partial_scope_does_not_resurrect_witness_text() { + let original = Fixture::standard(); + let mut missing = original.clone(); + missing + .files + .retain(|(path, _)| path != "模块/活動.arcadia"); + let snapshot = missing.snapshot(Some(&original), true, Limits::default()); + assert!(snapshot.scope().partial()); + assert!( + snapshot + .scope() + .issues + .iter() + .any(|issue| issue.kind == ScopeIssueKind::MissingInclude) + ); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&missing), + Action::Definition, + ); + assert_eq!(result.disposition, Disposition::NoDefinition); + assert!(result.definitions.is_empty()); + assert!( + result.scope.partial(), + "zero means no loaded definition, not no project definition" + ); + assert!(snapshot.file(2).is_none()); +} + +#[test] +fn detached_alias_and_identity_collisions_never_auto_select() { + let accepted = Fixture::standard(); + for (alias, id) in [ + ("pump", "00000000-0000-0000-0000-000000000099"), + ("other", PUMP), + ] { + let mut loaded = accepted.clone(); + loaded.files.push(( + "detached.arcadia".into(), + format!("oa_activity {alias} \"Detached\" id \"{id}\" {{}}"), + )); + let snapshot = loaded.snapshot(Some(&accepted), true, Limits::default()); + assert!(snapshot.scope().validated); + assert_eq!(snapshot.scope().detached_files, 1); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&loaded), + Action::Definition, + ); + assert_eq!(result.disposition, Disposition::Choices); + if alias == "pump" { + assert_eq!(result.total_definitions, 2); + assert!(result.definitions.iter().any( + |row| row.membership == Membership::Detached && row.evidence == Evidence::Draft + )); + } else { + assert_eq!(result.total_definitions, 1); + assert_eq!( + result.definitions[0].declaration.identity, + Identity::Collision { count: 2 } + ); + } + } +} + +#[test] +fn sole_detached_candidate_and_unapplied_equal_bytes_require_choice() { + let accepted = Fixture::standard(); + let mut detached = accepted.clone(); + *detached.text_mut("project.arcadia") = detached + .text("project.arcadia") + .replace(" include \"模块/活動.arcadia\";\r\n", ""); + let snapshot = detached.snapshot(None, true, Limits::default()); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&detached), + Action::Definition, + ); + assert_eq!(result.disposition, Disposition::Choices); + assert_eq!(result.definitions[0].membership, Membership::Detached); + let unapplied = accepted.snapshot(Some(&accepted), false, Limits::default()); + assert!(unapplied.scope().closure_complete && unapplied.scope().partial()); + assert!(!unapplied.scope().validated); + assert_eq!( + query( + &unapplied, + "links.arcadia", + reference_offset(&accepted), + Action::Definition + ) + .disposition, + Disposition::Choices + ); +} + +#[test] +fn include_cycle_depth_and_aggregate_edges_are_explicit_partial_outcomes() { + let mut fixture = Fixture::standard(); + fixture + .text_mut("模块/活動.arcadia") + .push_str("include \"../project.arcadia\";\r\n"); + let cycle = fixture.snapshot(None, true, Limits::default()); + assert!( + cycle + .scope() + .issues + .iter() + .any(|issue| issue.kind == ScopeIssueKind::IncludeCycle) + ); + let fixture = Fixture::standard(); + for (depth, edges, expected) in [ + (0, 1024, ScopeIssueKind::IncludeDepth), + (32, 1, ScopeIssueKind::IncludeBudget), + ] { + let snapshot = fixture.snapshot( + None, + true, + Limits { + include_depth: depth, + include_edges: edges, + ..Limits::default() + }, + ); + assert!(snapshot.scope().partial()); + assert!( + snapshot + .scope() + .issues + .iter() + .any(|issue| issue.kind == expected) + ); + assert_eq!( + query( + &snapshot, + "links.arcadia", + reference_offset(&fixture), + Action::Definition + ) + .disposition, + Disposition::Choices + ); + } +} + +#[test] +fn exact_snapshot_readback_rejects_same_key_byte_membership_and_evidence_changes() { + let fixture = Fixture::standard(); + let files = fixture.borrowed(); + let validation = Some(ValidatedSources { + entry: &fixture.entry, + files: &files, + }); + let input = Input { + key: key(1), + entry: &fixture.entry, + files: &files, + applied: true, + validation, + }; + let snapshot = capture(input, Limits::default()).unwrap(); + assert!(snapshot.matches(input)); + assert!(!snapshot.matches(Input { + key: key(2), + ..input + })); + assert!(!snapshot.matches(Input { + validation: None, + ..input + })); + assert!(!snapshot.matches(Input { + applied: false, + ..input + })); + let mut reversed = files.clone(); + reversed.reverse(); + assert!(snapshot.matches(Input { + files: &reversed, + ..input + })); + let mut changed = fixture.clone(); + changed.text_mut("links.arcadia").push(' '); + let altered = changed.borrowed(); + assert!(!snapshot.matches(Input { + files: &altered, + ..input + })); + let missing = &files[..files.len() - 1]; + assert!(!snapshot.matches(Input { + files: missing, + ..input + })); + let duplicated = [files[0], files[0], files[2]]; + assert!(!snapshot.matches(Input { + files: &duplicated, + ..input + })); + assert_eq!( + snapshot.query(Query { + stamp: QueryStamp { + snapshot: key(2), + serial: 37 + }, + path: "links.arcadia", + byte_offset: 0, + action: Action::Definition + }), + Err(QueryError::Query(symbols::QueryError::StaleSnapshot)) + ); +} + +#[test] +fn omitted_rows_are_counted_before_result_allocation_and_never_confer_uniqueness() { + let fixture = Fixture::standard(); + let snapshot = fixture.snapshot( + Some(&fixture), + true, + Limits { + result_rows: 2, + ..Limits::default() + }, + ); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&fixture), + Action::References, + ); + assert_eq!((result.total_definitions, result.total_references), (1, 2)); + assert_eq!( + ( + result.definitions.len(), + result.references.len(), + result.omitted_references + ), + (1, 1, 1) + ); + let zero = fixture.snapshot( + Some(&fixture), + true, + Limits { + result_rows: 0, + ..Limits::default() + }, + ); + let result = query( + &zero, + "links.arcadia", + reference_offset(&fixture), + Action::Definition, + ); + assert_eq!( + ( + result.definitions.len(), + result.total_definitions, + result.omitted_definitions + ), + (0, 1, 1) + ); + assert_eq!(result.disposition, Disposition::Choices); + let envelope = fixture.snapshot( + Some(&fixture), + true, + Limits { + result_bytes: 0, + ..Limits::default() + }, + ); + assert_eq!( + envelope.query(Query { + stamp: QueryStamp { + snapshot: key(1), + serial: 1 + }, + path: "links.arcadia", + byte_offset: reference_offset(&fixture), + action: Action::Definition + }), + Err(QueryError::ResultEnvelopeLimit) + ); +} + +#[test] +fn large_display_name_cannot_escape_the_owned_row_string_budget() { + let mut fixture = Fixture::standard(); + *fixture.text_mut("模块/活動.arcadia") = fixture + .text("模块/活動.arcadia") + .replace("泵 🚀", &"長".repeat(8192)); + let original = fixture.files.clone(); + let snapshot = fixture.snapshot( + None, + true, + Limits { + result_bytes: 64, + ..Limits::default() + }, + ); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&fixture), + Action::Definition, + ); + assert_eq!( + (result.total_definitions, result.omitted_definitions), + (1, 1) + ); + assert!(result.definitions.is_empty()); + assert_eq!(result.disposition, Disposition::Choices); + assert_eq!(fixture.files, original); +} + +#[test] +fn validation_witness_must_match_entry_and_complete_included_file_set() { + let fixture = Fixture::standard(); + for mutation in 0..3 { + let mut witness = fixture.clone(); + match mutation { + 0 => witness.entry = "other.arcadia".into(), + 1 => { + witness.files.pop(); + } + _ => witness.files.push(("unused.arcadia".into(), String::new())), + } + let snapshot = fixture.snapshot(Some(&witness), true, Limits::default()); + assert!(snapshot.scope().closure_complete); + assert!(!snapshot.scope().validated); + assert_eq!( + query( + &snapshot, + "links.arcadia", + reference_offset(&fixture), + Action::Definition + ) + .disposition, + Disposition::Choices + ); + } +} + +#[test] +fn raw_aggregate_limits_refuse_before_outline_or_copied_snapshot() { + // Token budget zero and malformed paths/text would fail later. Raw byte/path + // limits must win, including a large second file; no partial Snapshot exists. + let large = "not a model \0".repeat(16_384); + let files = [ + DraftFile { + path: "../bad.arcadia", + text: "?", + }, + DraftFile { + path: "large.arcadia", + text: &large, + }, + ]; + let input = Input { + key: key(1), + entry: "project.arcadia", + files: &files, + applied: true, + validation: None, + }; + for (resource, files_limit, bytes_limit, paths_limit) in [ + (symbols::Resource::Files, 1, usize::MAX, usize::MAX), + (symbols::Resource::SourceBytes, 8, 1, usize::MAX), + (symbols::Resource::PathBytes, 8, usize::MAX, 1), + ] { + let limits = Limits { + index: symbols::Limits { + files: files_limit, + source_bytes: bytes_limit, + path_bytes: paths_limit, + tokens: 0, + records: 0, + }, + ..Limits::default() + }; + assert!( + matches!(capture(input, limits), Err(AdmissionError::Index(symbols::BuildError::Limit { resource: actual, .. })) if actual == resource) + ); + } + assert_eq!(files[1].text, large); +} + +#[test] +fn token_declaration_and_live_reference_limits_are_aggregate_not_per_file() { + let files = [ + DraftFile { + path: "a.arcadia", + text: "oa_activity a \"A\" {}", + }, + DraftFile { + path: "b.arcadia", + text: "oa_activity b \"B\" {}", + }, + ]; + let input = Input { + key: key(1), + entry: "a.arcadia", + files: &files, + applied: false, + validation: None, + }; + let limits = Limits { + index: symbols::Limits { + tokens: 10, + ..Limits::default().index + }, + ..Limits::default() + }; + assert!(matches!( + capture(input, limits), + Err(AdmissionError::Outline { + file: 1, + error: language::OutlineLimit { + resource: language::OutlineResource::Tokens, + .. + } + }) + )); + let limits = Limits { + index: symbols::Limits { + records: 1, + ..Limits::default().index + }, + ..Limits::default() + }; + assert!(matches!( + capture(input, limits), + Err(AdmissionError::Outline { + file: 1, + error: language::OutlineLimit { + resource: language::OutlineResource::Declarations, + .. + } + }) + )); + let use_files = [ + files[0], + DraftFile { + path: "uses.arcadia", + text: "source @a; target @a; // @a ignored\n", + }, + ]; + let limits = Limits { + index: symbols::Limits { + records: 2, + ..Limits::default().index + }, + ..Limits::default() + }; + assert!(matches!( + capture( + Input { + files: &use_files, + ..input + }, + limits + ), + Err(AdmissionError::Index(symbols::BuildError::Limit { + resource: symbols::Resource::Records, + observed_at_least: 3, + .. + })) + )); +} + +#[test] +fn missing_and_noncanonical_authored_ids_remain_draft_spans_without_generated_anchors() { + let fixture = Fixture::standard(); + for replacement in [ + String::new(), + " id \"00000000000000000000000000000002\"".into(), + ] { + let mut draft = fixture.clone(); + let original_id = format!(" id \"{PUMP}\""); + *draft.text_mut("模块/活動.arcadia") = draft + .text("模块/活動.arcadia") + .replace(&original_id, &replacement); + draft + .text_mut("模块/活動.arcadia") + .push_str(&format!("// Historical UUID {PUMP} and @pump\r\n")); + let before = draft.files.clone(); + let snapshot = draft.snapshot(None, false, Limits::default()); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&draft), + Action::Definition, + ); + assert_eq!(result.definitions[0].declaration.authored_id, None); + assert_eq!( + result.definitions[0].declaration.identity, + Identity::Missing + ); + assert_eq!(result.disposition, Disposition::Choices); + assert_eq!(draft.files, before); + assert_eq!( + snapshot.file(1).unwrap().text, + draft.text("模块/活動.arcadia") + ); + } +} + +#[test] +fn keyword_alias_and_loaded_path_case_collisions_use_reviewed_index_rules() { + let mut fixture = Fixture::standard(); + for (_, text) in &mut fixture.files { + *text = text.replace("pump", "source"); + } + let snapshot = fixture.snapshot(None, true, Limits::default()); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(&fixture), + Action::Definition, + ); + assert_eq!(result.alias.as_deref(), Some("source")); + assert_eq!(result.total_definitions, 1); + fixture.files.push(("LINKS.arcadia".into(), String::new())); + let files = fixture.borrowed(); + assert!(matches!( + capture( + Input { + key: key(1), + entry: &fixture.entry, + files: &files, + applied: true, + validation: None + }, + Limits::default() + ), + Err(AdmissionError::Index(symbols::BuildError::Input { + problem: symbols::InputProblem::DuplicateOrCaseCollidingPath, + .. + })) + )); +} + +#[test] +fn owned_request_moves_exact_text_into_worker_snapshot_and_matches_sync_results() { + let fixture = Fixture::standard(); + let files = fixture.borrowed(); + let input = Input { + key: key(1), + entry: &fixture.entry, + files: &files, + applied: true, + validation: Some(ValidatedSources { + entry: &fixture.entry, + files: &files, + }), + }; + let owned = admit_owned(input, Limits::default()).unwrap(); + let allocations: Vec<_> = owned.files.iter().map(|file| file.text.as_ptr()).collect(); + let snapshot = owned.capture().unwrap(); + for (file, allocation) in allocations.iter().enumerate() { + assert_eq!( + snapshot.file(file).unwrap().text.as_ptr(), + *allocation, + "worker moves the one current-text copy" + ); + } + assert!(snapshot.matches(input)); + let synchronous = capture(input, Limits::default()).unwrap(); + assert_eq!( + query( + &snapshot, + "links.arcadia", + reference_offset(&fixture), + Action::References + ), + query( + &synchronous, + "links.arcadia", + reference_offset(&fixture), + Action::References + ) + ); + let mut edited = fixture.clone(); + edited + .text_mut("links.arcadia") + .push_str("// edited after request\r\n"); + let changed = edited.borrowed(); + assert!(!snapshot.matches(Input { + files: &changed, + ..input + })); + assert!(!snapshot.matches(Input { + validation: None, + ..input + })); +} + +#[test] +fn owned_request_checks_raw_limits_immediately_and_extracts_only_on_worker() { + let fixture = Fixture::standard(); + let files = fixture.borrowed(); + let input = Input { + key: key(1), + entry: &fixture.entry, + files: &files, + applied: true, + validation: None, + }; + let no_tokens = Limits { + index: symbols::Limits { + tokens: 0, + ..Limits::default().index + }, + ..Limits::default() + }; + let owned = admit_owned(input, no_tokens).expect("request admission does not lex or parse"); + assert!(matches!( + owned.capture(), + Err(AdmissionError::Outline { + file: 0, + error: language::OutlineLimit { + resource: language::OutlineResource::Tokens, + .. + } + }) + )); + let no_bytes = Limits { + index: symbols::Limits { + source_bytes: 1, + ..Limits::default().index + }, + ..Limits::default() + }; + assert!(matches!( + admit_owned(input, no_bytes), + Err(AdmissionError::Index(symbols::BuildError::Limit { + resource: symbols::Resource::SourceBytes, + .. + })) + )); + assert_eq!(files[1].text, fixture.text("模块/活動.arcadia")); +} + +#[test] +fn owned_validation_proof_preserves_full_witness_count_and_partial_scope() { + let accepted = Fixture::standard(); + for missing_include in [false, true] { + let mut current = accepted.clone(); + current + .files + .retain(|(path, _)| path != "模块/活動.arcadia"); + if !missing_include { + *current.text_mut("project.arcadia") = current + .text("project.arcadia") + .replace(" include \"模块/活動.arcadia\";\r\n", ""); + } + let files = current.borrowed(); + let old = accepted.borrowed(); + let input = Input { + key: key(1), + entry: ¤t.entry, + files: &files, + applied: true, + validation: Some(ValidatedSources { + entry: &accepted.entry, + files: &old, + }), + }; + let snapshot = admit_owned(input, Limits::default()) + .unwrap() + .capture() + .unwrap(); + assert_eq!(snapshot.scope().closure_complete, !missing_include); + assert!( + !snapshot.scope().validated, + "the old witness is not silently reduced to matching loaded files" + ); + let result = query( + &snapshot, + "links.arcadia", + reference_offset(¤t), + Action::Definition, + ); + assert_eq!(result.disposition, Disposition::NoDefinition); + assert!(snapshot.matches(input)); + } +} diff --git a/crates/oa-desktop/src/source_navigation/symbols.rs b/crates/oa-desktop/src/source_navigation/symbols.rs new file mode 100644 index 00000000..d1049469 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation/symbols.rs @@ -0,0 +1,2036 @@ +//! Current-text symbol queries, with no model or editor mutation. +//! +//! The caller supplies one immutable snapshot of current text, full lexer tokens, +//! and syntax-only declaration role spans. This module does not lex, parse +//! Arcadia declarations, lint, compile, allocate model IDs, discover includes, +//! or read an accepted Project. +//! Its matches are authored lexical uses, not typed semantic dependency edges. + +use oa_core::Id; +use oa_core::language::{Span, TokenClass, TokenSpan}; +use std::collections::{BTreeMap, BTreeSet}; +use std::ops::Range; + +/// Fresh editor/workspace session identity plus a revision changed for EVERY +/// indexed input change, including membership, outline and validation evidence. +/// Reusing this key for different input violates the caller contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotKey { + pub workspace: Id, + pub revision: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Membership { + Included, + Detached, + Unverified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Evidence { + /// The caller verified these exact bytes in the current source scope. + Validated, + /// Current editor bytes; no successful semantic validation is claimed. + Draft, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntaxCoverage { + /// The tolerant walk considered the entire file, even if its text is invalid. + FullFile, + /// A stopped/budget-limited outline cannot masquerade as a complete result. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Defensive input contract exercised by index tests." + ) + )] + Incomplete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PartialReason { + IncludesUnverified, + MissingFiles, + UnappliedWorkspace, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + CompleteProject, + Partial(PartialReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Domain { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested included-only queries; editor presents all loaded candidates." + ) + )] + Included, + Loaded, +} + +impl Domain { + fn contains(self, membership: Membership) -> bool { + self == Self::Loaded || membership == Membership::Included + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthoredIdentity { + pub id: Id, + /// Whole quoted JSON string token in the declaration header, not a UUID + /// mentioned in a body, history record, comment or display name. + pub literal_span: Span, +} + +/// These header roles must come from the coordinated core outline seam. The +/// existing public DeclarationSpan alone lacks alias/kind/ID literal spans. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeclarationInput { + pub span: Span, + pub kind_span: Span, + pub alias_span: Span, + pub identity: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct FileInput<'a> { + pub path: &'a str, + pub text: &'a str, + /// Exact lexer output for text, in order and covering the entire file. + pub tokens: &'a [TokenSpan], + /// Tolerant outline in alias-source order. Whole spans may nest. + pub declarations: &'a [DeclarationInput], + pub syntax: SyntaxCoverage, + pub membership: Membership, + pub evidence: Evidence, +} + +/// Explicit experiment limits; production must select its own shared budgets. +/// Caller-owned text/tokens/outline must already have bounded extraction. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub files: usize, + pub source_bytes: usize, + pub path_bytes: usize, + pub tokens: usize, + pub records: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Resource { + Files, + SourceBytes, + PathBytes, + Tokens, + Records, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputProblem { + NonCanonicalPath, + DuplicateOrCaseCollidingPath, + InconsistentCompleteScope, + IncompleteSyntax, + TokenCoverage, + TokenBounds, + DeclarationBounds, + DeclarationOrder, + HeaderRoleToken, + InvalidAlias, + IdentityLiteral, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BuildError { + /// No partial Index is returned. This is not a zero-result query. + Limit { + resource: Resource, + limit: usize, + observed_at_least: usize, + }, + Input { + file: usize, + record: Option, + problem: InputProblem, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Location { + pub file: usize, + pub span: Span, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DefinitionHandle { + snapshot: SnapshotKey, + index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReferenceHandle { + snapshot: SnapshotKey, + index: usize, +} + +/// Carries only an authored identity, never an identity generated by compilation. +/// It may be resolved across revisions of the same workspace. Resolution still +/// checks global loaded-file uniqueness every time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthoredAnchor { + workspace: Id, + id: Id, +} + +impl AuthoredAnchor { + pub fn id(self) -> Id { + self.id + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Identity { + Missing, + Unique(AuthoredAnchor), + Collision { count: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested unique-reference and authored-anchor projections." + ) +)] +pub enum Lookup { + Missing, + Unique(DefinitionHandle), + Ambiguous { count: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hit { + Definition(DefinitionHandle), + Reference(ReferenceHandle), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryError { + StaleSnapshot, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Authored-anchor projection rejects cross-workspace identity." + ) + )] + WrongWorkspace, + InvalidHandle, + InvalidFile, + InvalidBytePosition, + #[cfg_attr( + not(test), + expect(dead_code, reason = "Tested included-only reference projection.") + )] + OutsideDomain, + #[cfg_attr( + not(test), + expect(dead_code, reason = "Tested unique-declaration reference projection.") + )] + AmbiguousAlias { + count: usize, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested primitive scope; editor exposes adapter include inspection." + ) +)] +pub struct QueryScope { + pub declared: Scope, + pub domain: Domain, + /// True only for the complete included project, not loaded detached files. + pub project_complete: bool, + pub all_selected_files_validated: bool, + pub selected_files: usize, + pub detached_files: usize, + pub unverified_files: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Position { + pub line: usize, + /// Zero-based UTF-8 byte column. The editor adapter must convert explicitly + /// if it needs character/UTF-16 columns; CRLF bytes are never normalized. + pub byte_column: usize, +} + +#[derive(Debug)] +pub struct Definition<'a> { + pub alias: &'a str, + pub kind: &'a str, + pub declaration: Location, + pub alias_location: Location, + pub authored_id: Option, +} + +#[derive(Debug)] +pub struct Reference<'a> { + pub alias: &'a str, + pub at: Location, + pub alias_location: Location, +} + +#[derive(Debug)] +pub struct SourceFile<'a> { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Retained source projection for independently tested index callers." + ) + )] + pub path: &'a str, + pub text: &'a str, + pub membership: Membership, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Included-only scope projection retains validation evidence." + ) + )] + pub evidence: Evidence, + definitions: Range, + references: Range, +} + +#[derive(Debug)] +pub struct Index<'a> { + snapshot: SnapshotKey, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested domain scope projection; adapter owns the UI scope." + ) + )] + scope: Scope, + files: Vec>, + definitions: Vec>, + references: Vec>, + aliases: BTreeMap<&'a str, Vec>, + uses: BTreeMap<&'a str, Vec>, + identities: BTreeMap>, +} + +#[derive(Debug, Clone, Copy)] +struct Counts { + definitions: usize, + references: usize, +} + +fn charge( + total: &mut usize, + value: usize, + limit: usize, + resource: Resource, +) -> Result<(), BuildError> { + let next = total.checked_add(value).ok_or(BuildError::Limit { + resource, + limit, + observed_at_least: usize::MAX, + })?; + if next > limit { + return Err(BuildError::Limit { + resource, + limit, + observed_at_least: next, + }); + } + *total = next; + Ok(()) +} + +fn input_error(file: usize, record: Option, problem: InputProblem) -> BuildError { + BuildError::Input { + file, + record, + problem, + } +} + +fn valid_span(text: &str, span: Span) -> bool { + span.start < span.end + && span.end <= text.len() + && text.is_char_boundary(span.start) + && text.is_char_boundary(span.end) +} + +fn contains(outer: Span, inner: Span) -> bool { + outer.start <= inner.start && inner.end <= outer.end +} + +fn token_at(tokens: &[TokenSpan], span: Span) -> Option { + let index = tokens + .binary_search_by_key(&span.start, |token| token.span.start) + .ok()?; + let token = tokens[index]; + (token.span == span).then_some(token) +} + +fn word(class: TokenClass) -> bool { + matches!(class, TokenClass::Identifier | TokenClass::Keyword) +} + +/// Only a live punctuation @ followed by a legal alias outside trivia qualifies. +/// Trivia can separate @ and alias, but its bytes never become a clickable hit. +fn reference_spans<'a>(file: FileInput<'a>) -> impl Iterator + 'a { + file.tokens + .iter() + .enumerate() + .filter_map(move |(index, token)| { + if token.class != TokenClass::Punctuation + || &file.text[token.span.start..token.span.end] != "@" + { + return None; + } + let next = file.tokens[index + 1..] + .iter() + .find(|next| !matches!(next.class, TokenClass::Whitespace | TokenClass::Comment))?; + let alias = &file.text[next.span.start..next.span.end]; + (word(next.class) && oa_core::valid_alias(alias)).then_some((token.span, next.span)) + }) +} + +/// Runs all aggregate limits and structural checks before allocating Index maps +/// or vectors. Path normalization/quoted-ID decoding may allocate bounded scratch +/// strings. Scratch storage is proportional to admitted input bytes and file +/// count, including Unicode expansion and collection overhead. +fn preflight(files: &[FileInput<'_>], scope: Scope, limits: Limits) -> Result { + let mut file_count = 0; + charge(&mut file_count, files.len(), limits.files, Resource::Files)?; + let mut bytes = 0; + let mut path_bytes = 0; + let mut tokens = 0; + let mut records = 0; + for file in files { + charge( + &mut bytes, + file.text.len(), + limits.source_bytes, + Resource::SourceBytes, + )?; + charge( + &mut path_bytes, + file.path.len(), + limits.path_bytes, + Resource::PathBytes, + )?; + charge( + &mut tokens, + file.tokens.len(), + limits.tokens, + Resource::Tokens, + )?; + charge( + &mut records, + file.declarations.len(), + limits.records, + Resource::Records, + )?; + } + let definitions = records; + if scope == Scope::CompleteProject + && (!files + .iter() + .any(|file| file.membership == Membership::Included) + || files + .iter() + .any(|file| file.membership == Membership::Unverified)) + { + return Err(input_error( + 0, + None, + InputProblem::InconsistentCompleteScope, + )); + } + // Scratch storage is allocated only after aggregate file/path admission. + // Each admitted path is folded once, retaining core's Unicode policy. + let mut folded_paths = BTreeSet::new(); + for (file_index, file) in files.iter().copied().enumerate() { + if file.syntax != SyntaxCoverage::FullFile { + return Err(input_error( + file_index, + None, + InputProblem::IncompleteSyntax, + )); + } + if oa_core::source_project::resolve_include("", file.path).as_deref() != Ok(file.path) { + return Err(input_error( + file_index, + None, + InputProblem::NonCanonicalPath, + )); + } + if !folded_paths.insert(file.path.to_lowercase()) { + return Err(input_error( + file_index, + None, + InputProblem::DuplicateOrCaseCollidingPath, + )); + } + let mut end = 0; + for (token_index, token) in file.tokens.iter().enumerate() { + if !valid_span(file.text, token.span) { + return Err(input_error( + file_index, + Some(token_index), + InputProblem::TokenBounds, + )); + } + if token.span.start != end { + return Err(input_error( + file_index, + Some(token_index), + InputProblem::TokenCoverage, + )); + } + end = token.span.end; + } + if end != file.text.len() { + return Err(input_error(file_index, None, InputProblem::TokenCoverage)); + } + let mut previous_alias_end = 0; + for (declaration_index, declaration) in file.declarations.iter().enumerate() { + let problem = |problem| input_error(file_index, Some(declaration_index), problem); + if !valid_span(file.text, declaration.span) + || !valid_span(file.text, declaration.kind_span) + || !valid_span(file.text, declaration.alias_span) + || declaration.kind_span.start != declaration.span.start + || !contains(declaration.span, declaration.kind_span) + || !contains(declaration.span, declaration.alias_span) + || declaration.alias_span.start < declaration.kind_span.end + { + return Err(problem(InputProblem::DeclarationBounds)); + } + if declaration.alias_span.start < previous_alias_end { + return Err(problem(InputProblem::DeclarationOrder)); + } + previous_alias_end = declaration.alias_span.end; + if !token_at(file.tokens, declaration.kind_span).is_some_and(|token| word(token.class)) + || !token_at(file.tokens, declaration.alias_span) + .is_some_and(|token| word(token.class)) + { + return Err(problem(InputProblem::HeaderRoleToken)); + } + let alias = &file.text[declaration.alias_span.start..declaration.alias_span.end]; + if !oa_core::valid_alias(alias) { + return Err(problem(InputProblem::InvalidAlias)); + } + if let Some(identity) = declaration.identity { + if !valid_span(file.text, identity.literal_span) + || !contains(declaration.span, identity.literal_span) + || identity.literal_span.start < declaration.alias_span.end + || token_at(file.tokens, identity.literal_span).map(|token| token.class) + != Some(TokenClass::String) + { + return Err(problem(InputProblem::IdentityLiteral)); + } + let literal = &file.text[identity.literal_span.start..identity.literal_span.end]; + let decoded = serde_json::from_str::(literal) + .ok() + .filter(|value| oa_core::language::has_uuid_header_shape(value)) + .and_then(|value| Id::parse_str(&value).ok()); + if decoded != Some(identity.id) { + return Err(problem(InputProblem::IdentityLiteral)); + } + } + } + // Token bounds and coverage are proven before reference_spans slices. + for _ in reference_spans(file) { + charge(&mut records, 1, limits.records, Resource::Records)?; + } + } + Ok(Counts { + definitions, + references: records - definitions, + }) +} + +impl<'a> Index<'a> { + pub fn build( + snapshot: SnapshotKey, + files: &[FileInput<'a>], + scope: Scope, + limits: Limits, + ) -> Result { + let counts = preflight(files, scope, limits)?; + let mut index = Self { + snapshot, + scope, + files: Vec::with_capacity(files.len()), + definitions: Vec::with_capacity(counts.definitions), + references: Vec::with_capacity(counts.references), + aliases: BTreeMap::new(), + uses: BTreeMap::new(), + identities: BTreeMap::new(), + }; + for (file_index, file) in files.iter().copied().enumerate() { + let definition_start = index.definitions.len(); + let reference_start = index.references.len(); + for declaration in file.declarations { + let alias = &file.text[declaration.alias_span.start..declaration.alias_span.end]; + let number = index.definitions.len(); + index.aliases.entry(alias).or_default().push(number); + if let Some(identity) = declaration.identity { + index + .identities + .entry(identity.id) + .or_default() + .push(number); + } + index.definitions.push(Definition { + alias, + kind: &file.text[declaration.kind_span.start..declaration.kind_span.end], + declaration: Location { + file: file_index, + span: declaration.span, + }, + alias_location: Location { + file: file_index, + span: declaration.alias_span, + }, + authored_id: declaration.identity.map(|identity| identity.id), + }); + } + for (at, alias_span) in reference_spans(file) { + let alias = &file.text[alias_span.start..alias_span.end]; + index + .uses + .entry(alias) + .or_default() + .push(index.references.len()); + index.references.push(Reference { + alias, + at: Location { + file: file_index, + span: at, + }, + alias_location: Location { + file: file_index, + span: alias_span, + }, + }); + } + index.files.push(SourceFile { + path: file.path, + text: file.text, + membership: file.membership, + evidence: file.evidence, + definitions: definition_start..index.definitions.len(), + references: reference_start..index.references.len(), + }); + } + Ok(index) + } + + pub fn source(&self, file: usize) -> Result<&SourceFile<'a>, QueryError> { + self.files.get(file).ok_or(QueryError::InvalidFile) + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested included/loaded scope projection; editor uses adapter scope." + ) + )] + pub fn scope(&self, domain: Domain) -> QueryScope { + let selected_files = self + .files + .iter() + .filter(|file| domain.contains(file.membership)) + .count(); + QueryScope { + declared: self.scope, + domain, + project_complete: self.scope == Scope::CompleteProject && domain == Domain::Included, + all_selected_files_validated: selected_files != 0 + && self + .files + .iter() + .filter(|file| domain.contains(file.membership)) + .all(|file| file.evidence == Evidence::Validated), + selected_files, + detached_files: self + .files + .iter() + .filter(|file| file.membership == Membership::Detached) + .count(), + unverified_files: self + .files + .iter() + .filter(|file| file.membership == Membership::Unverified) + .count(), + } + } + + fn definition_handle(&self, index: usize) -> DefinitionHandle { + DefinitionHandle { + snapshot: self.snapshot, + index, + } + } + + fn reference_handle(&self, index: usize) -> ReferenceHandle { + ReferenceHandle { + snapshot: self.snapshot, + index, + } + } + + pub fn definition(&self, handle: DefinitionHandle) -> Result<&Definition<'a>, QueryError> { + if handle.snapshot != self.snapshot { + return Err(QueryError::StaleSnapshot); + } + self.definitions + .get(handle.index) + .ok_or(QueryError::InvalidHandle) + } + + pub fn reference(&self, handle: ReferenceHandle) -> Result<&Reference<'a>, QueryError> { + if handle.snapshot != self.snapshot { + return Err(QueryError::StaleSnapshot); + } + self.references + .get(handle.index) + .ok_or(QueryError::InvalidHandle) + } + + pub fn definitions_for_alias<'i>(&'i self, alias: &str, domain: Domain) -> Definitions<'i, 'a> { + Definitions { + index: self, + indices: self + .aliases + .get(alias) + .map_or(&[][..], Vec::as_slice) + .iter(), + domain, + } + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested authored-identity lookup; editor uses aliases and collision checks." + ) + )] + pub fn definitions_for_id(&self, id: Id, domain: Domain) -> Definitions<'_, 'a> { + Definitions { + index: self, + indices: self + .identities + .get(&id) + .map_or(&[][..], Vec::as_slice) + .iter(), + domain, + } + } + + pub fn references_for_alias<'i>(&'i self, alias: &str, domain: Domain) -> References<'i, 'a> { + References { + index: self, + indices: self.uses.get(alias).map_or(&[][..], Vec::as_slice).iter(), + domain, + } + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested unique-reference projection; UI shows bounded candidate rows." + ) + )] + pub fn resolve_reference( + &self, + handle: ReferenceHandle, + domain: Domain, + ) -> Result { + let reference = self.reference(handle)?; + if !domain.contains(self.files[reference.at.file].membership) { + return Err(QueryError::OutsideDomain); + } + let mut definitions = self.definitions_for_alias(reference.alias, domain); + let Some(first) = definitions.next() else { + return Ok(Lookup::Missing); + }; + let count = 1 + definitions.count(); + Ok(if count == 1 { + Lookup::Unique(first) + } else { + Lookup::Ambiguous { count } + }) + } + + pub fn identity(&self, handle: DefinitionHandle) -> Result { + let Some(id) = self.definition(handle)?.authored_id else { + return Ok(Identity::Missing); + }; + let count = self.identities[&id].len(); + Ok(if count == 1 { + Identity::Unique(AuthoredAnchor { + workspace: self.snapshot.workspace, + id, + }) + } else { + Identity::Collision { count } + }) + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested identity-safe anchor API; editor Back uses buffer generations." + ) + )] + pub fn resolve_anchor( + &self, + anchor: AuthoredAnchor, + domain: Domain, + ) -> Result { + if anchor.workspace != self.snapshot.workspace { + return Err(QueryError::WrongWorkspace); + } + let Some(candidates) = self.identities.get(&anchor.id) else { + return Ok(Lookup::Missing); + }; + // A detached duplicate must not silently confer identity uniqueness. + if candidates.len() != 1 { + return Ok(Lookup::Ambiguous { + count: candidates.len(), + }); + } + let candidate = candidates[0]; + Ok( + if domain.contains(self.files[self.definitions[candidate].declaration.file].membership) + { + Lookup::Unique(self.definition_handle(candidate)) + } else { + Lookup::Missing + }, + ) + } + + /// Exact authored alias-use projection for a unique current declaration in + /// the requested domain. Duplicate aliases require candidate inspection; + /// this API never attributes all matching uses to the first declaration. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested unique-target projection; UI labels lexical uses explicitly." + ) + )] + pub fn references_to( + &self, + handle: DefinitionHandle, + domain: Domain, + ) -> Result, QueryError> { + let definition = self.definition(handle)?; + if !domain.contains(self.files[definition.declaration.file].membership) { + return Err(QueryError::OutsideDomain); + } + let count = self.definitions_for_alias(definition.alias, domain).count(); + if count != 1 { + return Err(QueryError::AmbiguousAlias { count }); + } + Ok(self.references_for_alias(definition.alias, domain)) + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested exact single-location projection; adapter batches projections." + ) + )] + pub fn position(&self, file: usize, offset: usize) -> Result { + let source = self.source(file)?; + if offset > source.text.len() || !source.text.is_char_boundary(offset) { + return Err(QueryError::InvalidBytePosition); + } + let prefix = &source.text[..offset]; + Ok(Position { + line: prefix.bytes().filter(|byte| *byte == b'\n').count(), + byte_column: prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()), + }) + } + + pub fn hit(&self, file: usize, offset: usize) -> Result, QueryError> { + let source = self.source(file)?; + if offset > source.text.len() || !source.text.is_char_boundary(offset) { + return Err(QueryError::InvalidBytePosition); + } + let definitions = &self.definitions[source.definitions.clone()]; + let before = definitions + .partition_point(|definition| definition.alias_location.span.start <= offset); + if before != 0 && offset < definitions[before - 1].alias_location.span.end { + return Ok(Some(Hit::Definition( + self.definition_handle(source.definitions.start + before - 1), + ))); + } + let references = &self.references[source.references.clone()]; + let before = references.partition_point(|reference| reference.at.span.start <= offset); + if before != 0 { + let reference = &references[before - 1]; + if offset < reference.at.span.end + || (reference.alias_location.span.start <= offset + && offset < reference.alias_location.span.end) + { + return Ok(Some(Hit::Reference( + self.reference_handle(source.references.start + before - 1), + ))); + } + } + Ok(None) + } +} + +pub struct Definitions<'i, 'a> { + index: &'i Index<'a>, + indices: std::slice::Iter<'i, usize>, + domain: Domain, +} + +impl Iterator for Definitions<'_, '_> { + type Item = DefinitionHandle; + + fn next(&mut self) -> Option { + for &number in self.indices.by_ref() { + let definition = &self.index.definitions[number]; + if self + .domain + .contains(self.index.files[definition.declaration.file].membership) + { + return Some(self.index.definition_handle(number)); + } + } + None + } +} + +pub struct References<'i, 'a> { + index: &'i Index<'a>, + indices: std::slice::Iter<'i, usize>, + domain: Domain, +} + +impl References<'_, '_> { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Tested primitive scope; editor displays adapter results scope." + ) + )] + pub fn scope(&self) -> QueryScope { + self.index.scope(self.domain) + } +} + +impl Iterator for References<'_, '_> { + type Item = ReferenceHandle; + + fn next(&mut self) -> Option { + for &number in self.indices.by_ref() { + let reference = &self.index.references[number]; + if self + .domain + .contains(self.index.files[reference.at.file].membership) + { + return Some(self.index.reference_handle(number)); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: Id = Id::from_u128(1); + const B: Id = Id::from_u128(2); + const C: Id = Id::from_u128(3); + + struct Fixture { + path: String, + text: String, + tokens: Vec, + declarations: Vec, + } + + impl Fixture { + /// Test-only literal role proof. Production never searches declaration + /// headers: its caller supplies the coordinated syntax outline roles. + fn new(path: &str, text: String, roles: &[(&str, &str, Option)]) -> Self { + let tokens = oa_core::language::tokenize(&text); + let declarations = roles + .iter() + .map(|&(kind, alias, id)| { + let header = format!("{kind} {alias} "); + let start = text.find(&header).expect("authored fixture header"); + let end = text[start..] + .find('}') + .map_or(text.len(), |end| start + end + 1); + let alias_start = start + kind.len() + 1; + DeclarationInput { + span: Span { start, end }, + kind_span: Span { + start, + end: start + kind.len(), + }, + alias_span: Span { + start: alias_start, + end: alias_start + alias.len(), + }, + identity: id.map(|id| { + let literal = format!("\"{id}\""); + let literal_start = start + + text[start..end] + .find(&literal) + .expect("authored fixture UUID"); + AuthoredIdentity { + id, + literal_span: Span { + start: literal_start, + end: literal_start + literal.len(), + }, + } + }), + } + }) + .collect(); + Self { + path: path.into(), + text, + tokens, + declarations, + } + } + + fn input(&self, membership: Membership, evidence: Evidence) -> FileInput<'_> { + FileInput { + path: &self.path, + text: &self.text, + tokens: &self.tokens, + declarations: &self.declarations, + syntax: SyntaxCoverage::FullFile, + membership, + evidence, + } + } + + fn included(&self) -> FileInput<'_> { + self.input(Membership::Included, Evidence::Draft) + } + } + + fn snapshot(revision: u64) -> SnapshotKey { + SnapshotKey { + workspace: Id::from_u128(1000), + revision, + } + } + + fn limits() -> Limits { + Limits { + files: 16, + source_bytes: 1024 * 1024, + path_bytes: 8192, + tokens: 200_000, + records: 100_000, + } + } + + fn index<'a>(files: &[FileInput<'a>]) -> Index<'a> { + Index::build(snapshot(1), files, Scope::CompleteProject, limits()).unwrap() + } + + fn definition(index: &Index<'_>, alias: &str) -> DefinitionHandle { + let mut found = index.definitions_for_alias(alias, Domain::Loaded); + let result = found.next().expect("fixture definition"); + assert!(found.next().is_none(), "fixture expects unique alias"); + result + } + + fn anchor(index: &Index<'_>, handle: DefinitionHandle) -> AuthoredAnchor { + match index.identity(handle).unwrap() { + Identity::Unique(anchor) => anchor, + identity => panic!("fixture expects unique authored identity: {identity:?}"), + } + } + + #[test] + fn repeated_live_uses_are_distinct_and_strings_comments_history_are_excluded() { + let fixture = Fixture::new( + "project.arcadia", + format!( + "oa_actor tank \"Same display\" id \"{A}\" {{}}\n\ + oa_actor tank_extra \"Same display\" id \"{B}\" {{}}\n\ + oa_interaction flow \"Flow\" id \"{C}\" {{ source @tank; target @tank; other @tank_extra; \ + description \"@tank @tank_extra\"; historical uuid \"{A}\"; }}\n\ + // @tank @tank_extra and UUID {A}\n" + ), + &[ + ("oa_actor", "tank", Some(A)), + ("oa_actor", "tank_extra", Some(B)), + ("oa_interaction", "flow", Some(C)), + ], + ); + let original = ( + fixture.text.clone(), + fixture.tokens.clone(), + fixture.declarations.clone(), + ); + let index = index(&[fixture.included()]); + let tank = definition(&index, "tank"); + let uses: Vec<_> = index + .references_to(tank, Domain::Included) + .unwrap() + .collect(); + assert_eq!(uses.len(), 2); + assert_ne!(uses[0], uses[1]); + assert_eq!( + index + .references_for_alias("tank_extra", Domain::Loaded) + .count(), + 1 + ); + for usage in uses { + let record = index.reference(usage).unwrap(); + assert_eq!(&fixture.text[record.at.span.start..record.at.span.end], "@"); + assert_eq!( + &fixture.text[record.alias_location.span.start..record.alias_location.span.end], + "tank" + ); + assert_eq!( + index.resolve_reference(usage, Domain::Included), + Ok(Lookup::Unique(tank)) + ); + assert_eq!( + index.hit(0, record.alias_location.span.start), + Ok(Some(Hit::Reference(usage))) + ); + } + for ignored in [ + fixture.text.find("Same display").unwrap(), + fixture.text.find("// @tank").unwrap() + 3, + fixture.text.find("\"@tank @tank_extra\"").unwrap() + 1, + fixture.text.find("historical uuid").unwrap() + "historical uuid \"".len(), + ] { + assert_eq!(index.hit(0, ignored), Ok(None)); + } + assert_eq!(index.hit(0, fixture.text.len()), Ok(None)); + assert_eq!( + original, + ( + fixture.text.clone(), + fixture.tokens.clone(), + fixture.declarations.clone() + ) + ); + } + + #[test] + fn comment_separated_reference_keeps_crlf_unicode_and_disjoint_hit_spans() { + let fixture = Fixture::new( + "parts/actors.arcadia", + format!( + "oa_actor a \"救助 🚒\" id \"{A}\" {{}}\r\nsource @ // 注释 @a\r\n a; target @a;\r\n" + ), + &[("oa_actor", "a", Some(A))], + ); + let index = index(&[fixture.included()]); + let uses: Vec<_> = index.references_for_alias("a", Domain::Loaded).collect(); + assert_eq!(uses.len(), 2); + let first = index.reference(uses[0]).unwrap(); + assert_eq!( + index.position(0, first.at.span.start), + Ok(Position { + line: 1, + byte_column: 7 + }) + ); + assert_eq!( + index.position(0, first.alias_location.span.start), + Ok(Position { + line: 2, + byte_column: 2 + }) + ); + assert_eq!( + index.hit(0, first.at.span.start), + Ok(Some(Hit::Reference(uses[0]))) + ); + assert_eq!( + index.hit(0, first.alias_location.span.start), + Ok(Some(Hit::Reference(uses[0]))) + ); + for byte in [ + first.at.span.end, + fixture.text.find("注释").unwrap(), + fixture.text.find("// 注释 @a").unwrap() + "// 注释 ".len(), + first.alias_location.span.end, + ] { + assert_eq!(index.hit(0, byte), Ok(None)); + } + let unicode_inside = fixture.text.find('救').unwrap() + 1; + assert_eq!( + index.hit(0, unicode_inside), + Err(QueryError::InvalidBytePosition) + ); + assert_eq!( + index.position(0, unicode_inside), + Err(QueryError::InvalidBytePosition) + ); + assert_eq!( + index.hit(0, fixture.text.len() + 1), + Err(QueryError::InvalidBytePosition) + ); + assert_eq!(index.hit(1, 0), Err(QueryError::InvalidFile)); + assert!(index.source(0).unwrap().text.contains("\r\n")); + } + + #[test] + fn legal_keyword_classified_alias_is_not_discarded() { + let fixture = Fixture::new( + "project.arcadia", + format!( + "oa_actor oa_activity \"Participant\" id \"{A}\" {{}}\nsource @oa_activity; source @parent;" + ), + &[("oa_actor", "oa_activity", Some(A))], + ); + assert!(oa_core::valid_alias("oa_activity")); + assert!(!oa_core::valid_alias("parent")); + assert_eq!( + token_at(&fixture.tokens, fixture.declarations[0].alias_span) + .unwrap() + .class, + TokenClass::Keyword + ); + let index = index(&[fixture.included()]); + let found = definition(&index, "oa_activity"); + let usage = index + .references_for_alias("oa_activity", Domain::Included) + .next() + .unwrap(); + assert_eq!( + index.resolve_reference(usage, Domain::Included), + Ok(Lookup::Unique(found)) + ); + assert_eq!( + index.references_for_alias("parent", Domain::Loaded).count(), + 0 + ); + assert_eq!( + index.hit(0, fixture.declarations[0].alias_span.start), + Ok(Some(Hit::Definition(found))) + ); + } + + #[test] + fn duplicate_aliases_are_candidates_without_false_reference_attribution() { + let first = Fixture::new( + "project.arcadia", + format!("oa_actor a \"Same display\" id \"{A}\" {{}}\nsource @a;"), + &[("oa_actor", "a", Some(A))], + ); + let second = Fixture::new( + "parts/other.arcadia", + format!("oa_actor a \"Same display\" id \"{B}\" {{}}\nsource @a;"), + &[("oa_actor", "a", Some(B))], + ); + let index = index(&[first.included(), second.included()]); + let candidates: Vec<_> = index.definitions_for_alias("a", Domain::Included).collect(); + assert_eq!(candidates.len(), 2); + assert_eq!(index.definition(candidates[0]).unwrap().declaration.file, 0); + assert_eq!(index.definition(candidates[1]).unwrap().declaration.file, 1); + assert_ne!(anchor(&index, candidates[0]), anchor(&index, candidates[1])); + assert!(matches!( + index.references_to(candidates[0], Domain::Included), + Err(QueryError::AmbiguousAlias { count: 2 }) + )); + let uses: Vec<_> = index.references_for_alias("a", Domain::Included).collect(); + assert_eq!(uses.len(), 2); + for usage in uses { + assert_eq!( + index.resolve_reference(usage, Domain::Included), + Ok(Lookup::Ambiguous { count: 2 }) + ); + } + } + + #[test] + fn detached_uuid_clone_disables_anchor_even_for_included_query() { + let first = Fixture::new( + "project.arcadia", + format!("oa_actor a \"First\" id \"{A}\" {{}}"), + &[("oa_actor", "a", Some(A))], + ); + let original = index(&[first.included()]); + let old_anchor = anchor(&original, definition(&original, "a")); + let detached = Fixture::new( + "retained.arcadia", + format!("oa_actor old \"Retained\" id \"{A}\" {{}}"), + &[("oa_actor", "old", Some(A))], + ); + let current = Index::build( + snapshot(2), + &[ + first.included(), + detached.input(Membership::Detached, Evidence::Draft), + ], + Scope::CompleteProject, + limits(), + ) + .unwrap(); + assert_eq!( + current.identity(definition(¤t, "a")), + Ok(Identity::Collision { count: 2 }) + ); + assert_eq!( + current.identity(definition(¤t, "old")), + Ok(Identity::Collision { count: 2 }) + ); + assert_eq!( + current.resolve_anchor(old_anchor, Domain::Included), + Ok(Lookup::Ambiguous { count: 2 }) + ); + assert_eq!( + current.resolve_anchor(old_anchor, Domain::Loaded), + Ok(Lookup::Ambiguous { count: 2 }) + ); + assert_eq!( + current + .definitions_for_alias("old", Domain::Included) + .count(), + 0 + ); + assert_eq!(current.definitions_for_id(A, Domain::Included).count(), 1); + assert_eq!(current.definitions_for_id(A, Domain::Loaded).count(), 2); + } + + #[test] + fn reused_alias_cannot_steal_old_identity_and_old_locations_are_stale() { + let first = Fixture::new( + "project.arcadia", + format!("oa_actor a \"First\" id \"{A}\" {{}}\nsource @a;"), + &[("oa_actor", "a", Some(A))], + ); + let original = index(&[first.included()]); + let old_definition = definition(&original, "a"); + let old_reference = original + .references_for_alias("a", Domain::Included) + .next() + .unwrap(); + let old_anchor = anchor(&original, old_definition); + let edited = Fixture::new( + "project.arcadia", + format!( + "oa_actor b \"First renamed\" id \"{A}\" {{}}\noa_actor a \"New actor\" id \"{B}\" {{}}\nsource @a; target @b;" + ), + &[("oa_actor", "b", Some(A)), ("oa_actor", "a", Some(B))], + ); + let current = Index::build( + snapshot(2), + &[edited.included()], + Scope::CompleteProject, + limits(), + ) + .unwrap(); + assert_eq!( + current + .definition(old_definition) + .map(|definition| definition.alias), + Err(QueryError::StaleSnapshot) + ); + assert_eq!( + current + .reference(old_reference) + .map(|reference| reference.alias), + Err(QueryError::StaleSnapshot) + ); + assert_eq!( + current.resolve_anchor(old_anchor, Domain::Included), + Ok(Lookup::Unique(definition(¤t, "b"))) + ); + let new_use = current + .references_for_alias("a", Domain::Included) + .next() + .unwrap(); + assert_eq!( + current.resolve_reference(new_use, Domain::Included), + Ok(Lookup::Unique(definition(¤t, "a"))) + ); + assert_eq!(anchor(¤t, definition(¤t, "a")).id(), B); + let other_workspace = Index::build( + SnapshotKey { + workspace: Id::from_u128(2000), + revision: 2, + }, + &[edited.included()], + Scope::CompleteProject, + limits(), + ) + .unwrap(); + assert_eq!( + other_workspace.resolve_anchor(old_anchor, Domain::Loaded), + Err(QueryError::WrongWorkspace) + ); + assert_eq!( + other_workspace + .definition(definition(¤t, "b")) + .map(|definition| definition.alias), + Err(QueryError::StaleSnapshot) + ); + let deleted = Fixture::new( + "project.arcadia", + format!("oa_actor a \"New actor\" id \"{B}\" {{}}"), + &[("oa_actor", "a", Some(B))], + ); + let after_delete = Index::build( + snapshot(3), + &[deleted.included()], + Scope::CompleteProject, + limits(), + ) + .unwrap(); + assert_eq!( + after_delete.resolve_anchor(old_anchor, Domain::Loaded), + Ok(Lookup::Missing) + ); + } + + #[test] + fn missing_ids_produce_only_current_snapshot_locations_without_stabilization() { + let fixture = Fixture::new( + "project.arcadia", + format!("oa_actor a \"No UUID\" {{ historical uuid \"{A}\"; }}\r\nsource @a;"), + &[("oa_actor", "a", None)], + ); + let original = fixture.text.clone(); + let index = index(&[fixture.included()]); + let declaration = definition(&index, "a"); + assert_eq!(index.identity(declaration), Ok(Identity::Missing)); + assert_eq!(index.definition(declaration).unwrap().authored_id, None); + let usage = index + .references_to(declaration, Domain::Included) + .unwrap() + .next() + .unwrap(); + assert_eq!( + index.resolve_reference(usage, Domain::Included), + Ok(Lookup::Unique(declaration)) + ); + assert_eq!(fixture.text, original); + assert_eq!( + index.source(0).unwrap().text.as_bytes(), + original.as_bytes() + ); + assert!(!index.source(0).unwrap().text.contains(" id ")); + assert_eq!( + index.hit(0, fixture.text.find(&A.to_string()).unwrap()), + Ok(None) + ); + } + + #[test] + fn scope_provenance_and_detached_alias_collision_are_explicit() { + let included = Fixture::new( + "project.arcadia", + format!("oa_actor a \"Included\" id \"{A}\" {{}}\nsource @a;"), + &[("oa_actor", "a", Some(A))], + ); + let detached = Fixture::new( + "old.arcadia", + format!("oa_actor a \"Detached\" id \"{B}\" {{}}\nsource @a;"), + &[("oa_actor", "a", Some(B))], + ); + let unknown = Fixture::new("new.arcadia", "source @a;".into(), &[]); + let index = Index::build( + snapshot(1), + &[ + included.input(Membership::Included, Evidence::Validated), + detached.input(Membership::Detached, Evidence::Draft), + unknown.input(Membership::Unverified, Evidence::Draft), + ], + Scope::Partial(PartialReason::IncludesUnverified), + limits(), + ) + .unwrap(); + assert_eq!(index.references_for_alias("a", Domain::Included).count(), 1); + assert_eq!(index.references_for_alias("a", Domain::Loaded).count(), 3); + let scope = index.references_for_alias("a", Domain::Included).scope(); + assert_eq!( + scope.declared, + Scope::Partial(PartialReason::IncludesUnverified) + ); + assert!(!scope.project_complete); + assert!(scope.all_selected_files_validated); + assert_eq!( + ( + scope.selected_files, + scope.detached_files, + scope.unverified_files + ), + (1, 1, 1) + ); + assert!(!index.scope(Domain::Loaded).all_selected_files_validated); + let uses: Vec<_> = index.references_for_alias("a", Domain::Loaded).collect(); + assert!(matches!( + index.resolve_reference(uses[0], Domain::Included), + Ok(Lookup::Unique(_)) + )); + assert_eq!( + index.resolve_reference(uses[0], Domain::Loaded), + Ok(Lookup::Ambiguous { count: 2 }) + ); + assert_eq!( + index.resolve_reference(uses[1], Domain::Included), + Err(QueryError::OutsideDomain) + ); + assert_eq!( + index.resolve_reference(uses[2], Domain::Included), + Err(QueryError::OutsideDomain) + ); + assert_eq!(index.source(2).unwrap().membership, Membership::Unverified); + assert!(matches!( + Index::build( + snapshot(1), + &[ + included.included(), + unknown.input(Membership::Unverified, Evidence::Draft) + ], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::InconsistentCompleteScope, + .. + }) + )); + } + + #[test] + fn incomplete_draft_is_lexical_and_error_token_does_not_leak_references() { + let fixture = Fixture::new( + "project.arcadia", + "oa_actor a \"Draft\" {}\nsource @a; ? \"unfinished @a".into(), + &[("oa_actor", "a", None)], + ); + assert!( + fixture + .tokens + .iter() + .any(|token| token.class == TokenClass::Error) + ); + let index = Index::build( + snapshot(1), + &[fixture.included()], + Scope::Partial(PartialReason::UnappliedWorkspace), + limits(), + ) + .unwrap(); + let matches = index + .references_to(definition(&index, "a"), Domain::Included) + .unwrap(); + assert!(!matches.scope().project_complete); + assert!(!matches.scope().all_selected_files_validated); + assert_eq!(matches.count(), 1); + assert_eq!(index.hit(0, fixture.text.rfind("@a").unwrap()), Ok(None)); + } + + #[test] + fn header_metadata_must_match_current_token_boundaries_and_authored_id() { + let mut fixture = Fixture::new( + "project.arcadia", + format!("oa_actor alpha \"救助\" id \"{A}\" {{}}"), + &[("oa_actor", "alpha", Some(A))], + ); + let original = fixture.declarations[0]; + fixture.declarations[0].alias_span.start += 1; + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::HeaderRoleToken, + .. + }) + )); + fixture.declarations[0] = original; + fixture.declarations[0].identity.as_mut().unwrap().id = B; + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::IdentityLiteral, + .. + }) + )); + fixture.declarations[0] = original; + fixture.declarations[0].alias_span = Span { + start: fixture.text.find('救').unwrap() + 1, + end: fixture.text.find('救').unwrap() + 3, + }; + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::DeclarationBounds, + .. + }) + )); + fixture.declarations[0] = original; + fixture.declarations[0].span.end = fixture.text.len() + 1; + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::DeclarationBounds, + .. + }) + )); + } + + #[test] + fn truncated_or_gapped_tokens_refuse_even_when_project_scope_is_partial() { + let mut fixture = Fixture::new( + "project.arcadia", + "oa_actor a \"Text\" {}\nsource @a;".into(), + &[("oa_actor", "a", None)], + ); + let final_token = fixture.tokens.pop().unwrap(); + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::Partial(PartialReason::MissingFiles), + limits() + ), + Err(BuildError::Input { + problem: InputProblem::TokenCoverage, + .. + }) + )); + fixture.tokens.push(final_token); + fixture.tokens.remove(1); + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::TokenCoverage, + .. + }) + )); + let mut unicode = Fixture::new("project.arcadia", "// 救助\n".into(), &[]); + unicode.tokens[0].span.end = unicode.text.find('救').unwrap() + 1; + assert!(matches!( + Index::build( + snapshot(1), + &[unicode.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::TokenBounds, + .. + }) + )); + } + + #[test] + fn stopped_outline_is_not_a_successful_empty_query() { + let fixture = Fixture::new( + "project.arcadia", + "oa_actor a \"Text\" {}\nsource @a;".into(), + &[("oa_actor", "a", None)], + ); + let mut stopped = fixture.included(); + stopped.declarations = &[]; + stopped.syntax = SyntaxCoverage::Incomplete; + assert!(matches!( + Index::build( + snapshot(1), + &[stopped], + Scope::Partial(PartialReason::MissingFiles), + limits() + ), + Err(BuildError::Input { + problem: InputProblem::IncompleteSyntax, + .. + }) + )); + } + + #[test] + fn nested_declaration_extents_do_not_turn_body_text_into_definition_hits() { + let mut fixture = Fixture::new( + "project.arcadia", + "oa_entity outer \"Outer\" {\n oa_actor inner \"Inner\" {}\n}\nsource @inner;".into(), + &[("oa_entity", "outer", None), ("oa_actor", "inner", None)], + ); + // Supply the actual outer whole span; nesting does not change alias order. + fixture.declarations[0].span.end = fixture.text.rfind('}').unwrap() + 1; + let index = index(&[fixture.included()]); + let inner = definition(&index, "inner"); + assert_eq!( + index.hit(0, fixture.declarations[1].alias_span.start), + Ok(Some(Hit::Definition(inner))) + ); + assert_eq!( + index.hit(0, fixture.text.find("\"Inner\"").unwrap() + 1), + Ok(None) + ); + let usage = index + .references_for_alias("inner", Domain::Included) + .next() + .unwrap(); + assert_eq!( + index.resolve_reference(usage, Domain::Included), + Ok(Lookup::Unique(inner)) + ); + } + + #[test] + fn path_identity_rejects_normalization_and_case_collisions() { + let mut first = Fixture::new("project.arcadia", "source @a;".into(), &[]); + let second = Fixture::new("PROJECT.arcadia", "source @b;".into(), &[]); + assert!(matches!( + Index::build( + snapshot(1), + &[first.included(), second.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::DuplicateOrCaseCollidingPath, + .. + }) + )); + assert!(matches!( + Index::build( + snapshot(1), + &[first.included(), first.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::DuplicateOrCaseCollidingPath, + .. + }) + )); + for invalid in [ + "./project.arcadia", + "parts/../project.arcadia", + "../project.arcadia", + "C:/project.arcadia", + "parts\\project.arcadia", + ] { + first.path = invalid.into(); + assert!( + matches!( + Index::build( + snapshot(1), + &[first.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::NonCanonicalPath, + .. + }) + ), + "{invalid}" + ); + } + } + + #[test] + fn unicode_path_collisions_match_core_lowercase_policy() { + for (first_path, second_path) in [ + ("Ä.arcadia", "ä.arcadia"), + ("ΟΣ/model.arcadia", "ος/model.arcadia"), + ] { + let first = Fixture::new(first_path, "source @a;".into(), &[]); + let second = Fixture::new(second_path, "source @b;".into(), &[]); + assert_eq!(first_path.to_lowercase(), second_path.to_lowercase()); + assert!(!first_path.eq_ignore_ascii_case(second_path)); + assert!(matches!( + Index::build( + snapshot(1), + &[first.included(), second.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::DuplicateOrCaseCollidingPath, + .. + }) + )); + } + } + + #[test] + fn noncanonical_header_identity_cannot_enter_through_query_metadata() { + for raw in [ + "00000000000000000000000000000001", + "{00000000-0000-0000-0000-000000000001}", + "urn:uuid:00000000-0000-0000-0000-000000000001", + ] { + let literal = format!("\"{raw}\""); + let source = format!("oa_actor a \"A\" id {literal} {{}}\nsource @a;"); + let mut fixture = Fixture::new( + "project.arcadia", + source.clone(), + &[("oa_actor", "a", None)], + ); + let start = source.find(&literal).unwrap(); + // This fixture has one explicit empty body. A brace inside the + // quoted UUID must not become the test's declaration end. + let body_start = source.find("{}\n").unwrap(); + fixture.declarations[0].span.end = body_start + 2; + assert!(start + literal.len() <= body_start); + // An adapter passes None from the corrected outline; lexical + // definition/reference navigation survives without an ID anchor. + { + let admitted = index(&[fixture.included()]); + let d = definition(&admitted, "a"); + assert_eq!(admitted.identity(d), Ok(Identity::Missing)); + assert_eq!( + admitted.references_to(d, Domain::Included).unwrap().count(), + 1 + ); + assert_eq!(admitted.source(0).unwrap().text, source); + } + // A forged/stale caller cannot put the UUID crate's permissive + // interpretation back into an authored anchor via the raw API. + fixture.declarations[0].identity = Some(AuthoredIdentity { + id: A, + literal_span: Span { + start, + end: start + literal.len(), + }, + }); + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::IdentityLiteral, + .. + }) + )); + } + } + + #[test] + fn query_header_admission_preserves_uppercase_and_json_escape_bytes() { + let expected = Id::from_u128(0xabcdef); + let uppercase = expected.to_string().to_uppercase(); + for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] { + let literal = format!("\"{raw}\""); + let source = format!("oa_actor a \"A\" id {literal} {{}}\nsource @a;"); + let mut fixture = Fixture::new( + "project.arcadia", + source.clone(), + &[("oa_actor", "a", None)], + ); + let start = source.find(&literal).unwrap(); + fixture.declarations[0].identity = Some(AuthoredIdentity { + id: expected, + literal_span: Span { + start, + end: start + literal.len(), + }, + }); + let admitted = index(&[fixture.included()]); + assert_eq!(anchor(&admitted, definition(&admitted, "a")).id(), expected); + assert_eq!( + admitted.source(0).unwrap().text.as_bytes(), + source.as_bytes() + ); + } + } + + #[test] + fn admitted_unicode_path_budget_keeps_original_policy_after_single_fold() { + let fixture = Fixture::new("İ.arcadia", String::new(), &[]); + assert!(fixture.path.to_lowercase().len() > fixture.path.len()); + let exact = Limits { + files: 1, + source_bytes: 0, + path_bytes: fixture.path.len(), + tokens: 0, + records: 0, + }; + let admitted = Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + exact, + ) + .unwrap(); + assert_eq!(admitted.source(0).unwrap().path, "İ.arcadia"); + // PathBytes continues to count authored input. One lowercase key's + // Unicode expansion is bounded scratch, not a new path rejection rule. + } + + #[test] + fn every_budget_admits_boundary_and_returns_no_index_one_below() { + let fixture = Fixture::new( + "project.arcadia", + "oa_actor a \"Text\" {}\nsource @a; target @a;".into(), + &[("oa_actor", "a", None)], + ); + let exact = Limits { + files: 1, + source_bytes: fixture.text.len(), + path_bytes: fixture.path.len(), + tokens: fixture.tokens.len(), + records: 3, + }; + assert!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + exact + ) + .is_ok() + ); + for resource in [ + Resource::Files, + Resource::SourceBytes, + Resource::PathBytes, + Resource::Tokens, + Resource::Records, + ] { + let mut reduced = exact; + match resource { + Resource::Files => reduced.files -= 1, + Resource::SourceBytes => reduced.source_bytes -= 1, + Resource::PathBytes => reduced.path_bytes -= 1, + Resource::Tokens => reduced.tokens -= 1, + Resource::Records => reduced.records -= 1, + } + assert!( + matches!(Index::build(snapshot(1), &[fixture.included()], Scope::CompleteProject, reduced), Err(BuildError::Limit { resource: actual, .. }) if actual == resource) + ); + } + // Aggregate bytes/tokens/records, not a per-file budget reset. + let second = Fixture::new("other.arcadia", "source @a;".into(), &[]); + let mut aggregate = limits(); + aggregate.records = 3; + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included(), second.included()], + Scope::CompleteProject, + aggregate + ), + Err(BuildError::Limit { + resource: Resource::Records, + observed_at_least: 4, + .. + }) + )); + let mut maximum = usize::MAX; + assert!(matches!( + charge(&mut maximum, 1, usize::MAX, Resource::SourceBytes), + Err(BuildError::Limit { + resource: Resource::SourceBytes, + .. + }) + )); + assert_eq!(maximum, usize::MAX); + } + + #[test] + fn declaration_order_and_duplicate_roles_refuse_instead_of_corrupting_hit_search() { + let mut fixture = Fixture::new( + "project.arcadia", + "oa_actor a \"First\" {}\noa_actor b \"Second\" {}".into(), + &[("oa_actor", "a", None), ("oa_actor", "b", None)], + ); + fixture.declarations.reverse(); + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::DeclarationOrder, + .. + }) + )); + fixture.declarations.reverse(); + fixture.declarations.insert(1, fixture.declarations[0]); + assert!(matches!( + Index::build( + snapshot(1), + &[fixture.included()], + Scope::CompleteProject, + limits() + ), + Err(BuildError::Input { + problem: InputProblem::DeclarationOrder, + .. + }) + )); + } +} diff --git a/crates/oa-desktop/src/source_rename.rs b/crates/oa-desktop/src/source_rename.rs new file mode 100644 index 00000000..f185672c --- /dev/null +++ b/crates/oa-desktop/src/source_rename.rs @@ -0,0 +1,3 @@ +//! Pure preview and exact origin mapping for the guarded alias-rename controller. +pub mod origin; +pub mod preview; diff --git a/crates/oa-desktop/src/source_rename/origin.rs b/crates/oa-desktop/src/source_rename/origin.rs new file mode 100644 index 00000000..3da6ccb3 --- /dev/null +++ b/crates/oa-desktop/src/source_rename/origin.rs @@ -0,0 +1,278 @@ +//! Map a role-proven alias occurrence through the exact prepared source change. +//! This is cursor restoration, not target resolution or commit authority. +use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass}; +use std::fmt; + +const LIMITS: OutlineLimits = OutlineLimits { + max_source_bytes: 64 * 1024 * 1024, + max_tokens: 1_000_000, + max_declarations: 250_000, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginError { + Budget, + InvalidSource, + MissingOrigin, + ChangedStructure, +} + +impl fmt::Display for OriginError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.", + Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.", + Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.", + Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.", + }) + } +} + +impl std::error::Error for OriginError {} + +fn word(class: TokenClass) -> bool { + matches!(class, TokenClass::Identifier | TokenClass::Keyword) +} + +/// The controller passes the exact alias span returned by the admitted symbol +/// hit. `marker` is Some only for a reference (its separate @ span), including +/// invocation with the caret on @. A declaration passes None. +pub fn map( + before: &str, + after: &str, + old_alias: &str, + new_alias: &str, + origin: Span, + marker: Option, +) -> Result { + let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?; + let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?; + if old.has_lexical_errors || new.has_lexical_errors { + return Err(OriginError::InvalidSource); + } + if old.tokens.len() != new.tokens.len() { + return Err(OriginError::ChangedStructure); + } + let roles_before = roles(&old); + let roles_after = roles(&new); + let mut mapped = None; + for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() { + let left = &before[a.span.start..a.span.end]; + let right = &after[b.span.start..b.span.end]; + let alias_pair = word(a.class) + && word(b.class) + && left == old_alias + && right == new_alias + && roles_before[index].is_some() + && roles_after[index].is_some() + && roles_before[index].map(|role| role.is_some()) + == roles_after[index].map(|role| role.is_some()); + if a.span == origin { + if !alias_pair || roles_before[index] != Some(marker) { + return Err(OriginError::MissingOrigin); + } + mapped = Some(b.span); + } + if !alias_pair && (left != right || a.class != b.class) { + return Err(OriginError::ChangedStructure); + } + } + mapped.ok_or(OriginError::MissingOrigin) +} + +/// Outer None is not an alias role; Some(None) is a declaration alias; +/// Some(Some(span)) is a live @ reference with its exact separate marker. +/// Scan sorted declaration spans and tokens together, without repeated searches. +fn roles(outline: &Outline<'_>) -> Vec>> { + let mut result = vec![None; outline.tokens.len()]; + let mut declarations = outline.declarations.iter().peekable(); + let mut significant = None; + for (index, token) in outline.tokens.iter().enumerate() { + while declarations + .peek() + .is_some_and(|d| d.alias_span.start < token.span.start) + { + declarations.next(); + } + if word(token.class) { + if declarations + .peek() + .is_some_and(|d| d.alias_span == token.span) + { + result[index] = Some(None); + } else if let Some(previous) = significant { + let span: Span = previous; + if &outline.source[span.start..span.end] == "@" { + result[index] = Some(Some(span)); + } + } + } + if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) { + significant = Some(token.span); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn occurrence(source: &str, needle: &str, which: usize) -> Span { + let start = source.match_indices(needle).nth(which).unwrap().0; + Span { + start, + end: start + needle.len(), + } + } + + #[test] + fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() { + let before = + "oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n"; + let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n"; + let start = before.rfind(" a;").unwrap() + 1; + let marker = before.rfind("@ //").unwrap(); + let mapped = map( + before, + after, + "a", + "renamed", + Span { + start, + end: start + 1, + }, + Some(Span { + start: marker, + end: marker + 1, + }), + ) + .unwrap(); + assert_eq!(&after[mapped.start..mapped.end], "renamed"); + assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1); + assert_eq!( + before[..start].bytes().filter(|b| *b == b'\n').count(), + after[..mapped.start] + .bytes() + .filter(|b| *b == b'\n') + .count() + ); + } + + #[test] + fn preserves_final_same_line_occurrence_not_first_or_prose() { + let before = "oa_activity a \"a @a\" {} source @a; target @a;"; + let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;"; + let start = before.rfind("@a").unwrap(); + let result = map( + before, + after, + "a", + "longer", + Span { + start: start + 1, + end: start + 2, + }, + Some(Span { + start, + end: start + 1, + }), + ) + .unwrap(); + assert_eq!(result.start, after.rfind("@longer").unwrap() + 1); + let prose = occurrence(before, "a @a", 0); + assert!( + map( + before, + after, + "a", + "longer", + Span { + start: prose.start, + end: prose.start + 1 + }, + None + ) + .is_err() + ); + } + + #[test] + fn permits_identifier_keyword_transitions_in_alias_roles() { + let before = "oa_activity a \"Name\" {} source @a;"; + let alias = occurrence(before, "a \"Name", 0); + let origin = Span { + start: alias.start, + end: alias.start + 1, + }; + for replacement in ["none", "include", "oa_activity"] { + assert!(oa_core::valid_alias(replacement)); + assert_eq!( + language::tokenize(replacement)[0].class, + TokenClass::Keyword + ); + let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};"); + let mapped = map(before, &after, "a", replacement, origin, None).unwrap(); + assert_eq!(&after[mapped.start..mapped.end], replacement); + assert_eq!( + map(&after, before, replacement, "a", mapped, None).unwrap(), + origin + ); + } + } + + #[test] + fn rejects_changed_trivia_non_alias_words_and_wrong_marker() { + let before = "oa_activity a \"Name\" {} source @a;"; + let after = "oa_activity b \"Name\" {} source @b;"; + let start = before.rfind("@a").unwrap(); + let span = Span { + start: start + 1, + end: start + 2, + }; + let marker = Some(Span { + start, + end: start + 1, + }); + assert_eq!( + map( + before, + &after.replace("Name", "Other"), + "a", + "b", + span, + marker + ), + Err(OriginError::ChangedStructure) + ); + assert_eq!( + map( + before, + &after.replace(" source", " source"), + "a", + "b", + span, + marker + ), + Err(OriginError::ChangedStructure) + ); + assert_eq!( + map(before, after, "a", "b", span, None), + Err(OriginError::MissingOrigin) + ); + assert!( + map( + before, + after, + "a", + "b", + Span { + start: 0, + end: usize::MAX + }, + marker + ) + .is_err() + ); + } +} diff --git a/crates/oa-desktop/src/source_rename/preview.rs b/crates/oa-desktop/src/source_rename/preview.rs new file mode 100644 index 00000000..df96bb6d --- /dev/null +++ b/crates/oa-desktop/src/source_rename/preview.rs @@ -0,0 +1,578 @@ +//! Complete, bounded line-ordinal previews of an already prepared alias rename. +//! +//! Source text remains in the caller's immutable before/after snapshots. This +//! module owns only paths and indexes; it is not rename or commit authority. +use oa_core::source_project::SourceBundle; +use std::{fmt, ops::Range}; + +const CONTEXT: usize = 3; +// A byte-grid boundary can move back by at most three UTF-8 continuation bytes, +// so a 252-byte grid yields fragments of at most 255 bytes, independent of input. +const SEGMENT_GRID: usize = 252; + +/// Count all bounded fragments of exact source text; an empty line has one +/// empty fragment. Page these as well as lines, never mount an unbounded line. +pub fn segment_count(text: &str) -> usize { + text.len().div_ceil(SEGMENT_GRID).max(1) +} + +/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the +/// original bytes, including CRLF. No ellipsis or truncation replaces content. +pub fn text_segment(text: &str, index: usize) -> Option<&str> { + if index >= segment_count(text) { + return None; + } + let mut start = index.checked_mul(SEGMENT_GRID)?; + let mut end = start.saturating_add(SEGMENT_GRID).min(text.len()); + while !text.is_char_boundary(start) { + start -= 1; + } + while !text.is_char_boundary(end) { + end -= 1; + } + text.get(start..end) +} +const LIMITS: Limits = Limits { + files: 256, + changed_lines: 50_000, + display_bytes: 8 * 1024 * 1024, +}; + +#[derive(Clone, Copy)] +struct Limits { + files: usize, + changed_lines: usize, + display_bytes: usize, +} + +#[derive(Debug)] +pub struct Preview { + files: Vec, + changed_lines: usize, + display_bytes: usize, +} + +#[derive(Debug)] +pub struct File { + path: String, + hunks: Vec, +} + +#[derive(Debug)] +pub struct Hunk { + lines: Vec, +} + +#[derive(Debug)] +pub struct Line { + number: usize, + before: Range, + after: Range, + changed: bool, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PreviewError { + MembershipChanged, + LineCountChanged(String), + LineEndingChanged { path: String, line: usize }, + TooManyFiles { limit: usize }, + TooManyChangedLines { limit: usize }, + TooManyDisplayBytes { limit: usize }, +} + +impl fmt::Display for PreviewError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MembershipChanged => write!( + f, + "Rename preview refused: the entry or source file membership changed. No rename was applied." + ), + Self::LineCountChanged(path) => write!( + f, + "Rename preview refused: the line count changed in {path}. No rename was applied." + ), + Self::LineEndingChanged { path, line } => write!( + f, + "Rename preview refused: the line ending changed in {path}:{line}. No rename was applied." + ), + Self::TooManyFiles { limit } => write!( + f, + "Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied." + ), + Self::TooManyChangedLines { limit } => write!( + f, + "Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied." + ), + Self::TooManyDisplayBytes { limit } => write!( + f, + "Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied." + ), + } + } +} + +impl std::error::Error for PreviewError {} + +impl Preview { + pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result { + Self::with_limits(before, after, LIMITS) + } + + fn with_limits( + before: &SourceBundle, + after: &SourceBundle, + limits: Limits, + ) -> Result { + if before.entry != after.entry + || !before.files.contains_key(&before.entry) + || !before.files.keys().eq(after.files.keys()) + { + return Err(PreviewError::MembershipChanged); + } + let mut preview = Self { + files: Vec::new(), + changed_lines: 0, + display_bytes: 0, + }; + for ((path, old), (_, new)) in before.files.iter().zip(&after.files) { + if old == new { + continue; + } + if preview.files.len() == limits.files { + return Err(PreviewError::TooManyFiles { + limit: limits.files, + }); + } + // A changed source line can contain arbitrarily many renamed tokens. + // Count the line once; this count is never a semantic-reference count. + let mut changed = Vec::new(); + let mut old_lines = Lines::new(old); + let mut new_lines = Lines::new(new); + let mut count = 0; + loop { + match (old_lines.next(), new_lines.next()) { + (None, None) => break, + (Some(a), Some(b)) => { + count += 1; + if old[a.content_end..a.end] != new[b.content_end..b.end] { + return Err(PreviewError::LineEndingChanged { + path: path.clone(), + line: count, + }); + } + if old[a.start..a.end] != new[b.start..b.end] { + if preview.changed_lines == limits.changed_lines { + return Err(PreviewError::TooManyChangedLines { + limit: limits.changed_lines, + }); + } + preview.changed_lines += 1; + changed.push(count - 1); + } + } + _ => return Err(PreviewError::LineCountChanged(path.clone())), + } + } + + // Merge touching context intervals in order, without pairwise diff. + // At most 7 indexed lines per changed line are retained; unchanged + // lines outside these intervals are scanned but never indexed. + let mut ranges: Vec> = Vec::new(); + for line in changed { + let range = + line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count); + if let Some(previous) = ranges.last_mut() + && range.start <= previous.end + { + previous.end = previous.end.max(range.end); + continue; + } + ranges.push(range); + } + preview.charge(path.len(), limits)?; + let mut file = File { + path: path.clone(), + hunks: Vec::new(), + }; + let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate(); + for range in ranges { + let mut hunk = Hunk { lines: Vec::new() }; + for (ordinal, (a, b)) in pairs.by_ref() { + if ordinal < range.start { + continue; + } + // Charge exact UTF-8 bytes, including preserved endings and + // both copies of shared context, before adding the index. + // No render-time omission or shortening grants admission. + preview.charge(a.end - a.start, limits)?; + preview.charge(b.end - b.start, limits)?; + hunk.lines.push(Line { + number: ordinal + 1, + changed: old[a.start..a.end] != new[b.start..b.end], + before: a.start..a.end, + after: b.start..b.end, + }); + if ordinal + 1 == range.end { + break; + } + } + file.hunks.push(hunk); + } + preview.files.push(file); + } + Ok(preview) + } + + fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> { + let Some(total) = self.display_bytes.checked_add(bytes) else { + return Err(PreviewError::TooManyDisplayBytes { + limit: limits.display_bytes, + }); + }; + if total > limits.display_bytes { + return Err(PreviewError::TooManyDisplayBytes { + limit: limits.display_bytes, + }); + } + self.display_bytes = total; + Ok(()) + } + + /// All admitted files in source path order; suitable for a file picker/search. + pub fn files(&self) -> &[File] { + &self.files + } + + /// Changed source lines, not occurrences, declarations or semantic uses. + pub fn changed_line_count(&self) -> usize { + self.changed_lines + } + + /// Exact displayed source bytes for both sides plus each stored path once. + /// Index metadata is separately bounded by 7 lines per changed line. + pub fn display_bytes(&self) -> usize { + self.display_bytes + } + + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +impl File { + pub fn path(&self) -> &str { + &self.path + } + + /// Every hunk is present; the caller may page these indexes, never truncate. + pub fn hunks(&self) -> &[Hunk] { + &self.hunks + } +} + +impl Hunk { + /// The complete ordered line slice supports paging even a large single hunk. + pub fn lines(&self) -> &[Line] { + &self.lines + } +} + +impl Line { + /// One-based source line number, identical on both sides. + pub fn number(&self) -> usize { + self.number + } + + pub fn changed(&self) -> bool { + self.changed + } + + /// Borrow from the immutable original file; includes its exact line ending. + pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> { + original.get(self.before.clone()) + } + + /// Borrow from the immutable candidate file; includes its exact line ending. + pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> { + candidate.get(self.after.clone()) + } +} + +struct RawLine { + start: usize, + content_end: usize, + end: usize, +} + +struct Lines<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Lines<'a> { + fn new(text: &'a str) -> Self { + Self { + bytes: text.as_bytes(), + offset: 0, + } + } +} + +impl Iterator for Lines<'_> { + type Item = RawLine; + + fn next(&mut self) -> Option { + if self.offset == self.bytes.len() { + return None; + } + let start = self.offset; + // The compiler and source navigation count LF, not bare CR, as a line + // boundary. Preserve CRLF as an ending and bare CR as source content. + while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' { + self.offset += 1; + } + let mut content_end = self.offset; + if self.offset < self.bytes.len() { + if self.offset > start && self.bytes[self.offset - 1] == b'\r' { + content_end -= 1; + } + self.offset += 1; + } + Some(RawLine { + start, + content_end, + end: self.offset, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bundle(source: &str) -> SourceBundle { + SourceBundle { + entry: "救援.arcadia".into(), + files: [("救援.arcadia".into(), source.into())].into(), + } + } + + #[test] + fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() { + let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200)); + let pieces: Vec<_> = (0..segment_count(&text)) + .map(|i| text_segment(&text, i).unwrap()) + .collect(); + assert!(pieces.len() > 2); + assert!(pieces.iter().all(|part| part.len() <= 255)); + assert_eq!(pieces.concat(), text); + assert_eq!(text_segment(&text, segment_count(&text)), None); + assert_eq!(text_segment(&text, usize::MAX), None); + assert_eq!(segment_count(""), 1); + assert_eq!(text_segment("", 0), Some("")); + } + + #[test] + fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() { + let old = bundle("// 救援\r\n@a @a\ncontext\r@a"); + let new = bundle("// 救援\r\n@long @long\ncontext\r@long"); + let preview = Preview::build(&old, &new).unwrap(); + assert_eq!(preview.changed_line_count(), 2); + let file = &preview.files()[0]; + assert_eq!(file.path(), "救援.arcadia"); + assert_eq!(file.hunks().len(), 1); + let lines = file.hunks()[0].lines(); + assert_eq!( + lines.iter().map(Line::number).collect::>(), + [1, 2, 3] + ); + assert_eq!( + lines[0].before_text(&old.files[&old.entry]), + Some("// 救援\r\n") + ); + assert_eq!( + lines[1].after_text(&new.files[&new.entry]), + Some("@long @long\n") + ); + assert_eq!( + lines[2].after_text(&new.files[&new.entry]), + Some("context\r@long") + ); + assert!(lines[1].changed() && lines[2].changed()); + assert!(!lines[0].changed()); + assert_eq!( + preview.display_bytes(), + old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len() + ); + } + + #[test] + fn first_and_last_lines_form_complete_separate_hunks_and_pages() { + let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na"); + let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong"); + let preview = Preview::build(&old, &new).unwrap(); + let hunks = preview.files()[0].hunks(); + assert_eq!(hunks.len(), 2); + let page_numbers: Vec<_> = hunks + .iter() + .flat_map(|h| h.lines().chunks(2)) + .flat_map(|page| page.iter().map(Line::number)) + .collect(); + assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]); + assert_eq!( + hunks[1].lines()[3].after_text(&new.files[&new.entry]), + Some("long") + ); + } + + #[test] + fn nearby_changes_merge_and_all_changed_files_are_reachable() { + let mut old = bundle("a\n1\n2\n3\n4\n5\na\n"); + let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n"); + old.files.insert("other.arcadia".into(), "a a a".into()); + new.files.insert("other.arcadia".into(), "b b b".into()); + let preview = Preview::build(&old, &new).unwrap(); + assert_eq!(preview.changed_line_count(), 3); + assert_eq!(preview.files().len(), 2); + assert_eq!(preview.files()[0].path(), "other.arcadia"); + assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1); + assert_eq!(preview.files()[1].hunks().len(), 1); + assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7); + } + + #[test] + fn unchanged_and_empty_sources_have_no_preview() { + for source in ["", "a\r\nb\n", "a"] { + let original = bundle(source); + let preview = Preview::build(&original, &original).unwrap(); + assert!(preview.is_empty()); + assert_eq!(preview.changed_line_count(), 0); + assert_eq!(preview.display_bytes(), 0); + } + } + + #[test] + fn refuses_membership_entry_line_count_and_line_ending_changes() { + let old = bundle("a\nb"); + let mut new = old.clone(); + new.files.insert("extra.arcadia".into(), "".into()); + assert_eq!( + Preview::build(&old, &new).unwrap_err(), + PreviewError::MembershipChanged + ); + let mut new = old.clone(); + new.entry = "missing.arcadia".into(); + assert_eq!( + Preview::build(&old, &new).unwrap_err(), + PreviewError::MembershipChanged + ); + assert!(matches!( + Preview::build(&old, &bundle("a\nb\nc")), + Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_)) + )); + assert!(matches!( + Preview::build(&bundle("a\nb\n"), &bundle("a\n")), + Err(PreviewError::LineCountChanged(_)) + )); + for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] { + assert_eq!( + Preview::build(&bundle(a), &bundle(b)).unwrap_err(), + PreviewError::LineEndingChanged { + path: old.entry.clone(), + line: 1 + } + ); + } + } + + #[test] + fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() { + let old = bundle("a\ncontext\na"); + let new = bundle("bbb\ncontext\nbbb"); + let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len(); + let limits = Limits { + files: 1, + changed_lines: 2, + display_bytes: exact, + }; + assert!(Preview::with_limits(&old, &new, limits).is_ok()); + assert_eq!( + Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(), + PreviewError::TooManyFiles { limit: 0 } + ); + assert_eq!( + Preview::with_limits( + &old, + &new, + Limits { + changed_lines: 1, + ..limits + } + ) + .unwrap_err(), + PreviewError::TooManyChangedLines { limit: 1 } + ); + assert_eq!( + Preview::with_limits( + &old, + &new, + Limits { + display_bytes: exact - 1, + ..limits + } + ) + .unwrap_err(), + PreviewError::TooManyDisplayBytes { limit: exact - 1 } + ); + let long = bundle(&"a".repeat(LIMITS.display_bytes)); + assert!(matches!( + Preview::build(&bundle("b"), &long), + Err(PreviewError::TooManyDisplayBytes { .. }) + )); + } + + #[test] + fn limits_apply_to_the_whole_preview_not_each_file() { + let mut old = bundle("a"); + let mut new = bundle("b"); + old.files.insert("second.arcadia".into(), "a".into()); + new.files.insert("second.arcadia".into(), "b".into()); + let limits = Limits { + files: 2, + changed_lines: 2, + display_bytes: old.entry.len() + "second.arcadia".len() + 4, + }; + let complete = Preview::with_limits(&old, &new, limits).unwrap(); + assert_eq!(complete.files().len(), 2); + assert_eq!(complete.changed_line_count(), 2); + assert_eq!(complete.display_bytes(), limits.display_bytes); + assert!(matches!( + Preview::with_limits(&old, &new, Limits { files: 1, ..limits }), + Err(PreviewError::TooManyFiles { limit: 1 }) + )); + assert!(matches!( + Preview::with_limits( + &old, + &new, + Limits { + changed_lines: 1, + ..limits + } + ), + Err(PreviewError::TooManyChangedLines { limit: 1 }) + )); + assert!(matches!( + Preview::with_limits( + &old, + &new, + Limits { + display_bytes: limits.display_bytes - 1, + ..limits + } + ), + Err(PreviewError::TooManyDisplayBytes { .. }) + )); + } +} diff --git a/crates/oa-desktop/src/source_workspace.rs b/crates/oa-desktop/src/source_workspace.rs index 266cacc8..7e267999 100644 --- a/crates/oa-desktop/src/source_workspace.rs +++ b/crates/oa-desktop/src/source_workspace.rs @@ -1,7 +1,7 @@ //! Per-file drafts and navigation. Accepted model/source history lives in Document. use iced::widget::text_editor::Cursor; use oa_core::{ - language, + Id, language, source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic}, }; use std::collections::{BTreeMap, BTreeSet}; @@ -86,6 +86,7 @@ pub struct Buffer { pub cursor: Cursor, past: Vec, future: Vec, + generation: u64, } impl Buffer { @@ -99,14 +100,20 @@ impl Buffer { }, past: Vec::new(), future: Vec::new(), + generation: 0, } } pub fn dirty(&self) -> bool { self.text != self.baseline } + pub fn generation(&self) -> u64 { + self.generation + } } pub struct Workspace { + session: Id, + revision: u64, pub entry: String, pub active: String, pub files: BTreeMap, @@ -117,6 +124,35 @@ pub struct Workspace { } impl Workspace { + /// Ephemeral editor identity, never a model identity or recovery payload. + pub fn session(&self) -> Id { + self.session + } + pub fn revision(&self) -> u64 { + self.revision + } + fn advance(&mut self) -> u64 { + if let Some(revision) = self.revision.checked_add(1) { + self.revision = revision; + } else { + self.reset_navigation_session(); + } + self.revision + } + /// A changed filesystem root must not reuse queued navigation from Save As. + pub fn reset_navigation_session(&mut self) { + self.session = Id::new_v4(); + self.revision = 0; + for buffer in self.files.values_mut() { + buffer.generation = 0; + } + } + pub fn set_reachable(&mut self, reachable: BTreeSet) { + if self.reachable != reachable { + self.advance(); + self.reachable = reachable; + } + } pub fn recovery_snapshot(&self) -> RecoveryWorkspace { RecoveryWorkspace { entry: self.entry.clone(), @@ -136,6 +172,8 @@ impl Workspace { pub fn from_recovery(saved: RecoveryWorkspace) -> Result { saved.validate()?; Ok(Self { + session: Id::new_v4(), + revision: 0, entry: saved.entry, active: saved.active, files: saved @@ -154,6 +192,8 @@ impl Workspace { } pub fn new(bundle: &SourceBundle, applied: bool) -> Self { Self { + session: Id::new_v4(), + revision: 0, entry: bundle.entry.clone(), active: bundle.entry.clone(), files: bundle @@ -191,11 +231,20 @@ impl Workspace { } } pub fn edit(&mut self, text: String, cursor: Cursor) { + let changed = self.files.get(&self.active).is_none_or(|b| b.text != text); + let generation = if changed { + self.advance() + } else { + self.revision + }; let buffer = self .files .entry(self.active.clone()) .or_insert_with(|| Buffer::new(String::new())); buffer.cursor = cursor; + if changed { + buffer.generation = generation; + } if text != buffer.text { buffer.past.push(std::mem::replace(&mut buffer.text, text)); if buffer.past.len() > 100 { @@ -268,6 +317,8 @@ impl Workspace { } else { buffer.future.push(previous); } + let generation = self.advance(); + self.files.get_mut(&self.active).unwrap().generation = generation; self.trim_history(DRAFT_HISTORY_BYTES); true } @@ -281,6 +332,7 @@ impl Workspace { } } pub fn accept(&mut self, bundle: &SourceBundle) { + let generation = self.advance(); if self.entry != bundle.entry && !bundle.files.contains_key(&self.entry) && self.files.get(&self.entry).is_some_and(|b| !b.dirty()) @@ -291,7 +343,13 @@ impl Workspace { self.reachable = bundle.files.keys().cloned().collect(); for (path, text) in &bundle.files { let cursor = self.files.get(path).map(|b| b.cursor); + let previous_generation = self + .files + .get(path) + .filter(|b| b.text == *text) + .map(|b| b.generation); let mut buffer = Buffer::new(text.clone()); + buffer.generation = previous_generation.unwrap_or(generation); if let Some(cursor) = cursor { buffer.cursor = cursor; } @@ -303,10 +361,22 @@ impl Workspace { self.applied = true; } pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet) { + let changed = self.reachable != reachable + || bundle + .files + .keys() + .any(|path| !self.files.contains_key(path)); + let generation = if changed { + self.advance() + } else { + self.revision + }; for (path, text) in &bundle.files { - self.files - .entry(path.clone()) - .or_insert_with(|| Buffer::new(text.clone())); + self.files.entry(path.clone()).or_insert_with(|| { + let mut buffer = Buffer::new(text.clone()); + buffer.generation = generation; + buffer + }); } self.reachable = reachable; } @@ -314,6 +384,7 @@ impl Workspace { if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) { return false; } + self.advance(); self.files.remove(path); if self.active == path { self.active = self.entry.clone(); @@ -376,6 +447,148 @@ pub fn lint(bundle: &SourceBundle) -> Vec { mod tests { use super::*; use oa_core::Project; + + #[test] + fn fresh_session_cannot_reuse_a_retained_buffer_generation() { + let bundle = SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([ + ("main.arcadia".into(), "first".into()), + ("other.arcadia".into(), "other".into()), + ]), + }; + let mut workspace = Workspace::new(&bundle, true); + let cursor = workspace.files["main.arcadia"].cursor; + workspace.edit("second".into(), cursor); + workspace.reset_navigation_session(); + let before = workspace.files["main.arcadia"].generation(); + workspace.edit("third".into(), cursor); + assert_ne!(workspace.files["main.arcadia"].generation(), before); + workspace.switch("other.arcadia", cursor); + workspace.revision = u64::MAX; + workspace.edit("changed other".into(), cursor); + let after_wrap = workspace.files["main.arcadia"].generation(); + workspace.switch("main.arcadia", cursor); + workspace.edit("fourth".into(), cursor); + assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap); + assert!(workspace.undo(false)); + assert_eq!(workspace.text(), "third"); + } + + #[test] + fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), "entry\r\n".into()), + ("part.arcadia".into(), "雨 draft\r\n".into()), + ]), + }; + let mut workspace = Workspace::new(&bundle, true); + let reopened = Workspace::new(&bundle, true); + let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap(); + assert_ne!(workspace.session(), reopened.session()); + assert_ne!(workspace.session(), recovered.session()); + assert_eq!(workspace.bundle(), recovered.bundle()); + let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap(); + assert!(saved.get("session").is_none() && saved.get("revision").is_none()); + + let cursor = Cursor { + position: iced::widget::text_editor::Position { line: 0, column: 3 }, + selection: None, + }; + let revision = workspace.revision(); + workspace.edit(workspace.text().into(), cursor); + assert_eq!( + workspace.revision(), + revision, + "selection is not a text change" + ); + workspace.switch("part.arcadia", cursor); + assert_eq!( + workspace.revision(), + revision, + "switching retains the snapshot" + ); + let untouched = workspace.files["project.arcadia"].generation(); + workspace.edit("雨 unfinished !\r\n".into(), cursor); + let edited = workspace.files["part.arcadia"].generation(); + assert!(workspace.revision() > revision); + assert_eq!(workspace.files["project.arcadia"].generation(), untouched); + assert!(workspace.undo(false)); + assert_eq!(workspace.bundle(), bundle); + assert!(workspace.files["part.arcadia"].generation() > edited); + assert!(workspace.undo(true)); + assert_eq!(workspace.text(), "雨 unfinished !\r\n"); + let previous_session = workspace.session(); + let previous_text = workspace.bundle(); + workspace.reset_navigation_session(); + assert_ne!(workspace.session(), previous_session); + assert_eq!(workspace.bundle(), previous_text); + assert!( + workspace.undo(false), + "session reset preserves text history" + ); + } + + #[test] + fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), "entry".into()), + ("part.arcadia".into(), "part".into()), + ]), + }; + let mut workspace = Workspace::new(&bundle, true); + let entry_generation = workspace.files[&bundle.entry].generation(); + let initial_revision = workspace.revision(); + workspace.set_reachable(BTreeSet::from([bundle.entry.clone()])); + assert!(workspace.revision() > initial_revision); + let membership_revision = workspace.revision(); + workspace.set_reachable(workspace.reachable.clone()); + assert_eq!(workspace.revision(), membership_revision); + assert!(workspace.discard_detached("part.arcadia")); + assert!(workspace.revision() > membership_revision); + let closed_revision = workspace.revision(); + workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()])); + assert!(workspace.revision() > closed_revision); + assert_eq!( + workspace.files[&bundle.entry].generation(), + entry_generation + ); + assert!(workspace.files["part.arcadia"].generation() > closed_revision); + let discovered_revision = workspace.revision(); + workspace.discovered(&bundle, workspace.reachable.clone()); + assert_eq!(workspace.revision(), discovered_revision); + workspace.accept(&bundle); + assert!( + workspace.revision() > discovered_revision, + "validation evidence changed" + ); + assert_eq!( + workspace.files[&bundle.entry].generation(), + entry_generation + ); + assert_eq!(workspace.bundle(), bundle); + } + + #[test] + fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), "original".into())]), + }; + let mut workspace = Workspace::new(&bundle, true); + workspace.revision = u64::MAX; + let before = workspace.session(); + let cursor = workspace.files[&bundle.entry].cursor; + workspace.edit("changed".into(), cursor); + assert_ne!(workspace.session(), before); + assert_eq!(workspace.revision(), 0); + assert!(workspace.undo(false)); + assert_eq!(workspace.bundle(), bundle); + } #[test] fn draft_history_budget_covers_inactive_files_without_discarding_drafts() { let bundle = SourceBundle { diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index fe0476fd..8dabb812 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -377,7 +377,8 @@ fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() { send(&mut app, Message::SourceForSelection); assert_eq!(app.page, Page::Text); assert!(app.source_jump.is_some()); - send(&mut app, Message::SourceViewport); + let ticket = app.source_reveal_ticket(); + send(&mut app, Message::SourceViewport(ticket)); assert!(app.source_jump.is_none()); send(&mut app, Message::SourceForSelection); assert!(app.source_jump.is_some()); @@ -409,7 +410,8 @@ fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() { cursor.selection, Some(text_editor::Position { line: 0, column: 9 }) ); - send(&mut app, Message::SourceViewport); + let ticket = app.source_reveal_ticket(); + send(&mut app, Message::SourceViewport(ticket)); assert_eq!(app.source_editor.cursor(), cursor); send( &mut app, @@ -1269,7 +1271,26 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner() .get_mut(&ancestor) .unwrap() .collapsed = true; - assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram)); + // The usage query reports rendered objects; this port's owner is folded. + // The legacy authoring reveal below explicitly expands its saved ancestor. + assert!( + !oa_core::projection::project_view(&p, diagram) + .ports + .iter() + .any(|shown| shown.element == port) + ); + assert!(crate::diagram_usage::saved_usages_for(&p, port, Some(diagram)).is_empty()); + // Other saved views of this function may still paint the port. + let elsewhere = crate::diagram_usage::saved_usages(&p, port); + assert!( + !elsewhere.is_empty(), + "the demo still paints this port in another saved view" + ); + assert!( + elsewhere + .iter() + .all(|usage| usage.surface != crate::diagram_usage::Surface::Saved(diagram)) + ); let mut app = App::with_project(p); send(&mut app, Message::RevealInDiagram(diagram, port)); assert_eq!(app.selected, port); @@ -1281,6 +1302,12 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner() .iter() .any(|p| p.element == port) ); + assert!( + crate::diagram_usage::saved_usages_for(app.project(), port, Some(diagram)) + .iter() + .any(|usage| matches!(usage.target, + crate::diagram_usage::Target::Port { port: found, .. } if found == port)) + ); } #[test] diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index ddc1c2a5..9195b8cc 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -1,3 +1,4 @@ +mod source_symbols; pub(crate) mod traceability; use crate::{ app::{App, Choice, FileOperation, Message, Page, Pending}, @@ -12,6 +13,7 @@ use iced::{ }, }; use oa_core::*; +use unicode_segmentation::UnicodeSegmentation; pub fn label<'a>(s: impl Into) -> widget::Text<'a> { text(s.into()).size(11).color(style::MUTED) @@ -24,10 +26,12 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> { } pub fn short(s: &str, limit: usize) -> String { let s = s.split_whitespace().collect::>().join(" "); - if s.chars().count() > limit { + if s.graphemes(true).count() > limit { format!( "{}…", - s.chars().take(limit.saturating_sub(1)).collect::() + s.graphemes(true) + .take(limit.saturating_sub(1)) + .collect::() ) } else { s @@ -350,6 +354,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> { @@ -448,6 +459,9 @@ fn diagram(app: &App) -> View<'_, Message> { view_index: Some(app.document.view_index()), diagram: id, selected: app.selected, + navigation_focus: app + .diagram_focus(crate::diagram_usage::Surface::Saved(id)) + .map(|usage| usage.bounds), zoom: app.zoom, pan: app.pan, context: app.route_context().unwrap(), @@ -477,6 +491,7 @@ fn diagram(app: &App) -> View<'_, Message> { if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) { scene = scene.push(editor); } + let reveal = app.architecture_reveal_stamp(id); row![ widget::sensor( container(scene) @@ -484,8 +499,15 @@ fn diagram(app: &App) -> View<'_, Message> { .width(Fill) .height(Fill) ) - .on_show(Message::CanvasResized) - .on_resize(Message::CanvasResized), + .on_show( + move |size| reveal.map_or(Message::CanvasResized(size), |(context, epoch)| { + Message::DiagramRevealViewport(context, epoch, size) + }) + ) + .on_resize(move |size| reveal + .map_or(Message::CanvasResized(size), |(context, epoch)| { + Message::DiagramRevealViewport(context, epoch, size) + })), crate::app::architecture_authoring::palette(app) ] .height(Fill) @@ -657,7 +679,7 @@ fn inspector(app: &App) -> View<'_, Message> { return container( column![ tabs, - scrollable(crate::navigation::view(p, app.selected)).height(Fill) + scrollable(crate::navigation::view(app, app.selected)).height(Fill) ] .spacing(14), ) @@ -1075,6 +1097,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![ @@ -1091,7 +1120,7 @@ fn source(app: &App) -> View<'_, Message> { ), action("Next", Message::SourceNavigation(SourceEvent::Next(false))), label(app.source_navigation.label()), - action("Close", Message::SourceNavigation(SourceEvent::Close)), + action("Close", app.source_find_close_message()), ] .spacing(6) .align_y(iced::Center), @@ -1185,9 +1214,14 @@ fn source(app: &App) -> View<'_, Message> { } } let editor = widget::text_editor(&app.source_editor) - .id("source-editor") + .id(app.source_editor_id()) .style(style::source_editor) .on_action(Message::SourceEdit) + .key_binding({ + let context = app.source_symbol_context(); + let rename = app.source_rename_begin(); + move |press| source_binding(press, &context, Some(&rename)) + }) .font(Font::MONOSPACE) .size(14) .padding(16) @@ -1196,8 +1230,7 @@ fn source(app: &App) -> View<'_, Message> { app.source_snapshot.clone(), crate::highlight::format, ); - column![ - container( + let header = container( column![ row![ container(heading( @@ -1215,8 +1248,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))), @@ -1240,17 +1273,512 @@ fn source(app: &App) -> View<'_, Message> { "Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)." }), navigation, + 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))), + ].spacing(7), scrollable(diagnostics) .height((visible_diagnostics as f32 * 32.).min(132.)) ] .spacing(12) ) - .padding(18), - widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump)) - .on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport) + .padding(18); + let editor = widget::sensor(editor) + .key(( + app.source_workspace.active.clone(), + app.source_reveal_ticket(), + )) + .on_show({ + let ticket = app.source_reveal_ticket(); + move |_| Message::SourceViewport(ticket) + }) + .on_resize({ + let ticket = app.source_reveal_ticket(); + move |_| Message::SourceViewport(ticket) + }); + let workspace: View<'_, Message> = if app.source_symbols.open { + row![ + container(editor).width(Fill).height(Fill), + container(scrollable(source_symbols::panel(app))) + .width(300) + .height(Fill), + ] + .height(Fill) + .into() + } else { + container(editor).width(Fill).height(Fill).into() + }; + source_layout(header.into(), workspace, app.window_size.height) +} + +fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>( + header: View<'a, Message, iced::Theme, Renderer>, + workspace: View<'a, Message, iced::Theme, Renderer>, + window_height: f32, +) -> View<'a, Message, iced::Theme, Renderer> { + // Lint clears and repopulates diagnostics asynchronously. Reserve the same + // outer slot so a pointer aimed at the editor cannot land in the header. + column![ + container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)), + workspace, ] + .height(Fill) .into() } +pub(crate) fn source_binding( + press: widget::text_editor::KeyPress, + context: &crate::source_navigation::SymbolContext, + rename: Option<&crate::app::source_rename::Begin>, +) -> Option> { + use crate::source_navigation::{SymbolEvent, query::Action}; + use iced::keyboard::{Key, key::Named}; + use widget::text_editor::{Binding, Status}; + if matches!(press.status, Status::Focused { .. }) + && press.modifiers.is_empty() + && press.key == Key::Named(Named::F2) + && let Some(begin) = rename + { + return Some(Binding::Custom(Message::SourceRename( + crate::app::source_rename::Event::Begin(begin.clone()), + ))); + } + // Windows owns Alt+Space for its window menu. Iced's default binding would + // otherwise insert a supplied space payload, even after that menu closes. + // Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged. + #[cfg(target_os = "windows")] + if matches!(press.status, Status::Focused { .. }) + && press.modifiers == iced::keyboard::Modifiers::ALT + && matches!( + press.key.as_ref(), + Key::Named(Named::Space) | Key::Character(" ") + ) + { + return None; + } + if matches!(press.status, Status::Focused { .. }) + && !press.modifiers.control() + && !press.modifiers.logo() + { + let event = match &press.key { + Key::Named(Named::F12) if !press.modifiers.alt() => { + Some(SymbolEvent::Query(if press.modifiers.shift() { + Action::References + } else { + Action::Definition + })) + } + Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => { + Some(SymbolEvent::Back) + } + _ => None, + }; + if let Some(event) = event { + return Some(Binding::Custom(Message::SourceSymbols( + context.clone(), + event, + ))); + } + } + Binding::from_key_press(press) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) mod source_tests { + use super::*; + use iced::advanced::{ + Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget, + }; + use iced::widget::column; + use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector}; + + // Measure real Iced paragraphs/editor text without constructing a window or GPU. + pub(crate) struct FontLayout; + impl renderer::Renderer for FontLayout { + 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) {} + fn fill_quad(&mut self, _: renderer::Quad, _: impl Into) {} + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce( + Result, + ) + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut (), handle, callback); + } + } + impl shaped::Renderer for FontLayout { + type Font = Font; + type Paragraph = graphics::text::Paragraph; + type Editor = graphics::text::Editor; + const ICON_FONT: Font = Font::DEFAULT; + const CHECKMARK_ICON: char = '0'; + const ARROW_DOWN_ICON: char = '0'; + const SCROLL_UP_ICON: char = '0'; + const SCROLL_DOWN_ICON: char = '0'; + const SCROLL_LEFT_ICON: char = '0'; + const SCROLL_RIGHT_ICON: char = '0'; + const ICED_LOGO: char = '0'; + fn default_font(&self) -> Font { + Font::DEFAULT + } + fn default_size(&self) -> Pixels { + Pixels(16.) + } + fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {} + fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {} + fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} + } + + #[derive(Default)] + struct Geometry { + header: Option<(Rectangle, Rectangle, Vector)>, + editor: Option, + } + impl core_widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) { + visit(self); + } + fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) { + if id == Some(&core_widget::Id::new("test-source-editor")) { + self.editor = Some(bounds); + } + } + fn scrollable( + &mut self, + id: Option<&core_widget::Id>, + bounds: Rectangle, + content: Rectangle, + offset: Vector, + _: &mut dyn core_widget::operation::Scrollable, + ) { + if id == Some(&core_widget::Id::new("source-header")) { + self.header = Some((bounds, content, offset)); + } + } + } + + fn mounted_source<'a>( + content: &'a widget::text_editor::Content, + diagnostics: usize, + find: bool, + window_height: f32, + ) -> View<'a, Message, iced::Theme, FontLayout> { + let mut notices = column![].spacing(5); + for index in 0..diagnostics { + notices = notices.push(button(text(format!( + "救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1, + )).size(12)).padding([4, 6]).width(Fill)); + } + let find: View<'_, Message, iced::Theme, FontLayout> = if find { + column![ + text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation( + crate::source_navigation::Event::Query(s) + )), + text_input("Line", "1").on_input(|s| Message::SourceNavigation( + crate::source_navigation::Event::Line(s) + )), + ] + .spacing(8) + .into() + } else { + Space::new().into() + }; + let header = container( + column![ + text("Textual model").size(24), + text("Edit the current file. Apply text validates the complete included model.") + .size(11), + row![ + button(text("Apply text")), + button(text("Undo text")), + button(text("Model Undo")) + ] + .spacing(8), + text("救援/assess.arcadia · All files applied to the accepted model").size(11), + text("Undo text affects this draft. Model Undo restores accepted files together.") + .size(11), + find, + row![ + button(text("Definition · F12")), + button(text("References · Shift F12")), + button(text("Back")) + ] + .spacing(7), + scrollable(notices).height((diagnostics as f32 * 32.).min(132.)), + ] + .spacing(12), + ) + .padding(18); + let editor = container( + widget::text_editor(content) + .on_action(Message::SourceEdit) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(Fill), + ) + .id("test-source-editor") + .height(Fill) + .width(Fill); + source_layout(header.into(), editor.into(), window_height) + } + + #[test] + fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() { + for (host, window_height) in [ + (Size::new(720., 540.), 700.), + (Size::new(1180., 890.), 1080.), + ] { + let content = widget::text_editor::Content::::with_text( + "// is excluded\r\n// 雨\r\n", + ); + let mut tree = core_widget::Tree::empty(); + let mut baseline = None; + for (diagnostics, find) in [ + (0, false), + (6, false), + (0, false), + (6, true), + (1, true), + (0, false), + ] { + let mut ui = mounted_source(&content, diagnostics, find, window_height); + tree.diff(ui.as_widget()); + let node = ui.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, host), + ); + let mut geometry = Geometry::default(); + ui.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &FontLayout, + &mut geometry, + ); + let editor = geometry.editor.unwrap(); + let (header, _, _) = geometry.header.unwrap(); + let slot = node.children()[0].bounds(); + assert_eq!(slot.height, (window_height * 0.42).max(180.)); + assert!( + header.height <= slot.height, + "short content may shrink inside the fixed slot" + ); + assert!( + editor.height >= 240., + "remaining editor must stay usable: {editor:?}" + ); + assert_eq!(editor.y, slot.y + slot.height); + if let Some(expected) = baseline { + assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}"); + } else { + baseline = Some(editor); + } + } + } + } + + #[test] + fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() { + let content = widget::text_editor::Content::::with_text("// 雨\r\n"); + let host = Size::new(720., 540.); + let mut tree = core_widget::Tree::empty(); + let mut saved = None; + for diagnostics in [6, 7] { + let mut ui = mounted_source(&content, diagnostics, true, 700.); + tree.diff(ui.as_widget()); + let node = ui.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, host), + ); + let mut geometry = Geometry::default(); + ui.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); + let (bounds, contents, _) = geometry.header.unwrap(); + assert!( + contents.height > bounds.height, + "fixture must actually overflow" + ); + if saved.is_none() { + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + ui.as_widget_mut().update( + &mut tree, + &iced::Event::Mouse(iced::mouse::Event::WheelScrolled { + delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. }, + }), + Layout::new(&node), + iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)), + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &Rectangle::with_size(host), + ); + assert!(shell.is_event_captured()); + } + let mut geometry = Geometry::default(); + ui.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); + let offset = geometry.header.unwrap().2; + assert!(offset.y > 0., "the actual mounted Scrollable must move"); + if let Some(before) = saved { + assert_eq!(offset, before); + } else { + saved = Some(offset); + } + } + } + + #[cfg(target_os = "windows")] + #[test] + fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() { + use core_widget::operation::Focusable; + use iced::keyboard::{ + self, Key, Modifiers, + key::{Code, Named, Physical}, + }; + use widget::text_editor::{Binding, Content, KeyPress, State, Status}; + let baseline = "// is excluded\r\n// 雨\r\n"; + let context = crate::source_navigation::SymbolContext { + session: Id::new_v4(), + path: "救援/assess.arcadia".into(), + }; + for (key, physical_key, modifiers, payload, inserted) in [ + ( + Key::Named(Named::Space), + Physical::Code(Code::Space), + Modifiers::ALT, + " ", + false, + ), + ( + Key::Character(" ".into()), + Physical::Code(Code::Space), + Modifiers::ALT, + " ", + false, + ), + ( + Key::Named(Named::Space), + Physical::Code(Code::Space), + Modifiers::empty(), + " ", + true, + ), + ( + Key::Named(Named::Space), + Physical::Code(Code::Space), + Modifiers::CTRL | Modifiers::ALT, + " ", + true, + ), + ( + Key::Character("e".into()), + Physical::Code(Code::KeyE), + Modifiers::CTRL | Modifiers::ALT, + "€", + true, + ), + ( + Key::Named(Named::Space), + Physical::Code(Code::Space), + Modifiers::ALT | Modifiers::SHIFT, + " ", + true, + ), + ] { + let mut content = Content::::with_text(baseline); + content.move_to(widget::text_editor::Cursor { + position: widget::text_editor::Position { line: 0, column: 5 }, + selection: None, + }); + let press = KeyPress { + key: key.clone(), + modified_key: key.clone(), + physical_key, + modifiers, + text: Some(payload.into()), + status: Status::Focused { is_hovered: false }, + }; + assert!( + matches!( + Binding::::from_key_press(press), + Some(Binding::Insert(_)) + ), + "the supplied event must expose the default insertion path" + ); + let mut messages = Vec::new(); + { + let mut editor: View<'_, Message, iced::Theme, FontLayout> = + widget::text_editor(&content) + .on_action(Message::SourceEdit) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(Fill) + .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.); + let node = editor.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, size), + ); + tree.state + .downcast_mut::>() + .focus(); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: key, + physical_key, + location: keyboard::Location::Standard, + modifiers, + text: Some(payload.into()), + repeat: false, + }); + let mut shell = Shell::new(&mut messages); + editor.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &Rectangle::with_size(size), + ); + } + assert_eq!( + messages.len(), + usize::from(inserted), + "modifiers={modifiers:?}" + ); + for message in messages { + let Message::SourceEdit(action) = message else { + panic!("unexpected navigation action"); + }; + content.perform(action); + } + let expected = if inserted { + format!("// is{payload} excluded\r\n// 雨\r\n") + } else { + baseline.into() + }; + assert_eq!(content.text(), expected); + } + } +} fn checks(app: &App) -> View<'_, Message> { let issues = &app.model_issues; let mut out = column![heading( @@ -1363,3 +1891,57 @@ fn checks(app: &App) -> View<'_, Message> { } scrollable(out.padding(24)).into() } + +#[cfg(test)] +mod tests { + use super::short; + + #[test] + fn short_preserves_native_lifeline_clusters_at_cutoff() { + let oa = "abcdefghijklmnope\u{0301}尾"; + assert_eq!(short(oa, 17), "abcdefghijklmnop…"); + assert_eq!(short(oa, 18), oa); + assert_eq!(short(oa, 19), oa); + + let sa = "abcdefghijklmnop👩\u{200d}🔬 tail"; + assert_eq!(short(sa, 17), "abcdefghijklmnop…"); + assert_eq!(short(sa, 18), "abcdefghijklmnop👩\u{200d}🔬…"); + assert_eq!(short(sa, 19), "abcdefghijklmnop👩\u{200d}🔬 …"); + assert_eq!(short(sa, 22), sa); + } + + #[test] + fn short_never_splits_retained_extended_graphemes() { + for cluster in ["e\u{0301}", "👩\u{200d}🔬", "👍🏽", "🇫🇷"] { + let value = format!("A{cluster}BC"); + assert_eq!(short(&value, 2), "A…"); + assert_eq!(short(&value, 3), format!("A{cluster}…")); + assert_eq!(short(&value, 4), value); + } + } + + #[test] + fn short_preserves_ascii_cjk_and_whitespace_policy() { + assert_eq!(short("abcd", 3), "ab…"); + assert_eq!(short("abcd", 4), "abcd"); + assert_eq!(short("abcd", 5), "abcd"); + assert_eq!(short("甲乙丙丁", 3), "甲乙…"); + assert_eq!(short("甲乙丙丁", 4), "甲乙丙丁"); + assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 5), "A e\u{0301} B"); + assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 4), "A e\u{0301}…"); + } + + #[test] + fn short_preserves_empty_and_small_limit_policy() { + for limit in [0, 1, 2] { + assert_eq!(short("", limit), ""); + assert_eq!(short(" \t\r\n", limit), ""); + } + assert_eq!(short("A", 0), "…"); + assert_eq!(short("A", 1), "A"); + assert_eq!(short("AB", 1), "…"); + assert_eq!(short("e\u{0301}", 0), "…"); + assert_eq!(short("e\u{0301}", 1), "e\u{0301}"); + assert_eq!(short("e\u{0301}B", 1), "…"); + } +} diff --git a/crates/oa-desktop/src/views/source_symbols.rs b/crates/oa-desktop/src/views/source_symbols.rs new file mode 100644 index 00000000..bdcbe734 --- /dev/null +++ b/crates/oa-desktop/src/views/source_symbols.rs @@ -0,0 +1,260 @@ +use crate::{ + app::{App, Message}, + source_navigation::{ + SymbolEvent, + query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position}, + }, + style, +}; +use iced::{ + Element, Fill, + widget::{Space, button, column, container, row, scrollable, text, tooltip}, +}; + +pub(super) fn panel(app: &App) -> Element<'_, Message> { + let state = &app.source_symbols; + if !state.open + || (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none()) + { + return column![].into(); + } + let title = state.results.as_ref().map_or_else( + || "Source symbols".to_owned(), + |results| { + let action = match results.action { + query::Action::Definition => "Definitions", + query::Action::References => "References", + }; + results.alias.as_ref().map_or_else( + || action.to_owned(), + |alias| format!("{action} · @{}", preview(alias, 40)), + ) + }, + ); + let mut content = column![ + row![ + text(title).size(13), + Space::new().width(Fill), + button(text("Close").size(11)) + .padding([3, 8]) + .style(style::nav) + .on_press(app.source_symbol_message(SymbolEvent::Close)), + ] + .align_y(iced::Center) + ] + .spacing(5); + + if let Some(notice) = &state.notice { + content = content.push(super::label(preview(notice, 240))); + } + if state.in_flight.is_some() { + content = content.push(super::label("Finding source symbols…")); + } else if let Some(results) = &state.results { + let stale = results.stamp.snapshot != app.source_key(); + if stale { + content = content.push(super::label( + "Stale results — source changed. Run Definition or References again.", + )); + } + let scope = &results.scope; + content = content.push(super::label(format!( + "{} · {} · {} included / {} detached / {} unverified files · {} scope issues", + if scope.partial() { + "Partial scope" + } else { + "Complete include scope" + }, + if !scope.applied { + "Unapplied drafts" + } else if scope.validated { + "Validated sources" + } else { + "Draft sources" + }, + scope.included_files, + scope.detached_files, + scope.unverified_files, + scope.issues.len(), + ))); + let explanation = match results.disposition { + Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.", + Disposition::NoDefinition => "No matching declaration in the loaded sources.", + Disposition::Choices => { + "Choose a declaration; draft and detached candidates need an explicit choice." + } + Disposition::Direct => "Definition in the current sources.", + Disposition::LexicalReferences => { + "Lexical uses of this alias; these do not establish a semantic target." + } + }; + content = content.push(super::label(explanation)); + if results.total_definitions > 1 { + content = content.push(super::label(format!( + "Alias collision: {} declarations. Uses are not attributed to a single declaration.", + results.total_definitions, + ))); + } + let enabled = !stale && !app.busy; + let mut rows = column![super::label(counts( + "Declarations", + results.definitions.len(), + results.total_definitions, + results.omitted_definitions, + ))] + .spacing(4); + for (index, definition) in results.definitions.iter().enumerate() { + rows = rows.push(location_row( + declaration_title(&definition.declaration), + &definition.path, + definition.position, + provenance( + definition.membership, + definition.evidence, + definition.declaration.identity, + false, + ), + enabled.then(|| { + app.source_symbol_message(SymbolEvent::Choose { + stamp: results.stamp, + reference: false, + index, + }) + }), + )); + } + rows = rows.push(super::label(counts( + "Lexical uses", + results.references.len(), + results.total_references, + results.omitted_references, + ))); + for (index, reference) in results.references.iter().enumerate() { + let title = reference.context.as_ref().map_or_else( + || format!("@{} · lexical use", preview(&reference.alias, 48)), + |context| { + format!( + "@{} · inside {}", + preview(&reference.alias, 48), + declaration_title(context) + ) + }, + ); + rows = rows.push(location_row( + title, + &reference.path, + reference.position, + provenance( + reference.membership, + reference.evidence, + reference + .context + .as_ref() + .map_or(Identity::Missing, |context| context.identity), + true, + ), + enabled.then(|| { + app.source_symbol_message(SymbolEvent::Choose { + stamp: results.stamp, + reference: true, + index, + }) + }), + )); + } + let row_count = results.definitions.len() + results.references.len(); + content = + content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0))); + } + container(content) + .padding(8) + .width(Fill) + .style(style::subtle) + .into() +} + +fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String { + format!("{label}: {total} total · {shown} shown · {omitted} omitted") +} + +fn declaration_title(declaration: &Declaration) -> String { + let name = declaration + .name + .as_ref() + .map_or_else(String::new, |name| format!(" \"{}\"", preview(name, 48))); + format!( + "{}{} · @{}", + preview(&declaration.kind, 24), + name, + preview(&declaration.alias, 48) + ) +} + +fn provenance( + membership: Membership, + evidence: Evidence, + identity: Identity, + context: bool, +) -> String { + let membership = match membership { + Membership::Included => "Included", + Membership::Detached => "Detached", + Membership::Unverified => "Unverified", + }; + let evidence = match evidence { + Evidence::Validated => "Validated", + Evidence::Draft => "Draft", + }; + let collision = match identity { + Identity::Collision { count } => format!( + " · {}ID collision ({count})", + if context { "Context " } else { "" }, + ), + Identity::Missing | Identity::Unique(_) => String::new(), + }; + format!("{membership} · {evidence}{collision}") +} + +fn location_row( + title: String, + path: &str, + position: Position, + status: String, + event: Option, +) -> Element<'static, Message> { + let line = position.line.saturating_add(1); + let column = position.byte_column.saturating_add(1); + let location = format!("{}:{line}:{column}", preview(path, 72)); + tooltip( + button( + column![ + text(title).size(12), + super::label(format!("{location} · {status}")) + ] + .spacing(2), + ) + .padding([4, 6]) + .width(Fill) + .style(style::nav) + .on_press_maybe(event), + text(format!( + "{path}:{line}:{column} (1-based line and UTF-8 byte column)" + )) + .size(11), + tooltip::Position::Top, + ) + .into() +} + +/// Keep headings compact without copying the remainder of a long admitted value. +fn preview(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let mut result: String = chars + .by_ref() + .take(limit) + .map(|ch| if ch.is_whitespace() { ' ' } else { ch }) + .collect(); + if chars.next().is_some() { + result.push('…'); + } + result +} diff --git a/docs/language.html b/docs/language.html index 4b7f5d7c..7e372eac 100644 --- a/docs/language.html +++ b/docs/language.html @@ -2633,6 +2633,85 @@ trace links, occurrence identity, comments and file ownership. Each open file retains its own unapplied draft across file navigation. Located lint diagnostics refer to the raw draft bytes rather than an identity-stabilized intermediate. Property editing MUST NOT silently discard a pending source draft.

+

In Textual model, place the caret on a declaration alias or an @reference +and choose Definition (F12) or References (Shift+F12). +Back (Alt+Left) returns to the previous source location in this +workspace. These shortcuts act while the source editor has focus; the Text +toolbar also exposes the actions. If the previous draft changed, Back restores +its current cursor rather than reusing an obsolete selection.

+

Symbol navigation reads bounded snapshots of the currently loaded source +drafts. It does not apply drafts or load missing included files. The results +identify partial scope, draft evidence, detached or unverified files, alias +ambiguity and authored-ID collisions. Draft, detached and ambiguous definition +candidates require an explicit row choice. References are lexical uses of an +alias, not a list of resolved typed relationships; comments and strings are +excluded. Declaration and use counts include any rows omitted by the result +limits. If sources change, stale rows cannot navigate: run the query again. +Navigation MUST preserve the accepted model and every source draft, and select +locations in the original UTF-8 bytes. Displayed locations use one-based lines +and UTF-8 byte columns.

+

Show in diagrams finds representations of a model record in saved diagrams, +process/chain canvases and scenarios. Use the Text toolbar with the caret on a +unique, verified alias in clean accepted sources, or the model tree's context +menu for the chosen record. Choose a destination, then its exact representation. +Lookup runs from an accepted-model snapshot; navigation preserves source drafts, +accepted geometry and history. Pending edits or modal operations must be +finished first. Changed or stale results cannot activate a different target.

+

Membership follows the actual projection, including visible ports, state action +and internal-transition rows, qualified reuse tiles, and SequenceLink arrows +painted inside caller previews. Repeated callers have distinct destinations +within the same canvas. Hidden preview endpoints and deeper nested links that +the renderer does not draw are excluded; projected adjacency without an actual +relation is not a SequenceLink usage. Preview-arrow focus lies on a visible +line segment outside opaque tiles.

+

An Allocation projection represents a direct Allocation relation by grouping +only when its visible source performer placement occurs on the visible target +work placement's actual visual-parent chain. Aggregate-only allocation summaries +do not create direct relation usages. The navigation marker can focus a work +header, port owner or state row independently of semantic selection: selecting +an Allocation or state-action relation retains that relation as the model action +target. Camera movement preserves a valid marker; changing the selected record, +document, accepted revision or represented context retires it. Navigation does +not alter allocation ownership or the meaning of Delete.

+

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

+

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

+

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

+

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

+

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

Changing a graphical UUID must atomically update map keys and every reference. Alias changes preserve UUID identity. Deletion must account for owned elements, incident relationships, ordered occurrences, and diagram placements without diff --git a/docs/language.rst b/docs/language.rst index 6bd48bd5..ad433733 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -2075,6 +2075,95 @@ retains its own unapplied draft across file navigation. Located lint diagnostics refer to the raw draft bytes rather than an identity-stabilized intermediate. Property editing MUST NOT silently discard a pending source draft. +In Textual model, place the caret on a declaration alias or an ``@reference`` +and choose **Definition** (``F12``) or **References** (``Shift+F12``). +**Back** (``Alt+Left``) returns to the previous source location in this +workspace. These shortcuts act while the source editor has focus; the Text +toolbar also exposes the actions. If the previous draft changed, Back restores +its current cursor rather than reusing an obsolete selection. + +Symbol navigation reads bounded snapshots of the currently loaded source +drafts. It does not apply drafts or load missing included files. The results +identify partial scope, draft evidence, detached or unverified files, alias +ambiguity and authored-ID collisions. Draft, detached and ambiguous definition +candidates require an explicit row choice. References are lexical uses of an +alias, not a list of resolved typed relationships; comments and strings are +excluded. Declaration and use counts include any rows omitted by the result +limits. If sources change, stale rows cannot navigate: run the query again. +Navigation MUST preserve the accepted model and every source draft, and select +locations in the original UTF-8 bytes. Displayed locations use one-based lines +and UTF-8 byte columns. + +**Show in diagrams** finds representations of a model record in saved diagrams, +process/chain canvases and scenarios. Use the Text toolbar with the caret on a +unique, verified alias in clean accepted sources, or the model tree's context +menu for the chosen record. Choose a destination, then its exact representation. +Lookup runs from an accepted-model snapshot; navigation preserves source drafts, +accepted geometry and history. Pending edits or modal operations must be +finished first. Changed or stale results cannot activate a different target. + +Membership follows the actual projection, including visible ports, state action +and internal-transition rows, qualified reuse tiles, and SequenceLink arrows +painted inside caller previews. Repeated callers have distinct destinations +within the same canvas. Hidden preview endpoints and deeper nested links that +the renderer does not draw are excluded; projected adjacency without an actual +relation is not a SequenceLink usage. Preview-arrow focus lies on a visible +line segment outside opaque tiles. + +An Allocation projection represents a direct Allocation relation by grouping +only when its visible source performer placement occurs on the visible target +work placement's actual visual-parent chain. Aggregate-only allocation summaries +do not create direct relation usages. The navigation marker can focus a work +header, port owner or state row independently of semantic selection: selecting +an Allocation or state-action relation retains that relation as the model action +target. Camera movement preserves a valid marker; changing the selected record, +document, accepted revision or represented context retires it. Navigation does +not alter allocation ownership or the meaning of Delete. + +**Rename alias** (``F2`` while the source editor has focus) changes the alias +under the caret or its exact selection. It requires a unique authored identity +in the complete accepted source bundle, with no pending source, property or +behavior drafts and no extra loaded detached files. Accepted but unsaved source +is eligible. The operation MUST NOT implicitly apply a draft or allocate an ID. +The engineering name and UUID remain unchanged. + +The **Source alias** field does not trim or normalize the entered alias. Pasting +line breaks, tabs or other control characters is explicitly rejected as a whole: +the field and its selection remain unchanged, and any previous preview approval +is invalidated. **Preview** +prepares a typed alias change and displays every changed file and source line; +comments, strings, engineering data, file membership and declaration ownership +remain unchanged. Enter in the field requests Preview, never confirmation. +The preview is for this operation against accepted source, so a later Save may +also contain earlier accepted edits. A changing rename requires enough model +history capacity to retain its immediate Undo. An invalid, identical, stale or +oversized candidate cannot enable confirmation. + +The preview MUST provide access to all admitted files, hunks, lines and long-line +segments. Refuse a candidate whose complete preview exceeds the editor's bounds: +256 changed files, 50,000 changed lines or 8 MiB of displayed source bytes +(both versions of changed/context lines and file paths). Index metadata is +separately bounded to seven retained line records per changed line. These +limits do not restrict the language itself. A changed-line count is not a count +of semantic references. Cancel, Preview and Rename alias remain reachable +outside the independently scrolling source preview. + +Explicit **Rename alias** confirms the current preview once, as one accepted +model/source transaction. A source, document, recovery or replacement change +invalidates its approval even if later bytes match the old preview. Closing the +form cancels approval; a late worker result MUST NOT reopen it. Rejected, +canceled and unchanged operations preserve accepted bytes, drafts, save state, +revisions, selection and both histories. Successful rename selects the new alias +at the original occurrence rather than the first matching spelling in the file. +Editing the replacement permits a new Preview in the same form. After a source +context change, close the form and invoke Rename again on the current alias. + +**Undo rename** is guarded against intervening edits and uses model history; +the persistent **Undo model** and **Redo model** controls restore the complete +accepted snapshots. Text ``Ctrl+Z`` remains **Undo text**. Save is separate and +uses the ordinary guarded source-save contract. An unconfirmed preview and Undo +history are not persisted on reopening. + Changing a graphical UUID must atomically update map keys and every reference. Alias changes preserve UUID identity. Deletion must account for owned elements, incident relationships, ordered occurrences, and diagram placements without diff --git a/harness/release-navigation-palette-union-20260913.md b/harness/release-navigation-palette-union-20260913.md new file mode 100644 index 00000000..6bd6f242 --- /dev/null +++ b/harness/release-navigation-palette-union-20260913.md @@ -0,0 +1,58 @@ +# Selective navigation/rename union with the root canvas and palette + +Prepared source only, Codex-956atf, root-owned issue 27. The integration worktree is `.local/navigation-palette-union-20260913`, branch `codex/navigation-palette-union-956atf-20260913`, based on `e44f07565cc7599d5c5a15a6b5599b4f5d105718`. No compiler, test, native UI, Gitea write or publication was performed for this candidate. Root's later test-only readonly correction is deliberately left for the integration owner. Peer issue 13 WIP is excluded. + +## Source provenance + +The actual common Git ancestor of root e44 and peer navigation/rename 208 is `c6d296a7e22c8f126834a1e217b88551634dd5cf`; this preparation therefore imports scoped production deltas instead of replacing the peer tree or merging competing historical records. + +1. `cb91525cfd9da7ac15ea1b21b962145c77bb9922`: desktop grapheme-aware shortening and direct `unicode-segmentation = "1.13.3"` dependency. The root lockfile already resolves that version; only oa-desktop's direct dependency list is extended. +2. `7aa007b0777aa182a99f46e90eeb9b2558f3ad2a..46b4f3c0d0f04050ad6d705051ee0199138e47a2`: production crate changes comprising bounded source navigation `fdf756e6a41b1879760e45c2c263bd671b562fe3` and the exact-identity/visual-focus correction `46b4f3c`. Core changes are the bounded tolerant outline and UUID-header-shape APIs, not an analysis schema or codec import. +3. `4db1ef543ced916179194272bf0e65dce73cb107..208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23`: the already-composed navigation/rename production crates and language RST/HTML paragraphs. This includes the mandatory `cbc49a7802fb1bba0dd284fd904c700a0703ae70` preview-scroll correction, mutual modal guards, exact worker slots, captured action envelopes and Details visit rotation; its raw rename commits are not applied twice. +4. `10bd71197285a4ba894e6ed9398d2c5402ab463d`: the exact peer regression correction from `expected.project` to `expected.decoded.project`. + +The peer's `harness/experiments/navigation-rename-union-20260912/README.md`, `native-acceptance.md`, import provenance and final source hashes were read at exact 208. They remain evidence for that source. This candidate does not inherit an execution pass from them. + +## Composition decisions + +The initial navigation patch had conflicts in `app.rs`, `behavior.rs`, `behavior/topology.rs` and `views.rs`; the rename documentation patch had insertion conflicts in `docs/language.{rst,html}`. They were resolved as follows: + +- Keep root allocation dispatch and boundary retirement alongside navigation's first-valid-viewport completion. Stale Rename and stale toolbar-owner envelopes reject before either navigation reconciliation or allocation retirement. Passive allocation `KeyboardBoundary`/window-unfocus notifications preserve the pending navigation destination; actual allocation actions still pass through navigation retirement. New passive worker completions and stamped viewport events are recognized by allocation's boundary adapter. +- Keep `keyboard_owner_enabled` separate from busy execution admission, root `focus_pending` and pan/Space keyboard guards, root diagram focus wrappers and the state picker's stable widget slot. A diagram chooser also suspends allocation input ownership. Peer-only panel Focus actions are not imported as an unrelated prerequisite; their two classification arms are omitted from `is_navigation_camera` because those variants do not exist in the root UI. +- Keep typed activations, logical versus clipped correlation, conservative branch termination, no resurrection, lifecycle clearances, palette and guide/backplate/activation/foreground paint order. The behavior renderer gains the label-bounds helper required by actual-Scene navigation, retaining precise-message → activation → label → broad hit priority. Navigation does not create duplicate targets for activation segments. +- Keep root stronger preview boundaries while adding the peer's exact selected preview outline. Keep state selected-row outlines, lifecycle glyph colors and all palette foundation/readonly fixes. +- Keep the architecture allocation sensor/container identity while attaching stamped navigation measurements. Retain explicit `style::source_editor` together with the peer's dynamic editor ID and key binding. Preserve stamped Delete confirmations and the allocation feedback layer. +- Retain all root numeric, allocation, removal, focus, typed activation and lifecycle modules. No user specification or existing evidence is replaced. The readonly test file remains byte-identical to base e44 for the parent's separate fix. + +### Correction to the preliminary compatibility audit + +The earlier source prediction that port/state-row navigation necessarily leaves an owner placement armed for Delete was incomplete: `reveal_diagram_usage` ends by calling root `refresh_draft`, which normalizes both authoring placement fields for those semantic targets. Peer tests already assert this normalization. **That predicted defect is not established and no selection rewrite was made.** New mounted navigation-to-removal tests protect this actual composition, including confirmation and whole-source/model history. + +The parent reviewed the main paint/focus/dispatch composition and identified an additional stale-toolbar seam. The resulting early Scope check and regression are included. This is a source review checkpoint, not runtime acceptance. + +## New regression membership, all unrun + +Three source/App tests under `app::source_rename::tests::navigation_union_tests::palette_union::`: + +- `allocation_keyboard_notification_preserves_navigation_and_first_measurement` +- `stale_rename_begin_cannot_retire_allocation_epoch_or_new_navigation` +- `captured_behavior_toolbar_cannot_retire_a_new_documents_navigation` + +They use the peer's actual four-file Unicode/CRLF source fixture, independently delivered lookup completions, captured intent and actual App update path. They check accepted snapshot identity, allocation epoch, exact navigation identity and the first valid viewport. No worker Tasks are polled by these source tests. + +Two mounted tests under `app::diagram_removal_tests::navigation_union::`: + +- `port_navigation_then_canvas_delete_preserves_owner_and_restores_exact_history` +- `state_row_navigation_then_canvas_delete_preserves_work_and_restores_exact_history` + +They complete the real usage query, mount the production App tree, focus the real canvas with its public widget operation, capture actual Delete and Shift-Delete intents, cancel once, confirm the exact semantic target, compare the result with the core Delete command, and Undo to the full original Project/SourceBundle. They neither seed private canvas focus nor fabricate a removal intent. + +The published navigation union's original six App tests and four retained-widget preview-scroll tests remain. The broader `navigation_union_tests::` prefix now contains **nine** tests because of the three nested additions; the new `palette_union::` filter alone selects three. The removal union filter selects two. Keep original coverage, source navigation/query/outline, alias-rename/document, diagram usage/reveal, root allocation/removal/focus, style and activation/lifecycle suites in the combined gates. + +## Performed checks and remaining acceptance + +Only source inspection, bounded formatting and whitespace checks are performed here. Rustfmt `1.9.0-stable (48a229ceae 2026-09-01)` was invoked with edition 2024 and `skip_children=true` on the 67 changed/new Rust paths, excluding the root readonly test. The first unqualified formatter attempt did not launch because the executable was absent from this shell PATH; its stale shell exit value is explicitly corrected in the ignored receipt. The second attempt uses the resolved toolchain from the parent's absolute `.local/toolchain/env.ps1`. Ignored import patches and formatting receipts are retained at `.local/navigation-import` in this worktree. + +No `cargo`, `rustc`, test binary or GUI was run. Compile/lint correctness, new test outcomes, timing and actual native usability remain open. The integration owner should run focused new filters first, retain existing grouped/full/strict gates and build the matching executable. The parent's e44 results do not validate this union. + +Native acceptance retains the peer's six-file `examples/source-project` oracle: rename the second `@assess_report` reference to `assess_report_union`, exactly six alias-token changes in two files, no disk change before Save, immediate editor selection/typing and text Undo, navigation without model history, complete model Undo/Redo, then Save/reopen equality. Also exercise actual overflowing preview/Details return, modal exclusivity, source syntax selection, canvas focus/Delete after navigation, exact port/state-row and repeated-placement targets, nested activation shoulders/lifecycle boundaries, and passive event timing. Use deterministic race tests for held operations; screenshots alone do not establish ordering. Linux remains deferred.