use crate::{ app::{App, Message}, source_navigation::{ SymbolEvent, query::{Declaration, Disposition, Evidence, Identity, Membership, Position}, }, style, }; use iced::{ Element, Fill, widget::{button, column, container, scrollable, text, tooltip}, }; pub(super) fn panel(app: &App, current: bool) -> Element<'_, Message> { use crate::app::text_workspace::{DrawerTab, Event}; use std::collections::BTreeSet; let state = &app.source_symbols; let visible = app.text_workspace.drawer() == Some(DrawerTab::References); let presentation = app.text_workspace.references(); let mut content = column![].spacing(0).width(Fill); if visible && let Some(notice) = &state.notice { content = content.push(text(notice.clone()).size(12)); } if visible && let Some(results) = &state.results { let stale = !current; let enabled = !stale && app.source_navigation_available(); let files: BTreeSet<_> = results .definitions .iter() .map(|row| row.path.as_str()) .chain(results.references.iter().map(|row| row.path.as_str())) .collect(); let mut ordered: Vec<_> = files.iter().copied().collect(); // Keep the query's captured origin first even after navigating elsewhere. if let Some(origin) = state .display_request .as_ref() .map(|request| request.path.as_str()) { ordered.sort_by_key(|path| (*path != origin, *path)); } for path in ordered { let collapsed = presentation.collapsed(path); let count = results .definitions .iter() .filter(|row| row.path == path) .count() + results .references .iter() .filter(|row| row.path == path) .count(); let suffix = distinguishing_path(path, &files); content = content.push( button( text(format!( "{} {suffix} · {count}", if collapsed { "▸" } else { "▾" } )) .size(12) .wrapping(iced::widget::text::Wrapping::None), ) .height(24) .width(Fill) .padding([2, 6]) .style(style::nav) .on_press_maybe(app.source_navigation_available().then(|| { Message::TextWorkspace(Event::ToggleFile { stamp: results.stamp, visit: app.text_workspace.drawer_visit(), path: path.to_owned(), }) })), ); if collapsed { continue; } for (index, definition) in results .definitions .iter() .enumerate() .filter(|(_, row)| row.path == path) { content = content.push(location_row( declaration_title(&definition.declaration), path, definition.position, provenance( definition.membership, definition.evidence, definition.declaration.identity, false, ), enabled.then(|| { app.source_symbol_message(SymbolEvent::Choose { stamp: results.stamp, visit: app.text_workspace.drawer_visit(), reference: false, index, }) }), )); } for (index, reference) in results .references .iter() .enumerate() .filter(|(_, row)| row.path == path) { let title = reference.context.as_ref().map_or_else( || format!("@{} · lexical use", preview(&reference.alias, 48)), |context| format!("use · {}", declaration_title(context)), ); content = content.push(location_row( title, path, reference.position, provenance( reference.membership, reference.evidence, reference .context .as_ref() .map_or(Identity::Missing, |context| context.identity), true, ), enabled.then(|| { app.source_symbol_message(SymbolEvent::Choose { stamp: results.stamp, visit: app.text_workspace.drawer_visit(), reference: true, index, }) }), )); } // Full path is readable by ordinary scrolling, independently of hover. content = content.push( container( text(path.to_owned()) .font(iced::Font::MONOSPACE) .size(12) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ) .padding([3, 8]), ); } let scope = &results.scope; let explanation = match results.disposition { Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.", Disposition::NoDefinition => "No matching declaration in the loaded sources.", Disposition::Choices => { "Choose a declaration; draft and detached candidates need an explicit choice." } Disposition::Direct => "Definition in the current sources.", Disposition::LexicalReferences => { "Lexical uses of this alias; these do not establish a semantic target." } }; content = content.push(text(explanation).size(12)); if stale { content = content.push( text("Source changed. Run Definition or References again for current locations.") .size(12), ); } if results.total_definitions > 1 { content = content.push( text("Alias collision: uses are not attributed to a single declaration.").size(12), ); } content = content .push( text(format!( "{} · {} included / {} detached / {} unverified files · {} scope issues", if scope.partial() { "Partial include scope" } else { "Complete include scope" }, scope.included_files, scope.detached_files, scope.unverified_files, scope.issues.len() )) .size(12), ) .push( text(if !scope.applied { "Unapplied drafts" } else if scope.validated { "Validated sources" } else { "Draft sources" }) .size(12), ) .push( text(counts( "Declarations", results.definitions.len(), results.total_definitions, results.omitted_definitions, )) .size(12), ) .push( text(counts( "Lexical uses", results.references.len(), results.total_references, results.omitted_references, )) .size(12), ); } let identity = if visible { state .display_request .as_ref() .map(|request| (request.job, app.text_workspace.drawer_visit())) } else { None }; let guarded = crate::diagnostic_input::Guard::new(identity, content); let mut body = scrollable( container(guarded) .width(Fill) .padding(iced::Padding::ZERO.right(14)), ) .id(app.text_workspace.reference_scroll_id()) .height(Fill) .width(Fill); if let Some(stamp) = presentation.stamp() { let visit = app.text_workspace.drawer_visit(); body = body.on_scroll(move |viewport| { Message::TextWorkspace(Event::ReferenceScrolled { stamp, visit, y: viewport.absolute_offset().y, }) }); } body.into() } /// Shortest suffix that still distinguishes duplicate basenames and suffix paths. fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str { let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1)); for start in starts.collect::>().into_iter().rev() { let candidate = &path[start..]; if files .iter() .filter(|other| { **other == candidate || other .strip_suffix(candidate) .is_some_and(|prefix| prefix.ends_with('/')) }) .count() == 1 { return candidate; } } path } fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String { format!("{label}: {total} total · {shown} shown · {omitted} omitted") } fn declaration_title(declaration: &Declaration) -> String { let name = declaration .name .as_ref() .map_or_else(String::new, |name| format!(" \"{}\"", preview(name, 48))); format!( "{}{} · @{}", preview(&declaration.kind, 24), name, preview(&declaration.alias, 48) ) } fn provenance( membership: Membership, evidence: Evidence, identity: Identity, context: bool, ) -> String { let membership = match membership { Membership::Included => "Included", Membership::Detached => "Detached", Membership::Unverified => "Unverified", }; let evidence = match evidence { Evidence::Validated => "Validated", Evidence::Draft => "Draft", }; let collision = match identity { Identity::Collision { count } => format!( " · {}ID collision ({count})", if context { "Context " } else { "" }, ), Identity::Missing | Identity::Unique(_) => String::new(), }; format!("{membership} · {evidence}{collision}") } fn location_row( title: String, path: &str, position: Position, status: String, event: Option, ) -> Element<'static, Message> { let line = position.line.saturating_add(1); let column = position.byte_column.saturating_add(1); let full = format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)"); tooltip( button( container( text(format!("{line}:{column} {title} · {status}")) .size(12) .wrapping(iced::widget::text::Wrapping::None), ) .clip(true) .width(Fill), ) .padding([3, 12]) .height(28) .width(Fill) .style(style::nav) .on_press_maybe(event), text(full).size(12), tooltip::Position::Top, ) .into() } /// Keep headings compact without copying the remainder of a long admitted value. fn preview(value: &str, limit: usize) -> String { let mut chars = value.chars(); let mut result: String = chars .by_ref() .take(limit) .map(|ch| if ch.is_whitespace() { ' ' } else { ch }) .collect(); if chars.next().is_some() { result.push('…'); } result } #[cfg(test)] mod tests { use super::*; #[test] fn suffixes_distinguish_nested_duplicate_basenames_without_changing_unicode() { let files = [ "a/core.arcadia", "b/core.arcadia", "core.arcadia", "救援/actors.arcadia", ] .into_iter() .collect(); assert_eq!( distinguishing_path("a/core.arcadia", &files), "a/core.arcadia" ); assert_eq!( distinguishing_path("b/core.arcadia", &files), "b/core.arcadia" ); assert_eq!(distinguishing_path("core.arcadia", &files), "core.arcadia"); assert_eq!( distinguishing_path("救援/actors.arcadia", &files), "actors.arcadia" ); } }