386 lines
13 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|