Compose compact Text workspace with guarded root navigation

This commit is contained in:
2026-09-13 04:10:27 -07:00
parent feab2020d8
commit 74542ae2c1
20 changed files with 5482 additions and 738 deletions
+120 -48
View File
@@ -20,6 +20,7 @@ mod reference_authoring;
mod review_navigation_tests;
pub(crate) mod source_rename;
mod source_symbols;
pub(crate) mod text_workspace;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
@@ -440,8 +441,7 @@ pub enum Message {
IncludesLoaded(u64, Result<DiskProject, String>),
Lint(u64),
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
SourceDiagnostic(usize),
SourceRelated(SourceLocation),
TextWorkspace(text_workspace::Event),
SourceViewport(Option<Id>),
SourceSymbols(
crate::source_navigation::SymbolContext,
@@ -537,6 +537,7 @@ pub struct App {
pub source_navigation: crate::source_navigation::State,
source_find_instance: Id,
pub source_symbols: crate::source_navigation::SymbolState,
pub text_workspace: text_workspace::State,
pub source_rename: source_rename::State,
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
@@ -659,6 +660,7 @@ impl App {
source_navigation: crate::source_navigation::State::default(),
source_find_instance: Id::new_v4(),
source_symbols: crate::source_navigation::SymbolState::default(),
text_workspace: text_workspace::State::default(),
source_rename: source_rename::State::default(),
diagram_usages: None,
diagram_navigation_focus: None,
@@ -713,10 +715,35 @@ impl App {
fn sync_source(&mut self) {
self.sync_source_with_alias_editor(None);
}
fn publish_source_diagnostics(&mut self, diagnostics: Vec<SourceDiagnostic>) {
self.source_diagnostics = diagnostics;
self.text_workspace.publish_diagnostics(self.source_key());
}
/// A different source destination owns a new navigation/presentation session.
/// Only diagnostics proven current before this identity-only change may be
/// republished for it; stale source locations must not become fresh again.
fn reset_source_navigation_session(&mut self) {
let diagnostics_current = self
.text_workspace
.diagnostic_publication()
.is_some_and(|publication| publication.source() == self.source_key());
self.source_workspace.reset_navigation_session();
self.clear_source_presentation();
if diagnostics_current {
self.publish_source_diagnostics(self.source_diagnostics.clone());
}
}
fn clear_source_presentation(&mut self) {
let in_flight = self.source_symbols.in_flight;
self.source_symbols = crate::source_navigation::SymbolState::default();
self.source_symbols.in_flight = in_flight;
self.source_jump = None;
self.text_workspace.reset();
}
fn sync_source_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
self.source_workspace.accept(self.document.bundle());
self.show_source_file_with_alias_editor(false, hint);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
self.source_version += 1;
self.lint_requested = None;
}
@@ -857,7 +884,7 @@ impl App {
}
}
Err(issues) => {
self.source_diagnostics = issues;
self.publish_source_diagnostics(issues);
self.status = "Source contains errors. Your draft is preserved.".into();
self.page = Page::Text;
false
@@ -1013,6 +1040,7 @@ impl App {
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 menu_sequence = self.menu_sequence;
let guide = std::mem::take(&mut self.guide);
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
@@ -1027,6 +1055,7 @@ impl App {
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.menu_sequence = menu_sequence;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
@@ -1124,7 +1153,7 @@ impl App {
pub fn view(&self) -> Element<'_, Message> {
let base = crate::context_menu::Popup::new(
crate::views::view(self),
crate::model_menu::popup(self),
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
)
.into();
crate::source_navigation::interaction::observe(
@@ -1192,14 +1221,15 @@ impl App {
// 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.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()),
self.text_workspace.menu().is_none()
&& (self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.text_workspace.drawer().is_some()
|| self.source_navigation.open
|| self.source_rename.open())
&& self.architecture.is_none())),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
@@ -1249,6 +1279,11 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// Captured Text commands validate their origin before they can
// invalidate that same origin or pass into existing command handlers.
if let Message::TextWorkspace(event) = message {
return self.text_workspace_event(event);
}
// These handlers reject old owners/tokens without changing state. Apply
// the same admission before shared camera and allocation boundaries.
if !self.source_input_envelope(&message)
@@ -1411,6 +1446,7 @@ impl App {
}
message => message,
};
// 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.
@@ -1423,6 +1459,38 @@ impl App {
if let Message::DiagramUsagesReady(completion) = message {
return self.diagram_usages_ready(completion);
}
if self.text_workspace.menu().is_some() {
if matches!(
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
self.text_workspace.dismiss_menu();
self.source_interaction();
return Task::none();
}
// Global subscriptions can receive captured keyboard input.
// A Text menu owns those commands while passive work can finish.
if !matches!(
message,
Message::CheckpointWritten(..)
| Message::CheckpointLoaded(..)
| Message::CheckpointTick
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
| Message::Lint(_)
| Message::Linted(..)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(..)
| Message::SavePath(_)
| Message::SourceRecovered(_)
| Message::Exported(_)
) {
return Task::none();
}
}
if !matches!(
&message,
Message::CheckpointTick
@@ -2190,11 +2258,11 @@ impl App {
// Validate the originating canvas before advancing its input
// epoch. Actions are then generated with the refreshed context,
// while pointer history hidden behind the menu becomes stale.
let Some(opened) = crate::model_menu::Opened::new(
self,
request,
self.menu_sequence.wrapping_add(1),
) else {
let Some(token) = self.menu_sequence.checked_add(1) else {
self.status = "Reopen the application to start a new menu session.".into();
return Task::none();
};
let Some(opened) = crate::model_menu::Opened::new(self, request, token) else {
return Task::none();
};
self.connection = None;
@@ -2248,7 +2316,8 @@ impl App {
}
Message::Escape => {
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.source_symbols.open);
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
@@ -2264,15 +2333,11 @@ impl App {
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
}
if restore_source_focus && self.source_navigation.open {
// Dismiss the active Find surface first. Its underlying
// references remain available until a separate Escape.
self.source_navigation.open = false;
} else {
self.source_navigation.open = false;
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
self.source_navigation.open = false;
// Popup/modal priority was handled above. In the available Text
// workspace, one Escape dismisses Find before the drawer beneath it.
if !dismiss_find_only {
self.text_workspace.show_drawer(None);
}
if restore_source_focus {
self.restore_source_editor_focus();
@@ -3260,7 +3325,7 @@ impl App {
.edit(after.clone(), self.source_editor.cursor());
self.source_snapshot = Arc::from(after);
self.source_version += 1;
self.source_diagnostics.clear();
self.publish_source_diagnostics(Vec::new());
let version = self.source_version;
return Task::perform(
async move {
@@ -3325,8 +3390,13 @@ impl App {
}
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");
let diagnostics = self
.source_diagnostics
.iter()
.filter(|issue| issue.diagnostic.code != "pending_save")
.cloned()
.collect();
self.publish_source_diagnostics(diagnostics);
match receipt.disk {
Ok(disk) if !self.source_workspace.applied => {
self.open_source_project(disk)
@@ -3338,7 +3408,7 @@ impl App {
|| self.path.as_ref() == Some(&receipt.target)
{
if self.path.as_ref() != Some(&receipt.target) {
self.source_workspace.reset_navigation_session();
self.reset_source_navigation_session();
}
self.path = Some(receipt.target);
self.document.bind_repair_baseline(disk.reachable_bundle());
@@ -3372,7 +3442,7 @@ impl App {
self.source_workspace
.discovered(&disk.bundle, disk.reachable.clone());
self.source_version += 1;
self.source_diagnostics = disk.diagnostics.clone();
self.publish_source_diagnostics(disk.diagnostics.clone());
self.disk_project = Some(disk);
self.status = "Included files loaded; existing drafts retained.".into();
return self.request_lint();
@@ -3393,21 +3463,13 @@ impl App {
self.lint_running = None;
if version == self.source_version {
match result {
Ok(diagnostics) => self.source_diagnostics = diagnostics,
Ok(diagnostics) => self.publish_source_diagnostics(diagnostics),
Err(error) => self.status = format!("Source checks failed: {error}"),
}
}
return self.start_pending_lint();
}
Message::SourceDiagnostic(index) => {
if let Some(issue) = self.source_diagnostics.get(index) {
self.jump_to_source(SourceLocation {
path: issue.path.clone(),
span: issue.diagnostic.span,
});
}
}
Message::SourceRelated(location) => self.jump_to_source(location),
Message::TextWorkspace(_) => return Task::none(), // checked before generic input
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),
@@ -3449,7 +3511,7 @@ impl App {
if self.source_workspace.undo(redo) {
self.show_source_file(true);
self.source_version += 1;
self.source_diagnostics.clear();
self.publish_source_diagnostics(Vec::new());
return self.request_lint();
}
}
@@ -3597,7 +3659,7 @@ impl App {
self.sync_source();
}
if self.path.as_ref() != Some(&receipt.path) {
self.source_workspace.reset_navigation_session();
self.reset_source_navigation_session();
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
@@ -4139,7 +4201,7 @@ impl App {
self.source_workspace
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
self.migration = self.document.compiled().decoded.migration.clone();
if self.migration.is_some() {
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
@@ -4159,9 +4221,10 @@ impl App {
self.path = Some(path);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.source_workspace = Workspace::new(&disk.bundle, false);
self.clear_source_presentation();
self.source_workspace.set_reachable(disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = error.diagnostics().to_vec();
self.publish_source_diagnostics(error.diagnostics().to_vec());
self.source_version += 1;
self.lint_requested = None;
self.page = Page::Text;
@@ -5226,7 +5289,16 @@ mod source_project_tests {
assert_eq!(app.project(), &project);
assert!(!app.apply_source());
assert_eq!(app.source_diagnostics[0].path, fragment);
send(&mut app, Message::SourceDiagnostic(0));
send(&mut app, Message::Page(Page::Text));
let _ = app.show_text_drawer(Some(text_workspace::DrawerTab::Problems));
let issue = &app.source_diagnostics[0];
let message = app
.text_diagnostic_message(SourceLocation {
path: issue.path.clone(),
span: issue.diagnostic.span,
})
.unwrap();
send(&mut app, message);
assert_eq!(app.source_workspace.active, fragment);
assert_eq!(app.source_editor.text(), "? invalid source");
send(&mut app, Message::RevertSource);