Publish in-progress navigation source and independent canvas reviews

This commit is contained in:
2026-09-12 06:58:08 -07:00
parent 7aa007b077
commit fdf756e6a4
48 changed files with 16281 additions and 125 deletions
+236 -26
View File
@@ -2,12 +2,15 @@ pub(crate) mod architecture_authoring;
#[cfg(test)]
mod architecture_authoring_tests;
mod checkpoints;
mod diagram_reveal;
#[cfg(test)]
mod diagram_selection_tests;
mod diagram_usages;
mod hosted;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
mod source_symbols;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
@@ -427,9 +430,18 @@ pub enum Message {
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
SourceDiagnostic(usize),
SourceRelated(SourceLocation),
SourceViewport,
SourceViewport(Option<Id>),
SourceSymbols(
crate::source_navigation::SymbolContext,
crate::source_navigation::SymbolEvent,
),
SourceSymbolsReady(crate::source_navigation::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<PathBuf>),
Saved(Result<Option<FileSnapshot>, SaveFailure>),
@@ -507,6 +519,16 @@ pub struct App {
pub source_jump: Option<text_editor::Cursor>,
source_select_line: bool,
pub source_navigation: crate::source_navigation::State,
source_find_instance: Id,
pub source_symbols: crate::source_navigation::SymbolState,
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
source_diagram_job: Option<Id>,
diagram_lookup_in_flight: Option<Id>,
pending_diagram_reveal: Option<(
crate::diagram_usage::Context,
crate::diagram_usage::Usage,
u64,
)>,
pub model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
source_version: u64,
@@ -615,6 +637,12 @@ 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(),
diagram_usages: None,
source_diagram_job: None,
diagram_lookup_in_flight: None,
pending_diagram_reveal: None,
model_issues,
migration: None,
source_version: 0,
@@ -668,6 +696,7 @@ impl App {
self.lint_requested = None;
}
fn show_source_file(&mut self, restore_cursor: bool) {
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();
@@ -682,6 +711,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<Id> {
self.active_diagram
@@ -935,6 +967,8 @@ 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 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();
@@ -946,6 +980,8 @@ 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.diagram_lookup_in_flight = diagram_lookup_in_flight;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
@@ -1046,22 +1082,70 @@ 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<Message> {
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)),
architecture_authoring::subscription(self),
// 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.architecture.is_none()),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
@@ -1108,6 +1192,17 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
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);
if let Message::BehaviorControl(intent) = message {
return self.behavior_control(intent);
}
@@ -1118,6 +1213,12 @@ impl App {
);
let before = self.camera_boundary();
let task = self.update_inner(message);
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 !camera && self.camera_boundary() != before {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
@@ -1207,6 +1308,75 @@ impl App {
)
}
fn update_inner(&mut self, message: Message) -> Task<Message> {
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::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;
}
@@ -1274,7 +1444,7 @@ impl App {
| Message::CanvasResized(_)
| Message::Lint(_)
| Message::Linted(_, _)
| Message::SourceViewport
| Message::SourceViewport(_)
| Message::Request(Pending::Close(_))
)
{
@@ -1320,7 +1490,7 @@ impl App {
| Message::Exported(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
| Message::SourceViewport(_)
)
{
return Task::none();
@@ -1358,7 +1528,7 @@ impl App {
| Message::CheckpointLoaded(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
| Message::SourceViewport(_)
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
@@ -1969,6 +2139,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();
@@ -1985,6 +2157,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;
}
@@ -3036,6 +3214,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);
@@ -3104,13 +3285,28 @@ 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::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 => {
@@ -3157,6 +3353,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 {
@@ -3165,13 +3362,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();
}
@@ -3182,8 +3388,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,
},
@@ -3266,6 +3471,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;
@@ -3866,7 +4074,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;
@@ -3918,11 +4126,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> {