The app now writes a diagnostic log to nexus.log in the data directory (0600, rotated at 5 MiB): connection attempts and results, remote closes, reconnect scheduling, host-key/certificate checks with the policy applied, and credentials that couldn't be stored (kind only). Credentials and terminal data are never logged. RUST_LOG sets the level and also mirrors the log to stderr. The remaining eprintln! diagnostics in storage and URL handling go through it too.
174 lines
6.8 KiB
Rust
174 lines
6.8 KiB
Rust
//! Shared widgets: section headers, buttons, chips, key/value grids, virtual lists, empty states.
|
|
//! Colours always come from `Tokens`.
|
|
|
|
use crate::theme::{self, Tokens};
|
|
use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, vec2};
|
|
|
|
/// Small uppercase section header with a hairline.
|
|
pub fn section(ui: &mut Ui, title: &str) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.add_space(4.0);
|
|
ui.label(RichText::new(title.to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
|
|
ui.add_space(1.0);
|
|
}
|
|
|
|
/// An icon + label button in the secondary style.
|
|
pub fn button(ui: &mut Ui, glyph: &str, label: &str) -> Response {
|
|
ui.add(egui::Button::new(format!("{glyph} {label}")))
|
|
}
|
|
|
|
/// Filled accent button for a panel's primary action.
|
|
pub fn primary(ui: &mut Ui, glyph: &str, label: &str) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.add(egui::Button::new(RichText::new(format!("{glyph} {label}")).color(t.accent_text)).fill(t.accent))
|
|
}
|
|
|
|
/// A square icon-only button with a tooltip.
|
|
pub fn icon_button(ui: &mut Ui, glyph: &str, tip: &str) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click());
|
|
if resp.hovered() {
|
|
ui.painter().rect_filled(rect, t.radius, t.hover);
|
|
}
|
|
let c = if resp.hovered() { t.text } else { t.text_dim };
|
|
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, glyph, egui::FontId::proportional(15.0), c);
|
|
resp.on_hover_text(tip)
|
|
}
|
|
|
|
pub fn chip(ui: &mut Ui, text: &str, color: Color32) -> Response {
|
|
let font = theme::semibold(10.5);
|
|
let galley = ui.painter().layout_no_wrap(text.to_owned(), font, color);
|
|
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(12.0, 4.0), Sense::hover());
|
|
ui.painter().rect_filled(rect, 9.0, color.gamma_multiply(0.18));
|
|
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, color);
|
|
resp
|
|
}
|
|
|
|
/// Two-column key/value grid; clicking a value copies it.
|
|
pub fn kv(ui: &mut Ui, id: &str, rows: &[(&str, String)]) {
|
|
let t = Tokens::get(ui.ctx());
|
|
egui::Grid::new(id).num_columns(2).spacing(vec2(12.0, 4.0)).striped(false).show(ui, |ui| {
|
|
for (k, v) in rows {
|
|
ui.label(RichText::new(*k).color(t.text_dim));
|
|
let r = ui.add(egui::Label::new(RichText::new(v).font(theme::mono(12.0))).sense(Sense::click()).wrap());
|
|
if r.on_hover_text("Click to copy").clicked() {
|
|
ui.ctx().copy_text(v.clone());
|
|
}
|
|
ui.end_row();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// A virtualised list of `n` rows of height `h`; `row` draws row `i` and returns whether it
|
|
/// was clicked. Hover and selection are painted for you.
|
|
pub fn rows(ui: &mut Ui, id: &str, n: usize, h: f32, selected: Option<usize>, mut row: impl FnMut(&mut Ui, usize)) -> Option<usize> {
|
|
let t = Tokens::get(ui.ctx());
|
|
let mut clicked = None;
|
|
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, false]).show_rows(ui, h, n, |ui, range| {
|
|
for i in range {
|
|
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
|
|
if selected == Some(i) {
|
|
ui.painter().rect_filled(rect, 3.0, t.row_selected);
|
|
} else if resp.hovered() {
|
|
ui.painter().rect_filled(rect, 3.0, t.hover.gamma_multiply(0.6));
|
|
} else if i % 2 == 1 {
|
|
ui.painter().rect_filled(rect, 0.0, t.text.gamma_multiply(0.025));
|
|
}
|
|
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(6.0, 0.0))).layout(Layout::left_to_right(Align::Center)));
|
|
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
|
// A row never grows: long text truncates (hover shows it in full where the panel adds it).
|
|
child.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
|
|
row(&mut child, i);
|
|
if resp.clicked() {
|
|
clicked = Some(i);
|
|
}
|
|
}
|
|
});
|
|
clicked
|
|
}
|
|
|
|
/// Centered empty state.
|
|
pub fn empty(ui: &mut Ui, glyph: &str, title: &str, hint: &str) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.vertical_centered(|ui| {
|
|
ui.add_space(28.0);
|
|
ui.label(RichText::new(glyph).size(30.0).color(t.text_faint));
|
|
ui.add_space(4.0);
|
|
ui.label(RichText::new(title).font(theme::semibold(13.5)).color(t.text_dim));
|
|
ui.label(RichText::new(hint).color(t.text_faint).size(12.0));
|
|
});
|
|
}
|
|
|
|
/// Dim helper text.
|
|
pub fn hint(ui: &mut Ui, text: &str) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.label(RichText::new(text).color(t.text_faint).size(11.5));
|
|
}
|
|
|
|
/// A small counter line ("1,234 results"), with an optional truncation note.
|
|
pub fn count(ui: &mut Ui, n: usize, noun: &str, capped: bool) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let s = format!("{} {noun}{}", group(n as u64), if capped { " (capped)" } else { "" });
|
|
ui.label(RichText::new(s).color(if capped { t.warning } else { t.text_dim }).size(11.5));
|
|
}
|
|
|
|
/// 1234567 → "1,234,567"
|
|
pub fn group(n: u64) -> String {
|
|
let s = n.to_string();
|
|
let mut out = String::new();
|
|
for (i, c) in s.chars().enumerate() {
|
|
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
|
out.push(',');
|
|
}
|
|
out.push(c);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Whether `url` is a plain web link. Links that arrive from the network (update manifests,
|
|
/// GitHub API responses) are only handed to the system opener when they are http(s), so a
|
|
/// hostile or spoofed server can't launch `file:`, `ssh:` or custom-scheme handlers.
|
|
pub fn is_web_url(url: &str) -> bool {
|
|
let u = url.trim();
|
|
(u.len() > 8 && u[..8].eq_ignore_ascii_case("https://")) || (u.len() > 7 && u[..7].eq_ignore_ascii_case("http://"))
|
|
}
|
|
|
|
/// Opens `url` in the browser if it is a web link; returns false (and opens nothing) otherwise.
|
|
pub fn open_web_url(ctx: &egui::Context, url: &str) -> bool {
|
|
if !is_web_url(url) {
|
|
tracing::warn!(url, "refusing to open a non-web URL");
|
|
return false;
|
|
}
|
|
ctx.open_url(egui::OpenUrl::new_tab(url.trim()));
|
|
true
|
|
}
|
|
|
|
pub fn human_size(n: u64) -> String {
|
|
const U: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
let mut v = n as f64;
|
|
let mut i = 0;
|
|
while v >= 1024.0 && i < 4 {
|
|
v /= 1024.0;
|
|
i += 1;
|
|
}
|
|
if i == 0 { format!("{n} B") } else { format!("{v:.1} {}", U[i]) }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn helpers() {
|
|
assert_eq!(super::group(1234567), "1,234,567");
|
|
}
|
|
|
|
#[test]
|
|
fn only_web_urls_are_opened() {
|
|
for ok in ["https://dixon.cx/downloads/nexus/", "http://localhost:3000/x", " HTTPS://Example.com "] {
|
|
assert!(super::is_web_url(ok), "{ok}");
|
|
}
|
|
for bad in ["", "https://", "file:///etc/passwd", "ssh://evil", "javascript:alert(1)", "ms-msdt:/id x", "ftp://host/", "//host/path"] {
|
|
assert!(!super::is_web_url(bad), "{bad}");
|
|
}
|
|
}
|
|
}
|