Files
ARCADIA-Studio/crates/oa-desktop/src/views/source_symbols.rs
T

386 lines
13 KiB
Rust

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::<Vec<_>>().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<Message>,
) -> 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"
);
}
}