Compose compact Text workspace with guarded root navigation
This commit is contained in:
@@ -219,7 +219,7 @@ impl App {
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
@@ -359,7 +359,7 @@ impl App {
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
|
||||
@@ -215,7 +215,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_admission(&self) -> Result<(), String> {
|
||||
pub(super) fn rename_admission(&self) -> Result<(), String> {
|
||||
if self.page != Page::Text
|
||||
|| self.busy
|
||||
|| self.pending.is_some()
|
||||
|
||||
@@ -359,7 +359,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv
|
||||
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);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
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);
|
||||
|
||||
@@ -164,3 +164,112 @@ fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
for changed in 0..3 {
|
||||
let mut app = navigation_fixture();
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::Status));
|
||||
let origin = app.text_drawer_context();
|
||||
let captured = [
|
||||
Message::TextWorkspace(TextEvent::ShowDrawer(origin, DrawerTab::Problems)),
|
||||
Message::TextWorkspace(TextEvent::HideDrawer(origin)),
|
||||
crate::views::captured_text_drawer_resize(&app),
|
||||
];
|
||||
match changed {
|
||||
0 => app.canvas_document = Id::new_v4(),
|
||||
1 => app.source_workspace.reset_navigation_session(),
|
||||
_ => app.text_workspace.show_drawer(Some(DrawerTab::Status)),
|
||||
}
|
||||
let found = navigate(&mut app);
|
||||
// Keep command availability true to isolate the stale owner check from
|
||||
// the page guard, while a newer measured navigation is still pending.
|
||||
app.page = Page::Text;
|
||||
assert!(app.source_navigation_available());
|
||||
let destination = navigation(&app);
|
||||
let allocation = app.allocation_boundary();
|
||||
let camera = app.camera_boundary();
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let status = app.status.clone();
|
||||
for message in captured {
|
||||
send(&mut app, message);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
app.page = Page::Diagram;
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_compact_drawer_controls_and_mounted_resize_remain_live() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let show = Message::TextWorkspace(TextEvent::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Status,
|
||||
));
|
||||
send(&mut app, show);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let resize = crate::views::captured_text_drawer_resize(&app);
|
||||
send(&mut app, resize);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height + 60.);
|
||||
let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
||||
send(&mut app, hide);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
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 compact_diagram_usages_command_uses_captured_query_and_existing_chooser() {
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let command = app.text_command_message(crate::app::text_workspace::Command::ShowDiagramUsages);
|
||||
send(&mut app, command);
|
||||
let request = app
|
||||
.source_symbols
|
||||
.request
|
||||
.clone()
|
||||
.expect("actual source query started");
|
||||
assert_eq!(app.source_diagram_job, Some(request.job));
|
||||
let completion = source_completion(&app, request);
|
||||
send(&mut app, Message::SourceSymbolsReady(completion));
|
||||
let panel = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("existing guarded chooser opened");
|
||||
assert_eq!(panel.context.subject, model_id(2));
|
||||
assert_eq!(panel.context.document, app.canvas_document);
|
||||
assert_eq!(panel.context.revision, app.document.revision());
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(panel.token));
|
||||
assert!(!app.source_navigation_available());
|
||||
let (_, found) = finish_lookup(&mut app);
|
||||
assert_eq!(found.surface, usage::Surface::Saved(model_id(8)));
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist
|
||||
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);
|
||||
app.publish_source_diagnostics(source_workspace::lint(&invalid));
|
||||
assert!(!app.source_diagnostics.is_empty());
|
||||
let version = app.source_version;
|
||||
app.lint_requested = Some(version);
|
||||
@@ -839,7 +839,8 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
send(&mut app, Message::SourceRenameReady(ready.clone()));
|
||||
}
|
||||
app.source_navigation.open = true;
|
||||
app.source_symbols.open = true;
|
||||
app.text_workspace
|
||||
.show_drawer(Some(crate::app::text_workspace::DrawerTab::References));
|
||||
match other_modal {
|
||||
1 => app.hosted.visible = true,
|
||||
2 => {
|
||||
@@ -866,7 +867,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
}
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(
|
||||
app.source_symbols.open && app.source_navigation.open,
|
||||
app.text_workspace.drawer().is_some() && app.source_navigation.open,
|
||||
"rename Escape does not fall through"
|
||||
);
|
||||
if pending {
|
||||
@@ -875,7 +876,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
assert!(!app.source_rename.open());
|
||||
}
|
||||
assert_eq!(evidence(&app), before);
|
||||
app.source_symbols.open = false;
|
||||
app.text_workspace.show_drawer(None);
|
||||
app.source_navigation.open = false;
|
||||
assert!(captured_escape(&app).is_empty());
|
||||
}
|
||||
|
||||
@@ -33,9 +33,29 @@ fn admission_notice(error: query::AdmissionError) -> String {
|
||||
impl App {
|
||||
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::SourceSymbols(context, _) => {
|
||||
Message::SourceSymbols(context, event) => {
|
||||
context.session == self.source_workspace.session()
|
||||
&& context.path == self.source_workspace.active
|
||||
&& match event {
|
||||
nav::SymbolEvent::Choose { stamp, visit, .. } => {
|
||||
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_some_and(|results| results.stamp == *stamp)
|
||||
&& self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_some_and(|cache| self.source_snapshot_matches(cache))
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
context == &self.source_symbol_context()
|
||||
@@ -118,6 +138,7 @@ impl App {
|
||||
&& self.model_menu.is_none()
|
||||
&& self.diagram_usages.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& self.text_workspace.menu().is_none()
|
||||
&& self.architecture.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
@@ -133,6 +154,12 @@ impl App {
|
||||
))
|
||||
}
|
||||
|
||||
/// Capture the same source-owned query used by the keyboard/menu adapter.
|
||||
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
|
||||
self.source_navigation_available()
|
||||
.then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages))
|
||||
}
|
||||
|
||||
pub fn source_reveal_ticket(&self) -> Option<Id> {
|
||||
self.source_symbols
|
||||
.reveal
|
||||
@@ -215,7 +242,8 @@ impl App {
|
||||
match event {
|
||||
nav::SymbolEvent::DiagramUsages => {
|
||||
if self.source_dirty() || !self.source_workspace.applied {
|
||||
self.source_symbols.open = true;
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
|
||||
return Task::none();
|
||||
}
|
||||
@@ -228,20 +256,30 @@ impl App {
|
||||
return task;
|
||||
}
|
||||
nav::SymbolEvent::Close => {
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
return self.show_text_drawer(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();
|
||||
if self.source_symbols.notice.is_some() {
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
let refs = self.text_workspace.references();
|
||||
return iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp,
|
||||
reference,
|
||||
index,
|
||||
..
|
||||
} => {
|
||||
let Some(results) = self.source_symbols.results.clone() else {
|
||||
return Task::none();
|
||||
@@ -279,7 +317,8 @@ impl App {
|
||||
}
|
||||
|
||||
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
|
||||
self.source_symbols.open = true;
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
if !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
@@ -300,7 +339,7 @@ impl App {
|
||||
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
|
||||
Some(serial) => serial,
|
||||
None => {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
self.source_symbols.back.clear();
|
||||
self.source_symbols.cache = None;
|
||||
0
|
||||
@@ -340,9 +379,12 @@ impl App {
|
||||
};
|
||||
self.source_symbols.in_flight = Some(request.job);
|
||||
self.source_symbols.request = Some(request.clone());
|
||||
self.source_symbols.display_request = Some(request.clone());
|
||||
self.text_workspace
|
||||
.begin_references(request.stamp, request.path.clone());
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
Task::perform(
|
||||
let worker = Task::perform(
|
||||
async move {
|
||||
let worker_request = request.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -373,7 +415,14 @@ impl App {
|
||||
nav::Completion { request, result }
|
||||
},
|
||||
Message::SourceSymbolsReady,
|
||||
)
|
||||
);
|
||||
Task::batch([
|
||||
worker,
|
||||
iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. },
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
|
||||
@@ -386,11 +435,11 @@ impl App {
|
||||
if diagram_query {
|
||||
self.source_diagram_job = None;
|
||||
}
|
||||
let wanted = self
|
||||
let wanted_navigation = self
|
||||
.source_symbols
|
||||
.request
|
||||
.take()
|
||||
.is_some_and(|current| current.job == request.job)
|
||||
.is_some_and(|current| current == request)
|
||||
&& request.interaction == self.source_symbols.interaction
|
||||
&& request.stamp.snapshot == self.source_key()
|
||||
&& nav::interaction::is_current(
|
||||
@@ -400,15 +449,34 @@ impl App {
|
||||
&& request.path == self.source_workspace.active
|
||||
&& request.cursor == self.source_editor.cursor()
|
||||
&& self.source_navigation_available();
|
||||
if !wanted {
|
||||
let wanted_display = self
|
||||
.source_symbols
|
||||
.display_request
|
||||
.as_ref()
|
||||
.is_some_and(|current| current == &request)
|
||||
&& request.stamp.snapshot == self.source_key();
|
||||
if !wanted_display {
|
||||
return Task::none();
|
||||
}
|
||||
match completion.result {
|
||||
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
|
||||
Ok((snapshot, results))
|
||||
if self.source_snapshot_matches(&snapshot)
|
||||
&& results.stamp == request.stamp
|
||||
&& results.action == request.action =>
|
||||
{
|
||||
self.source_symbols.cache = Some(snapshot);
|
||||
self.source_symbols.results = Some(results.clone());
|
||||
if diagram_query {
|
||||
if results.disposition == query::Disposition::Direct
|
||||
self.text_workspace.install_reference_files(
|
||||
results.stamp,
|
||||
results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.clone())
|
||||
.chain(results.references.iter().map(|row| row.path.clone())),
|
||||
);
|
||||
if diagram_query && wanted_navigation {
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& results.scope.validated
|
||||
&& results.omitted_definitions == 0
|
||||
&& let [row] = results.definitions.as_slice()
|
||||
@@ -419,7 +487,7 @@ impl App {
|
||||
&& crate::diagram_usage::alias(self.project(), identity.id())
|
||||
== row.declaration.alias
|
||||
{
|
||||
self.source_symbols.open = false;
|
||||
self.text_workspace.show_drawer(None);
|
||||
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
|
||||
crate::diagram_usage::Context {
|
||||
document: self.canvas_document,
|
||||
@@ -431,7 +499,8 @@ impl App {
|
||||
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
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& let Some(row) = results.definitions.first()
|
||||
{
|
||||
self.source_symbol_jump(&row.path, row.position, row.end_position);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.source_navigation.query, "retain 雪");
|
||||
assert!(app.source_symbols.open);
|
||||
assert!(references_visible(&app));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
@@ -40,7 +40,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.notice.is_none());
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
//! Text-only presentation and captured command envelopes. Source/model authority
|
||||
//! remains in the existing App controllers; dispatch this envelope before the
|
||||
//! generic source_interaction invalidation in App::update.
|
||||
use super::{App, Message};
|
||||
use crate::{context_menu, source_navigation as nav};
|
||||
use iced::{
|
||||
Point, Task,
|
||||
widget::{self, text_editor::Cursor},
|
||||
};
|
||||
use nav::query::{QueryStamp, SnapshotKey};
|
||||
use oa_core::{Id, source_project::SourceLocation};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrawerTab {
|
||||
References,
|
||||
Problems,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Find,
|
||||
GoToLine,
|
||||
Definition,
|
||||
References,
|
||||
RenameAlias,
|
||||
Back,
|
||||
UndoText,
|
||||
RedoText,
|
||||
UndoModel,
|
||||
RedoModel,
|
||||
UndoRename,
|
||||
RevertFile,
|
||||
ShowProblems,
|
||||
ShowStatus,
|
||||
ShowDiagramUsages,
|
||||
}
|
||||
impl Command {
|
||||
fn shortcut(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Find => Some("Ctrl F"),
|
||||
Self::GoToLine => Some("Ctrl G"),
|
||||
Self::Definition => Some("F12"),
|
||||
Self::References => Some("Shift F12"),
|
||||
Self::RenameAlias => Some("F2"),
|
||||
Self::Back => Some("Alt ←"),
|
||||
Self::UndoText => Some("Ctrl Z"),
|
||||
Self::RedoText => Some("Ctrl Y"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Find => "Find in file",
|
||||
Self::GoToLine => "Go to line",
|
||||
Self::Definition => "Go to definition",
|
||||
Self::References => "Find references",
|
||||
Self::RenameAlias => "Rename source alias",
|
||||
Self::Back => "Back in source",
|
||||
Self::UndoText => "Undo text edit",
|
||||
Self::RedoText => "Redo text edit",
|
||||
Self::UndoModel => "Undo model change",
|
||||
Self::RedoModel => "Redo model change",
|
||||
Self::UndoRename => "Undo source rename",
|
||||
Self::RevertFile => "Revert current file",
|
||||
Self::ShowProblems => "Source problems",
|
||||
Self::ShowStatus => "Source status",
|
||||
Self::ShowDiagramUsages => "Show diagram usages",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandContext {
|
||||
document: Id,
|
||||
document_revision: u64,
|
||||
source: SnapshotKey,
|
||||
path: String,
|
||||
cursor: Cursor,
|
||||
interaction: Id,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandRequest {
|
||||
context: CommandContext,
|
||||
command: Command,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuRequest {
|
||||
context: CommandContext,
|
||||
anchor: Point,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DiagnosticPublication {
|
||||
generation: Id,
|
||||
source: SnapshotKey,
|
||||
}
|
||||
impl DiagnosticPublication {
|
||||
pub fn generation(self) -> Id {
|
||||
self.generation
|
||||
}
|
||||
pub fn source(self) -> SnapshotKey {
|
||||
self.source
|
||||
}
|
||||
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
|
||||
DiagnosticTarget {
|
||||
publication: self,
|
||||
visit,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiagnosticTarget {
|
||||
publication: DiagnosticPublication,
|
||||
visit: Id,
|
||||
location: SourceLocation,
|
||||
}
|
||||
|
||||
/// Authority captured by the rendered drawer, independent of its source rows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DrawerContext {
|
||||
pub document: Id,
|
||||
pub session: Id,
|
||||
pub visit: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
ShowDrawer(DrawerContext, DrawerTab),
|
||||
HideDrawer(DrawerContext),
|
||||
ResizeDrawer(DrawerContext, f32),
|
||||
ToggleFile {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
path: String,
|
||||
},
|
||||
ReferenceScrolled {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
y: f32,
|
||||
},
|
||||
Command(CommandRequest),
|
||||
OpenMenu(MenuRequest),
|
||||
DismissMenu(Id),
|
||||
Invoke {
|
||||
token: Id,
|
||||
command: Command,
|
||||
},
|
||||
Diagnostic(DiagnosticTarget),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FrozenEntry {
|
||||
command: Command,
|
||||
action: Option<Action>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
enum Action {
|
||||
Dispatch(Box<Message>),
|
||||
Drawer(DrawerTab),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu {
|
||||
token: Id,
|
||||
popup_token: u64,
|
||||
anchor: Point,
|
||||
context: CommandContext,
|
||||
entries: Vec<FrozenEntry>,
|
||||
}
|
||||
impl Menu {
|
||||
pub fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReferencePresentation {
|
||||
stamp: Option<QueryStamp>,
|
||||
origin: String,
|
||||
files: BTreeSet<String>,
|
||||
installed: bool,
|
||||
collapsed_files: BTreeSet<String>,
|
||||
scroll_y: f32,
|
||||
}
|
||||
impl Default for ReferencePresentation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stamp: None,
|
||||
origin: String::new(),
|
||||
files: BTreeSet::new(),
|
||||
installed: false,
|
||||
collapsed_files: BTreeSet::new(),
|
||||
scroll_y: 0.,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ReferencePresentation {
|
||||
pub fn stamp(&self) -> Option<QueryStamp> {
|
||||
self.stamp
|
||||
}
|
||||
pub fn scroll_y(&self) -> f32 {
|
||||
self.scroll_y
|
||||
}
|
||||
pub fn collapsed(&self, path: &str) -> bool {
|
||||
self.collapsed_files.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
drawer: Option<DrawerTab>,
|
||||
drawer_visit: Id,
|
||||
drawer_height: f32,
|
||||
references: ReferencePresentation,
|
||||
menu: Option<Menu>,
|
||||
diagnostic_publication: Option<DiagnosticPublication>,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
drawer: None,
|
||||
drawer_visit: Id::new_v4(),
|
||||
drawer_height: 180.,
|
||||
references: ReferencePresentation::default(),
|
||||
menu: None,
|
||||
diagnostic_publication: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub fn drawer_visit(&self) -> Id {
|
||||
self.drawer_visit
|
||||
}
|
||||
pub fn reference_scroll_id(&self) -> widget::Id {
|
||||
widget::Id::from(format!("text-references-{}", self.drawer_visit))
|
||||
}
|
||||
pub fn drawer(&self) -> Option<DrawerTab> {
|
||||
self.drawer
|
||||
}
|
||||
pub fn drawer_height(&self) -> f32 {
|
||||
self.drawer_height
|
||||
}
|
||||
pub fn references(&self) -> &ReferencePresentation {
|
||||
&self.references
|
||||
}
|
||||
pub fn menu(&self) -> Option<&Menu> {
|
||||
self.menu.as_ref()
|
||||
}
|
||||
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
|
||||
self.diagnostic_publication
|
||||
}
|
||||
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
|
||||
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
|
||||
let publication = DiagnosticPublication {
|
||||
generation: Id::new_v4(),
|
||||
source,
|
||||
};
|
||||
self.diagnostic_publication = Some(publication);
|
||||
publication
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
pub fn dismiss_menu(&mut self) {
|
||||
self.menu = None;
|
||||
}
|
||||
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
|
||||
self.references = ReferencePresentation {
|
||||
stamp: Some(stamp),
|
||||
origin,
|
||||
..ReferencePresentation::default()
|
||||
};
|
||||
self.show_drawer(Some(DrawerTab::References));
|
||||
}
|
||||
/// Install presentation groups only for the current completed query. Repeated
|
||||
/// delivery of the same completion cannot undo the user's group choices.
|
||||
pub fn install_reference_files(
|
||||
&mut self,
|
||||
stamp: QueryStamp,
|
||||
paths: impl IntoIterator<Item = String>,
|
||||
) {
|
||||
let refs = &mut self.references;
|
||||
if refs.stamp != Some(stamp) || refs.installed {
|
||||
return;
|
||||
}
|
||||
refs.files = paths.into_iter().collect();
|
||||
refs.collapsed_files = refs.files.clone();
|
||||
let expanded = if refs.files.contains(&refs.origin) {
|
||||
Some(refs.origin.clone())
|
||||
} else {
|
||||
refs.files.first().cloned()
|
||||
};
|
||||
if let Some(path) = expanded {
|
||||
refs.collapsed_files.remove(&path);
|
||||
}
|
||||
refs.installed = true;
|
||||
}
|
||||
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
|
||||
self.drawer = drawer;
|
||||
self.drawer_visit = Id::new_v4();
|
||||
}
|
||||
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer != Some(DrawerTab::References)
|
||||
|| self.drawer_visit != visit
|
||||
|| refs.stamp != Some(stamp)
|
||||
|| !refs.files.contains(&path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !refs.collapsed_files.remove(&path) {
|
||||
refs.collapsed_files.insert(path);
|
||||
}
|
||||
// Collapsing a group can replace a pressed row at the same position.
|
||||
self.drawer_visit = Id::new_v4();
|
||||
true
|
||||
}
|
||||
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer == Some(DrawerTab::References)
|
||||
&& refs.stamp == Some(stamp)
|
||||
&& self.drawer_visit == visit
|
||||
&& y.is_finite()
|
||||
&& y >= 0.
|
||||
{
|
||||
refs.scroll_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn text_drawer_context(&self) -> DrawerContext {
|
||||
DrawerContext {
|
||||
document: self.canvas_document,
|
||||
session: self.source_workspace.session(),
|
||||
visit: self.text_workspace.drawer_visit(),
|
||||
}
|
||||
}
|
||||
fn text_command_context(&self) -> CommandContext {
|
||||
CommandContext {
|
||||
document: self.canvas_document,
|
||||
document_revision: self.document.revision(),
|
||||
source: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
}
|
||||
}
|
||||
pub fn text_command_message(&self, command: Command) -> Message {
|
||||
Message::TextWorkspace(Event::Command(CommandRequest {
|
||||
context: self.text_command_context(),
|
||||
command,
|
||||
}))
|
||||
}
|
||||
pub fn text_menu_message(&self, anchor: Point) -> Message {
|
||||
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
|
||||
context: self.text_command_context(),
|
||||
anchor,
|
||||
}))
|
||||
}
|
||||
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
|
||||
let menu = self.text_workspace.menu.as_ref()?;
|
||||
Some(context_menu::Menu {
|
||||
token: menu.popup_token,
|
||||
anchor: menu.anchor,
|
||||
entries: menu
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
let mut rows = Vec::with_capacity(2);
|
||||
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
|
||||
rows.push(context_menu::Entry::separator());
|
||||
}
|
||||
rows.push(context_menu::Entry::Action {
|
||||
label: entry.command.label().into(),
|
||||
shortcut: entry.command.shortcut().map(str::to_owned),
|
||||
message: entry.action.as_ref().map(|_| {
|
||||
Message::TextWorkspace(Event::Invoke {
|
||||
token: menu.token(),
|
||||
command: entry.command,
|
||||
})
|
||||
}),
|
||||
});
|
||||
rows
|
||||
})
|
||||
.collect(),
|
||||
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
|
||||
})
|
||||
}
|
||||
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
|
||||
let publication = self.text_workspace.diagnostic_publication?;
|
||||
(publication.source == self.source_key()
|
||||
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
|
||||
.then(|| {
|
||||
Message::TextWorkspace(Event::Diagnostic(
|
||||
publication.target(self.text_workspace.drawer_visit(), location),
|
||||
))
|
||||
})
|
||||
}
|
||||
fn text_command_action(&self, command: Command) -> Option<Action> {
|
||||
// Menu has been taken before dispatch-time admission. This intentionally
|
||||
// uses navigation admission, not rename admission, for Find/text history.
|
||||
if !self.source_navigation_available() {
|
||||
return None;
|
||||
}
|
||||
let message =
|
||||
match command {
|
||||
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
|
||||
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
|
||||
Command::Definition => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
|
||||
Command::References => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
|
||||
Command::RenameAlias => {
|
||||
self.rename_admission().ok()?;
|
||||
self.source_rename_message()
|
||||
}
|
||||
Command::Back => {
|
||||
if !self
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.any(|entry| entry.session == self.source_workspace.session())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.source_symbol_message(nav::SymbolEvent::Back)
|
||||
}
|
||||
Command::UndoText => Message::SourceUndo,
|
||||
Command::RedoText => Message::SourceRedo,
|
||||
Command::UndoModel | Command::RedoModel => {
|
||||
if self.source_dirty() || self.draft_dirty() {
|
||||
return None;
|
||||
}
|
||||
if command == Command::UndoModel {
|
||||
if !self.document.can_undo() {
|
||||
return None;
|
||||
}
|
||||
Message::Undo
|
||||
} else {
|
||||
if !self.document.can_redo() {
|
||||
return None;
|
||||
}
|
||||
Message::Redo
|
||||
}
|
||||
}
|
||||
Command::UndoRename => self.source_rename_undo_message()?,
|
||||
Command::RevertFile => Message::RevertSource,
|
||||
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
|
||||
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
|
||||
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
|
||||
};
|
||||
Some(Action::Dispatch(Box::new(message)))
|
||||
}
|
||||
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::OpenMenu(request) => {
|
||||
if request.context != self.text_command_context()
|
||||
|| !self.source_navigation_available()
|
||||
|| self.text_workspace.menu.is_some()
|
||||
|| !request.anchor.x.is_finite()
|
||||
|| !request.anchor.y.is_finite()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.source_interaction();
|
||||
let commands = [
|
||||
Command::Find,
|
||||
Command::GoToLine,
|
||||
Command::Definition,
|
||||
Command::References,
|
||||
Command::RenameAlias,
|
||||
Command::Back,
|
||||
Command::UndoText,
|
||||
Command::RedoText,
|
||||
Command::UndoRename,
|
||||
Command::UndoModel,
|
||||
Command::RedoModel,
|
||||
Command::RevertFile,
|
||||
Command::ShowProblems,
|
||||
Command::ShowStatus,
|
||||
Command::ShowDiagramUsages,
|
||||
];
|
||||
let entries = commands
|
||||
.into_iter()
|
||||
.filter_map(|command| {
|
||||
let action = self.text_command_action(command);
|
||||
if command == Command::ShowDiagramUsages && action.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(FrozenEntry { command, action })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.menu_sequence = popup_token;
|
||||
self.text_workspace.menu = Some(Menu {
|
||||
token: Id::new_v4(),
|
||||
popup_token,
|
||||
anchor: request.anchor,
|
||||
context: self.text_command_context(),
|
||||
entries,
|
||||
});
|
||||
}
|
||||
Event::DismissMenu(token) => {
|
||||
if self
|
||||
.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.token == token)
|
||||
{
|
||||
self.text_workspace.menu = None;
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::Invoke { token, command } => {
|
||||
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
|
||||
menu.token == token && menu.context == self.text_command_context()
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
// Validate before closing; consume this invocation only once.
|
||||
// Never resolve a frozen row against a newly generated menu.
|
||||
let menu = self.text_workspace.menu.take().unwrap();
|
||||
let Some(action) = menu
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.and_then(|entry| entry.action)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
if self.text_command_action(command).is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
Event::Command(request) => {
|
||||
if request.context != self.text_command_context() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(action) = self.text_command_action(request.command) {
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
}
|
||||
Event::ShowDrawer(context, tab) => {
|
||||
if context != self.text_drawer_context() || !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(Some(tab));
|
||||
}
|
||||
Event::HideDrawer(context) => {
|
||||
if context != self.text_drawer_context() || !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(None);
|
||||
}
|
||||
Event::ResizeDrawer(context, height) => {
|
||||
if context == self.text_drawer_context()
|
||||
&& self.source_navigation_available()
|
||||
&& height.is_finite()
|
||||
{
|
||||
// The view applies the measured editor-allocation bound.
|
||||
let height = height.clamp(180., 1200.);
|
||||
if height != self.text_workspace.drawer_height {
|
||||
self.source_interaction();
|
||||
self.text_workspace.drawer_height = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToggleFile { stamp, visit, path } => {
|
||||
if self.source_navigation_available()
|
||||
&& self.text_workspace.toggle_file(stamp, visit, path)
|
||||
{
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::ReferenceScrolled { stamp, visit, y } => {
|
||||
// Passive geometry must neither invalidate pending navigation
|
||||
// nor reset the editor identity, even while commands are blocked.
|
||||
self.text_workspace.reference_scrolled(stamp, visit, y);
|
||||
}
|
||||
Event::Diagnostic(target) => {
|
||||
if !self.source_navigation_available()
|
||||
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|
||||
|| self.text_workspace.drawer_visit() != target.visit
|
||||
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|
||||
|| target.publication.source != self.source_key()
|
||||
|| !self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&target.location.path)
|
||||
.is_some_and(|file| valid_span(&file.text, &target.location))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.source_interaction();
|
||||
self.jump_to_source(target.location);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
|
||||
match action {
|
||||
Action::Dispatch(message) => self.update(*message),
|
||||
Action::Drawer(tab) => self.show_text_drawer(Some(tab)),
|
||||
}
|
||||
}
|
||||
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
|
||||
self.source_interaction();
|
||||
self.text_workspace.show_drawer(drawer);
|
||||
if drawer == Some(DrawerTab::References) {
|
||||
let refs = self.text_workspace.references();
|
||||
widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_span(text: &str, location: &SourceLocation) -> bool {
|
||||
let span = location.span;
|
||||
span.start <= span.end
|
||||
&& span.end <= text.len()
|
||||
&& text.is_char_boundary(span.start)
|
||||
&& text.is_char_boundary(span.end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests;
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Real lifecycle receipts, with all disk writes confined to unique temp roots.
|
||||
//! Returned App worker tasks are not polled; completions use the normal captured
|
||||
//! query input and are delivered deterministically through App::update.
|
||||
use super::*;
|
||||
use crate::app::{Page, read_snapshot, write_snapshot};
|
||||
use crate::document::Document;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use oa_core::{
|
||||
Project,
|
||||
language::{Diagnostic, Span},
|
||||
source_project::{SourceBundle, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), VALID.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
// with_project uses in-memory Home metadata and no recovery directory.
|
||||
assert!(app.recovery.directory.is_none());
|
||||
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.show_source_file(false);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn save(app: &mut App, path: &Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
send(app, Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
fn reveal(app: &mut App) {
|
||||
if let Some(ticket) = app.source_reveal_ticket() {
|
||||
send(app, Message::SourceViewport(Some(ticket)));
|
||||
}
|
||||
}
|
||||
fn query(app: &mut App, action: nav::query::Action) -> nav::Completion {
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(action));
|
||||
send(app, message);
|
||||
let request = app.source_symbols.request.clone().expect("admitted query");
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
let snapshot = Arc::new(app.with_source_input(|input| {
|
||||
nav::query::admit_owned(input, nav::query::Limits::default())
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap()
|
||||
}));
|
||||
let results = Arc::new(
|
||||
snapshot
|
||||
.query(nav::query::Query {
|
||||
stamp: request.stamp,
|
||||
path: &request.path,
|
||||
byte_offset: request.offset,
|
||||
action,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
nav::Completion {
|
||||
request,
|
||||
result: Ok((snapshot, results)),
|
||||
}
|
||||
}
|
||||
fn seed_navigation(app: &mut App, pending: bool) -> Option<nav::Completion> {
|
||||
let text = app.source_workspace.text();
|
||||
let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len();
|
||||
let prefix = &text[..start];
|
||||
let position = widget::text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix.rsplit_once('\n').unwrap().1.len(),
|
||||
};
|
||||
app.source_editor.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
let ready = query(app, nav::query::Action::Definition);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
reveal(app);
|
||||
assert_eq!(app.source_symbols.back.len(), 1);
|
||||
let ready = query(app, nav::query::Action::References);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some());
|
||||
assert!(app.source_symbols.display_request.is_some());
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
if pending {
|
||||
Some(query(app, nav::query::Action::Definition))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn warning(app: &App) -> SourceDiagnostic {
|
||||
let source = app.source_workspace.text();
|
||||
let primary = source.find("model").unwrap();
|
||||
let related = source.find("assess").unwrap();
|
||||
SourceDiagnostic {
|
||||
path: app.source_workspace.active.clone(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
code: "lifecycle_warning".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 2,
|
||||
column: 9,
|
||||
message: "Retained warning with a related source location".into(),
|
||||
span: Span {
|
||||
start: primary,
|
||||
end: primary + 5,
|
||||
},
|
||||
},
|
||||
related: vec![SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span {
|
||||
start: related,
|
||||
end: related + 6,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn warning_messages(app: &App) -> [Message; 2] {
|
||||
let warning = &app.source_diagnostics[0];
|
||||
[
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: warning.path.clone(),
|
||||
span: warning.diagnostic.span,
|
||||
})
|
||||
.unwrap(),
|
||||
app.text_diagnostic_message(warning.related[0].clone())
|
||||
.unwrap(),
|
||||
]
|
||||
}
|
||||
fn assert_cleared(app: &App, job: Option<Id>) {
|
||||
assert_eq!(app.source_symbols.in_flight, job);
|
||||
assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none());
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.references().stamp().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
let ready = seed_navigation(&mut app, true).unwrap();
|
||||
let job = ready.request.job;
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let warnings = app.source_diagnostics.clone();
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let raw = app.source_workspace.bundle();
|
||||
save(&mut app, &second_path);
|
||||
assert_eq!(app.path.as_deref(), Some(second_path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, source.workspace);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID);
|
||||
assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID);
|
||||
assert_eq!(app.source_diagnostics, warnings);
|
||||
let current = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.expect("identity-only save retained current warnings");
|
||||
assert_eq!(current.source(), app.source_key());
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert_cleared(&app, Some(job));
|
||||
for message in old {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
for message in warning_messages(&app) {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related warnings remain actionable"
|
||||
);
|
||||
reveal(&mut app);
|
||||
}
|
||||
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_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker()
|
||||
{
|
||||
for pending in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("broken.arcadia");
|
||||
let invalid = format!(
|
||||
"{} include \"missing.arcadia\";\r\n}}\r\n",
|
||||
VALID.strip_suffix("}\r\n").unwrap()
|
||||
);
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
let mut app = fixture();
|
||||
let ready = seed_navigation(&mut app, pending);
|
||||
let job = ready.as_ref().map(|ready| ready.request.job);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let old_source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let open_menu = app.text_menu_message(Point::new(20., 20.));
|
||||
send(&mut app, open_menu);
|
||||
assert!(app.text_workspace.menu().is_some());
|
||||
let loaded = read_snapshot(path.clone()).unwrap();
|
||||
send(&mut app, Message::Loaded(Ok(Some(loaded))));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert_eq!(app.path.as_deref(), Some(path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, old_source.workspace);
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_cleared(&app, job);
|
||||
assert!(
|
||||
app.source_diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.severity == Severity::Error)
|
||||
);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(publication.source(), app.source_key());
|
||||
assert_ne!(publication.generation(), old_publication.generation());
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let issue = app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.path == app.source_workspace.active
|
||||
&& issue.diagnostic.span.end <= invalid.len()
|
||||
})
|
||||
.unwrap();
|
||||
let current = app
|
||||
.text_diagnostic_message(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
})
|
||||
.unwrap();
|
||||
send(&mut app, current);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
reveal(&mut app);
|
||||
if let Some(ready) = ready {
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
}
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() {
|
||||
for failed in [false, true] {
|
||||
let mut app = fixture();
|
||||
seed_navigation(&mut app, false);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
let results = app.source_symbols.results.clone().unwrap();
|
||||
let cache = app.source_symbols.cache.clone().unwrap();
|
||||
let display = app.source_symbols.display_request.clone();
|
||||
let back = app
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let key = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let result = if failed {
|
||||
Err("Fixture read failed before installing a workspace".into())
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
send(&mut app, Message::Loaded(result));
|
||||
assert_eq!(app.source_key(), key);
|
||||
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
|
||||
assert_eq!(app.source_diagnostics, diagnostics);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.cache.as_ref().unwrap(),
|
||||
&cache
|
||||
));
|
||||
assert_eq!(app.source_symbols.display_request, display);
|
||||
assert_eq!(
|
||||
app.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| (
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
back
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let draft = format!("{VALID}// unsaved current draft\r\n");
|
||||
app.source_workspace
|
||||
.edit(draft.clone(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert_ne!(publication.source(), app.source_key());
|
||||
let accepted = app.document.snapshot();
|
||||
save(&mut app, &second_path);
|
||||
assert!(
|
||||
app.text_workspace.diagnostic_publication().is_none(),
|
||||
"identity-only reset cannot turn an old source span into a current warning"
|
||||
);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: app.source_diagnostics[0].path.clone(),
|
||||
span: app.source_diagnostics[0].diagnostic.span,
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), draft);
|
||||
assert!(app.source_dirty());
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second_path).unwrap(),
|
||||
VALID,
|
||||
"receipt records accepted bytes while the newer draft remains unsaved"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
use super::*;
|
||||
use crate::app::Page;
|
||||
use oa_core::{Project, language::Span};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
app.publish_source_diagnostics(diagnostics);
|
||||
assert!(app.source_navigation_available());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
// Exercise the real envelope path; worker/lint futures are deliberately not
|
||||
// polled here. The parent owns serialized compile/runtime verification.
|
||||
let _ = app.update(Message::TextWorkspace(event));
|
||||
}
|
||||
fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event {
|
||||
Event::ToggleFile {
|
||||
stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
fn open(app: &mut App) -> Id {
|
||||
let message = app.text_menu_message(Point::new(10., 20.));
|
||||
let _ = app.update(message);
|
||||
app.text_workspace.menu().unwrap().token()
|
||||
}
|
||||
fn invoke(app: &mut App, token: Id, command: Command) {
|
||||
send(app, Event::Invoke { token, command });
|
||||
}
|
||||
fn enabled(app: &App, command: Command) -> bool {
|
||||
app.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.is_some_and(|entry| entry.action.is_some())
|
||||
}
|
||||
fn back_entry(app: &App) -> nav::HistoryEntry {
|
||||
nav::HistoryEntry {
|
||||
session: app.source_workspace.session(),
|
||||
path: app.source_workspace.active.clone(),
|
||||
generation: app.source_workspace.files[&app.source_workspace.active].generation(),
|
||||
cursor: app.source_editor.cursor(),
|
||||
}
|
||||
}
|
||||
fn stamp(app: &App, serial: u64) -> QueryStamp {
|
||||
QueryStamp {
|
||||
snapshot: app.source_key(),
|
||||
serial,
|
||||
}
|
||||
}
|
||||
fn location(app: &App, start: usize, end: usize) -> SourceLocation {
|
||||
SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span { start, end },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() {
|
||||
let mut app = fixture();
|
||||
let old_interaction = app.source_symbols.interaction;
|
||||
let token = open(&mut app);
|
||||
assert_ne!(old_interaction, app.source_symbols.interaction);
|
||||
assert_eq!(
|
||||
app.text_workspace.menu.as_ref().unwrap().context,
|
||||
app.text_command_context()
|
||||
);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
let instance = app.source_find_instance;
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert_eq!(
|
||||
app.source_find_instance, instance,
|
||||
"replay must not reopen Find"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(!enabled(&app, Command::Back));
|
||||
app.source_symbols.back.push(back_entry(&app));
|
||||
assert!(
|
||||
!enabled(&app, Command::Back),
|
||||
"frozen presentation must remain disabled"
|
||||
);
|
||||
invoke(&mut app, token, Command::Back);
|
||||
assert_eq!(
|
||||
app.source_symbols.back.len(),
|
||||
1,
|
||||
"a newly enabled action cannot be substituted"
|
||||
);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
app.busy = true;
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
let new = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), new);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() {
|
||||
for change in 0..6 {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
match change {
|
||||
0 => {
|
||||
let value = format!("{}\n// changed", app.source_workspace.text());
|
||||
app.source_workspace.edit(value, app.source_editor.cursor());
|
||||
}
|
||||
1 => {
|
||||
let mut cursor = app.source_editor.cursor();
|
||||
cursor.position.column += 1;
|
||||
app.source_editor.move_to(cursor);
|
||||
}
|
||||
2 => app.canvas_document = Id::new_v4(),
|
||||
3 => app.source_workspace.active = "other.arcadia".into(),
|
||||
4 => app.source_symbols.interaction = Id::new_v4(),
|
||||
_ => {
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str("\n// accepted revision");
|
||||
assert!(app.document.apply_bundle(bundle).unwrap());
|
||||
}
|
||||
}
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open, "stale context case {change}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let pending_name = "Pending property draft";
|
||||
// Use the actual property input route before source edits coexist. Changing
|
||||
// Draft::value alone is overwritten by its authoritative raw input buffer.
|
||||
let _ = app.update(Message::Field("/name".into(), pending_name.into()));
|
||||
let assert_pending_property = |app: &App| {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(draft.inputs["/name"].raw, pending_name);
|
||||
assert_eq!(draft.inputs["/name"].editor.text(), pending_name);
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap()["name"].as_str(),
|
||||
Some(pending_name)
|
||||
);
|
||||
};
|
||||
assert_pending_property(&app);
|
||||
let initial = app.source_workspace.text().to_owned();
|
||||
app.source_workspace
|
||||
.edit(format!("{initial}\n// draft"), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert!(app.source_dirty() && app.draft_dirty());
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText));
|
||||
assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert_pending_property(&app);
|
||||
let token = open(&mut app);
|
||||
invoke(&mut app, token, Command::UndoText);
|
||||
assert_eq!(app.source_workspace.text(), initial);
|
||||
assert_pending_property(&app);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_toolbar_request_cannot_retarget_after_source_change() {
|
||||
let mut app = fixture();
|
||||
let request = app.text_command_message(Command::Find);
|
||||
app.source_workspace
|
||||
.edit("// changed".into(), app.source_editor.cursor());
|
||||
let _ = app.update(request);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap();
|
||||
let previous = app.text_workspace.diagnostic_publication().unwrap();
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(previous.source(), current.source());
|
||||
assert_ne!(previous.generation(), current.generation());
|
||||
for message in [primary, related] {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
app.source_interaction();
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.source_workspace
|
||||
.edit("// newer".into(), app.source_editor.cursor());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none());
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
app.source_workspace
|
||||
.edit("a雨b".into(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let mut missing = location(&app, 0, 1);
|
||||
missing.path = "missing.arcadia".into();
|
||||
for target in [
|
||||
location(&app, 3, 2),
|
||||
location(&app, 0, 6),
|
||||
location(&app, 2, 4),
|
||||
location(&app, 1, 3),
|
||||
missing,
|
||||
] {
|
||||
let message = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() {
|
||||
let mut app = fixture();
|
||||
let first = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(first, "b/use.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]);
|
||||
assert!(app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(!app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "a/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let old_scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 123.,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_symbols.interaction, interaction,
|
||||
"scroll is passive"
|
||||
);
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Status);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id);
|
||||
let second = stamp(&app, 2);
|
||||
app.text_workspace
|
||||
.begin_references(second, "absent.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["old.arcadia".into()]);
|
||||
app.text_workspace
|
||||
.install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 0.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "b/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
|
||||
let mut app = fixture();
|
||||
open(&mut app);
|
||||
app.text_workspace.reset();
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.diagnostic_publication().is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.drawer_height(), 180.);
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.drawer_height(), 1200.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "uses.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["uses.arcadia".into()]);
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let event = toggle(&app, current, "not-a-group.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old = QueryStamp {
|
||||
serial: 0,
|
||||
..current
|
||||
};
|
||||
let event = toggle(&app, old, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), 180.);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.request.is_some());
|
||||
let event = toggle(&app, current, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_ne!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(
|
||||
app.source_symbols.in_flight,
|
||||
Some(request.job),
|
||||
"intent does not release an occupied worker"
|
||||
);
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), 240.);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
{
|
||||
let captured = Event::HideDrawer(app.text_drawer_context());
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
let path = app.source_workspace.active.clone();
|
||||
app.text_workspace.begin_references(current, path.clone());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, [path.clone()]);
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed(&path));
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit(
|
||||
widget::text_editor::Edit::Insert(' '),
|
||||
)));
|
||||
assert_ne!(current.snapshot, app.source_key());
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(!app.text_workspace.references().collapsed(&path));
|
||||
assert_eq!(app.source_workspace.active, path);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty());
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
let sequence = app.text_workspace_menu().unwrap().token;
|
||||
app.reset(Project::empty(), None);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
app.page = Page::Text;
|
||||
let fresh = open(&mut app);
|
||||
assert_ne!(old, fresh);
|
||||
assert!(app.text_workspace_menu().unwrap().token > sequence);
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() {
|
||||
for hide in [false, true] {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let targets = [location(&app, 0, 1), location(&app, 1, 2)];
|
||||
let old = targets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|target| app.text_diagnostic_message(target).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty(),
|
||||
);
|
||||
let event = if hide {
|
||||
Event::HideDrawer(app.text_drawer_context())
|
||||
} else {
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References)
|
||||
};
|
||||
send(&mut app, event);
|
||||
for reopened in [false, true] {
|
||||
if reopened {
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
} else {
|
||||
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
|
||||
}
|
||||
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
|
||||
assert_eq!(
|
||||
app.text_workspace.diagnostic_publication(),
|
||||
publication,
|
||||
"same list and source isolate the visit guard from publication freshness"
|
||||
);
|
||||
let context = app.text_command_context();
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
for message in old.iter().cloned() {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.text_command_context(),
|
||||
context,
|
||||
"stale diagnostic must fail before interaction invalidation"
|
||||
);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty()
|
||||
),
|
||||
history
|
||||
);
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
let fresh = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(fresh);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related targets remain usable"
|
||||
);
|
||||
app.source_interaction();
|
||||
}
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
|
||||
for replacement in 0..3 {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "a.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]);
|
||||
let old = toggle(&app, current, "a.arcadia".into());
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
match replacement {
|
||||
0 => {
|
||||
{
|
||||
let captured = Event::HideDrawer(app.text_drawer_context());
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
let event = toggle(&app, current, "b.arcadia".into());
|
||||
send(&mut app, event);
|
||||
}
|
||||
}
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
assert_ne!(visit, old_visit);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: current,
|
||||
visit,
|
||||
y: 84.,
|
||||
},
|
||||
);
|
||||
let scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let groups =
|
||||
["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path));
|
||||
let context = app.text_command_context();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
for _ in 0..2 {
|
||||
send(&mut app, old.clone());
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 84.);
|
||||
assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id);
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
assert_eq!(
|
||||
["a.arcadia", "b.arcadia"]
|
||||
.map(|path| app.text_workspace.references().collapsed(path)),
|
||||
groups,
|
||||
);
|
||||
assert_eq!(app.text_command_context(), context);
|
||||
assert_eq!(app.source_symbols.request.as_ref(), Some(&request));
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
let fresh = toggle(&app, current, "a.arcadia".into());
|
||||
send(&mut app, fresh);
|
||||
assert_eq!(
|
||||
app.text_workspace.references().collapsed("a.arcadia"),
|
||||
!groups[0]
|
||||
);
|
||||
assert_ne!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_ne!(app.source_symbols.interaction, context.interaction);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user