From f3bc16b3c5f2a2d6c3abb3c191cd9c63a9e52088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jana=20D=C3=B6nszelmann?= Date: Tue, 24 Feb 2026 23:16:33 +0100 Subject: [PATCH] display hyperlinks --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 8 ++++- src/tui/log_viewer.rs | 6 +++- src/tui/mod.rs | 74 ++++++++++++++++++++++++++++++++++++++++--- src/tui/model.rs | 10 ++++++ 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b369423..b531192 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1179,6 +1179,7 @@ name = "rustc-logviz" version = "0.1.0" dependencies = [ "clap", + "itertools", "jiff", "ratatui", "ratatui-themes", diff --git a/Cargo.toml b/Cargo.toml index 7d4a08c..91128a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ tui-widget-list = "0.15" serde = {version = "1", features = ["derive"]} serde_json = "1" thiserror = "2" +itertools = "0.14" diff --git a/src/main.rs b/src/main.rs index c6e2207..09f8ca1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,11 @@ struct Args { #[arg(long = "logs-dir")] logs_dir: PathBuf, + /// Path where the compiler source code lives, for links in the TUI to work. + #[arg(global = true)] + #[arg(long = "compiler-root")] + compiler_root: Option, + #[arg(trailing_var_arg = true)] #[arg(allow_hyphen_values = true)] #[arg(global = true)] @@ -60,11 +65,12 @@ fn main() { preset, logs_dir, rest, + compiler_root, } = Args::parse(); let rustc_log = match preset { Preset::Show => { - tui::run(logs_dir); + tui::run(logs_dir, compiler_root); exit(0); } Preset::Types => { diff --git a/src/tui/log_viewer.rs b/src/tui/log_viewer.rs index e0ad866..a5c84e4 100644 --- a/src/tui/log_viewer.rs +++ b/src/tui/log_viewer.rs @@ -188,7 +188,7 @@ impl LogViewer { } } - pub fn items(&self, max: usize) -> Option<(Vec<(Rc, usize)>, usize)> { + pub fn items(&mut self, max: usize) -> Option<(Vec<(Rc, usize)>, usize)> { let mut temp_iter = self.curr.iter.clone(); let mut res = Vec::new(); for _ in 0..max { @@ -198,6 +198,10 @@ impl LogViewer { res.push(i); } + if self.curr.selection_offset > res.len() { + self.curr.selection_offset = res.len(); + } + Some((res, self.curr.selection_offset)) } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c2da174..b2bb6ba 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,3 +1,4 @@ +use itertools::Itertools; use ratatui_themes::{Theme, ThemeName}; use std::{ fs::{self, DirEntry}, @@ -22,7 +23,7 @@ use ratatui::{ crossterm::event::{self, Event, KeyCode, KeyModifiers}, layout::{Constraint, HorizontalAlignment, Layout, Rect}, style::Style, - text::Span, + text::{Span, Text}, widgets::{ Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, }, @@ -34,10 +35,10 @@ pub mod model; pub mod processing; pub mod reader; -pub fn run(logs_dir: PathBuf) { +pub fn run(logs_dir: PathBuf, compiler_root: Option) { let terminal = ratatui::init(); let theme = Theme::new(ThemeName::OneDarkPro); - let app_result = App::new(logs_dir, theme).run(terminal); + let app_result = App::new(logs_dir, compiler_root, theme).run(terminal); ratatui::restore(); if let Err(e) = app_result { @@ -97,16 +98,18 @@ fn initialize_filter(lv: &mut LogViewer, kind: Option) -> WipFilter struct App { tabs: Vec, logs_dir: PathBuf, + compiler_root: Option, current_file: Option, theme: Theme, } impl App { - fn new(logs_dir: PathBuf, theme: Theme) -> Self { + fn new(logs_dir: PathBuf, compiler_root: Option, theme: Theme) -> Self { let mut res = Self { tabs: Vec::new(), current_file: None, logs_dir, + compiler_root, theme, }; res.replace_tab(res.choose_file()); @@ -366,13 +369,14 @@ impl Widget for &mut App { }; let footer_area = { - let block = Block::bordered() + let mut block = Block::bordered() .style(default) .border_style(if footer_focused { border_selected } else { border }); + let inner = block.inner(footer_area); block.render(footer_area, buf); inner @@ -441,6 +445,29 @@ impl Widget for &mut App { )); Widget::render(list, main_area, buf); + Clear.render(footer_area, buf); + let [first_line, footer_area] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]) + .areas(footer_area); + if let Some((e, _)) = lv.selected() { + let rustc_root = self.compiler_root.as_ref(); + let (file, line) = e.file_line_string(); + + if let Some(rustc_root) = rustc_root + && let Ok(canonical_rustc_root) = rustc_root.canonicalize() + { + let full_file_path = canonical_rustc_root.join(&file); + Hyperlink::new( + format!("In file: {}", file.display()), + format!("file://{}:{line}", full_file_path.display()), + ) + .render(first_line, buf); + } else { + Span::from(format!("In file: {}:{line}", file.display())) + .render(first_line, buf); + } + } + let items = lv.footer_fields(); let width = 20; let builder = ListBuilder::new(|cx| { @@ -591,3 +618,40 @@ impl Widget for &mut App { } } } + +struct Hyperlink<'content> { + text: Text<'content>, + url: String, +} + +impl<'content> Hyperlink<'content> { + fn new(text: impl Into>, url: impl Into) -> Self { + Self { + text: text.into(), + url: url.into(), + } + } +} + +impl Widget for Hyperlink<'_> { + fn render(self, area: Rect, buffer: &mut Buffer) { + (&self.text).render(area, buffer); + + // this is a hacky workaround for https://github.com/ratatui/ratatui/issues/902, a bug + // in the terminal code that incorrectly calculates the width of ANSI escape sequences. It + // works by rendering the hyperlink as a series of 2-character chunks, which is the + // calculated width of the hyperlink text. + for (i, two_chars) in self + .text + .to_string() + .chars() + .chunks(2) + .into_iter() + .enumerate() + { + let text = two_chars.collect::(); + let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", self.url, text); + buffer[(area.x + i as u16 * 2, area.y)].set_symbol(hyperlink.as_str()); + } + } +} diff --git a/src/tui/model.rs b/src/tui/model.rs index a139c4b..e5ad65b 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -1,6 +1,7 @@ use std::{ collections::BTreeMap, hash::{DefaultHasher, Hash, Hasher}, + path::PathBuf, rc::Rc, sync::OnceLock, }; @@ -38,6 +39,15 @@ pub enum LogEntry { } impl LogEntry { + pub fn file_line_string(&self) -> (PathBuf, usize) { + let entry = match self { + LogEntry::Single { raw } => raw, + LogEntry::Sub { enter, .. } => enter, + }; + + (PathBuf::from(entry.filename.clone()), entry.line_number) + } + pub fn hash(&self) -> u64 { let mut hasher = DefaultHasher::new(); match self {