diff --git a/Cargo.toml b/Cargo.toml index 7869788..76aee5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "term" version = "0.1.0" edition = "2024" +license = "MIT" default-run = "term" diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..edf57eb --- /dev/null +++ b/deny.toml @@ -0,0 +1,55 @@ +# cargo-deny configuration +# Run: cargo deny check +# Docs: https://embarkstudios.github.io/cargo-deny/ + +[graph] +targets = [ + { triple = "aarch64-apple-darwin" }, + { triple = "x86_64-apple-darwin" }, +] + +# ── Security advisories ──────────────────────────────────────────────────────── +[advisories] +# Warn on unmaintained crates; error on known vulnerabilities. +version = 2 +# Transitive unmaintained deps we can't control directly — treat as warnings. +# RUSTSEC-2025-0141: bincode (via portable-pty) +# RUSTSEC-2024-0436: paste (via metal / wgpu) +# RUSTSEC-2017-0008: serial (via portable-pty) +ignore = [ + { id = "RUSTSEC-2025-0141", reason = "transitive dep via portable-pty; no fix available upstream" }, + { id = "RUSTSEC-2024-0436", reason = "transitive dep via wgpu/metal; no fix available upstream" }, + { id = "RUSTSEC-2017-0008", reason = "transitive dep via portable-pty; no fix available upstream" }, +] + +# ── Licenses ────────────────────────────────────────────────────────────────── +[licenses] +version = 2 +# Licenses considered acceptable for dependencies. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "0BSD", + "CC0-1.0", + "Unicode-3.0", + "Unlicense", + "LGPL-2.1-or-later", # via rustybuzz/ttf-parser; dynamic linking exemption +] + +# ── Duplicate crates ────────────────────────────────────────────────────────── +[bans] +# Warn on duplicate versions of the same crate (not error — wgpu pulls in dupes). +multiple-versions = "warn" +# Crates that must never appear in the dependency tree. +deny = [] + +# ── Source hygiene ───────────────────────────────────────────────────────────── +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/src/main.rs b/src/main.rs index 40fe821..02131f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,9 +82,25 @@ struct Pane { ghost_text: Option, engine: Engine, selection: Option, + /// Cached URL scan result. Keyed on (generation, vis_rows, vis_cols) so + /// we only re-scan when terminal content or viewport dimensions change. + url_cache: Vec<(usize, usize, usize, String)>, + url_cache_gen: u64, + url_cache_dims: (usize, usize), } impl Pane { + /// Return URL spans for the current view, using the cached result when + /// the terminal generation and viewport dimensions haven't changed. + fn urls_cached(&mut self, vis_rows: usize, vis_cols: usize) -> &[(usize, usize, usize, String)] { + let cur_gen = self.terminal.state.generation; + if self.url_cache_gen != cur_gen || self.url_cache_dims != (vis_rows, vis_cols) { + self.url_cache = find_urls(&self.terminal.state, vis_rows, vis_cols); + self.url_cache_gen = cur_gen; + self.url_cache_dims = (vis_rows, vis_cols); + } + &self.url_cache + } fn write(&mut self, data: &[u8]) { let _ = self.pty_writer.write_all(data); let _ = self.pty_writer.flush(); @@ -385,16 +401,15 @@ impl TerminalWindow { Some((pi, vrow as i64 - vo, col)) } - fn update_cursor_icon(&self) { + fn update_cursor_icon(&mut self) { let icon = if self.modifiers.super_key() { let is_url = self.pixel_to_pane_cell(self.cursor_pos.0, self.cursor_pos.1) .map(|(pi, row, col)| { - let pane = &self.panes[pi]; let rects = self.pane_rects(); let (_, _, pw, ph) = rects.get(pi).copied().unwrap_or((0., 0., 0., 0.)); let vis_cols = (pw as usize / self.renderer.cell_width).max(1); let vis_rows = (ph as usize / self.renderer.cell_height).max(1); - find_urls(&pane.terminal.state, vis_rows, vis_cols) + self.panes[pi].urls_cached(vis_rows, vis_cols) .iter() .any(|(r, c0, c1, _)| *r == row && col >= *c0 && col < *c1) }) @@ -460,22 +475,25 @@ impl TerminalWindow { let ch = self.renderer.cell_height; let mods_super = self.modifiers.super_key(); - // Build URL underlines per pane - let url_ulines: Vec> = (0..self.panes.len()) - .map(|i| { + // Build URL underlines per pane (uses cached scan when content unchanged) + let url_ulines: Vec> = { + let mut result = Vec::with_capacity(self.panes.len()); + for i in 0..self.panes.len() { if i < rects.len() && mods_super { let (_, _, pw, ph) = rects[i]; let vis_rows = (ph as usize / ch).max(1); let vis_cols = (pw as usize / cw).max(1); - find_urls(&self.panes[i].terminal.state, vis_rows, vis_cols) - .into_iter() - .map(|(r, c0, c1, _)| (r, c0, c1)) - .collect() + let spans = self.panes[i].urls_cached(vis_rows, vis_cols) + .iter() + .map(|(r, c0, c1, _)| (*r, *c0, *c1)) + .collect(); + result.push(spans); } else { - vec![] + result.push(vec![]); } - }) - .collect(); + } + result + }; // Build PaneView slice let mut pane_views: Vec> = Vec::new(); @@ -867,8 +885,20 @@ impl App { let _ = pair.slave.spawn_command(cmd); drop(pair.slave); - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let writer = pair.master.take_writer().expect("take writer"); + let mut reader = match pair.master.try_clone_reader() { + Ok(r) => r, + Err(e) => { + eprintln!("pty clone reader: {e}"); + return None; + } + }; + let writer = match pair.master.take_writer() { + Ok(w) => w, + Err(e) => { + eprintln!("pty take writer: {e}"); + return None; + } + }; let proxy_clone = proxy.clone(); std::thread::spawn(move || { @@ -897,6 +927,9 @@ impl App { ghost_text: None, engine: Engine::new(), selection: None, + url_cache: Vec::new(), + url_cache_gen: u64::MAX, // sentinel: force first-use recompute + url_cache_dims: (0, 0), }) } @@ -1465,10 +1498,10 @@ impl ApplicationHandler for App { let (_, _, pw, ph) = rects.get(pi).copied().unwrap_or((0., 0., 0., 0.)); let vis_cols = (pw as usize / tw.renderer.cell_width).max(1); let vis_rows = (ph as usize / tw.renderer.cell_height).max(1); - let url = find_urls(&tw.panes[pi].terminal.state, vis_rows, vis_cols) - .into_iter() + let url = tw.panes[pi].urls_cached(vis_rows, vis_cols) + .iter() .find(|(r, c0, c1, _)| *r == row && col >= *c0 && col < *c1) - .map(|(_, _, _, u)| u); + .map(|(_, _, _, u)| u.clone()); if let Some(u) = url { // Only open http/https URLs — defence in depth against // file:// or custom-scheme injection via terminal output. diff --git a/src/terminal.rs b/src/terminal.rs index 0fcb781..6d0cce6 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -68,6 +68,9 @@ pub struct TerminalState { pub grid: Vec>, pub scrollback: VecDeque>, pub viewport_offset: usize, // 0 = live view; N = scrolled N rows above live bottom + /// Incremented on every `Terminal::process()` call so callers can detect + /// when content has changed and invalidate derived caches (e.g. URL spans). + pub generation: u64, pub cols: usize, pub rows: usize, pub cursor_row: usize, @@ -118,6 +121,7 @@ impl TerminalState { grid: vec![vec![Cell::default(); cols]; rows], scrollback: VecDeque::new(), viewport_offset: 0, + generation: 0, cols, rows, cursor_row: 0, @@ -2454,6 +2458,7 @@ impl Terminal { for &b in bytes { self.parser.advance(&mut self.state, b); } + self.state.generation = self.state.generation.wrapping_add(1); } pub fn resize(&mut self, cols: usize, rows: usize) {