Make the Text workspace compact and preserve editing context

Replace the oversized header and reference rail with stable editor slots and a retained supporting drawer. Bind diagnostic, result and menu actions to their original publication and visit; preserve effective selections on navigation and dismiss Actions, Find and the drawer one surface per Escape.

Retain exact Windows source captures, eight final gates (871 desktop passes), independent reviews, native selection/rename/recovery trials and the original failures. All 526 tested inputs and staged evidence were byte-checked before commit. Combined preview integration and packaging remain with issue 27; Linux remains paused.
This commit is contained in:
2026-09-12 22:00:36 -07:00
parent 6a244fb21c
commit 2df7f580aa
660 changed files with 69626 additions and 738 deletions
+168 -56
View File
@@ -9,6 +9,7 @@ mod hosted;
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)]
@@ -415,8 +416,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,
@@ -503,6 +503,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 model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
@@ -611,6 +612,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(),
model_issues,
migration: None,
@@ -660,10 +662,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;
}
@@ -804,7 +831,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
@@ -959,6 +986,7 @@ impl App {
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 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();
@@ -972,6 +1000,7 @@ impl App {
self.lint_running = lint_running;
self.source_symbols.in_flight = navigation_in_flight;
self.source_rename.in_flight = rename_in_flight;
self.menu_sequence = menu_sequence;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
@@ -1069,7 +1098,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(
@@ -1086,13 +1115,14 @@ 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.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.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, _| {
@@ -1139,6 +1169,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);
}
if let Message::SourceRename(event) = &message
&& !self.source_rename_envelope(event)
{
@@ -1167,6 +1202,24 @@ impl App {
{
return Task::none();
}
// Retaining result data does not retain authority for an old row click.
if let Message::SourceSymbols(
_,
crate::source_navigation::SymbolEvent::Choose { stamp, visit, .. },
) = &message
&& (self.text_workspace.drawer() != Some(text_workspace::DrawerTab::References)
|| !self.source_navigation_available()
|| stamp.snapshot != self.source_key()
|| *visit != self.text_workspace.drawer_visit()
|| self
.source_symbols
.results
.as_ref()
.is_none_or(|results| results.stamp != *stamp))
{
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.
@@ -1176,6 +1229,38 @@ impl App {
if let Message::SourceRenameReady(completion) = message {
return self.source_rename_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
@@ -1852,11 +1937,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;
@@ -1910,7 +1995,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();
@@ -1927,9 +2013,11 @@ 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;
// 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();
}
@@ -2907,7 +2995,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 {
@@ -2919,10 +3007,12 @@ impl App {
}
}
Message::SourceFile(path) => {
if self
.source_workspace
.switch(&path, self.source_editor.cursor())
if path != self.source_workspace.active
&& self.source_workspace.files.contains_key(&path)
{
let departure =
crate::source_navigation::selection::capture(&mut self.source_editor);
self.source_workspace.switch(&path, departure);
self.show_source_file(true);
}
}
@@ -2970,8 +3060,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)
@@ -2983,7 +3078,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());
@@ -3017,7 +3112,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();
@@ -3038,21 +3133,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),
@@ -3076,7 +3163,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();
}
}
@@ -3224,7 +3311,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);
@@ -3766,7 +3853,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();
@@ -3786,9 +3873,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;
@@ -3818,17 +3906,14 @@ impl App {
}
fn jump_to_source(&mut self, location: SourceLocation) {
self.source_workspace
.switch(&location.path, self.source_editor.cursor());
if self.source_workspace.active != location.path {
let Some(target) = self.source_workspace.files.get(&location.path) else {
self.status = format!(
"{} has not been loaded. Use Load includes or create the missing file.",
location.path
);
return;
}
self.show_source_file(false);
let source = self.source_workspace.text();
};
let source = &target.text;
let mut offset = location.span.start.min(source.len());
while !source.is_char_boundary(offset) {
offset -= 1;
@@ -3838,6 +3923,9 @@ impl App {
let column = prefix
.rfind('\n')
.map_or(offset, |start| offset - start - 1);
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
self.source_workspace.switch(&location.path, departure);
self.show_source_file(false);
self.queue_source_reveal(
text_editor::Cursor {
position: text_editor::Position { line, column },
@@ -3864,6 +3952,7 @@ impl App {
.is_ok_and(|includes| includes.iter().any(|include| include.target == path))
});
let mut changed = bundle.clone();
let mut changed_entry = None;
if !already_included {
if self.source_dirty() {
return Err(
@@ -3883,16 +3972,30 @@ impl App {
serde_json::to_string(&path).map_err(|e| e.to_string())?
),
);
self.source_workspace
.switch(&bundle.entry, self.source_editor.cursor());
self.source_workspace
.edit(entry.clone(), self.source_editor.cursor());
changed_entry = Some(entry.clone());
}
// All fallible preparation is complete. The intermediate entry buffer
// is not displayed, so it must not receive the outgoing editor's cursor.
let original_path = self.source_workspace.active.clone();
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
if let Some(entry) = changed_entry {
self.source_workspace.switch(&bundle.entry, departure);
let cursor = if self.source_workspace.active == original_path {
departure
} else {
self.source_workspace.files[&self.source_workspace.active].cursor
};
self.source_workspace.edit(entry, cursor);
}
changed.files.insert(path.clone(), String::new());
self.source_workspace
.discovered(&changed, changed.files.keys().cloned().collect());
self.source_workspace
.switch(&path, self.source_editor.cursor());
let cursor = if self.source_workspace.active == original_path {
departure
} else {
self.source_workspace.files[&self.source_workspace.active].cursor
};
self.source_workspace.switch(&path, cursor);
self.source_workspace.new_path.clear();
self.show_source_file(true);
self.source_version += 1;
@@ -4838,7 +4941,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);