Merge GitHub and IRC clients, IRC formatting
This commit is contained in:
Generated
+33
@@ -339,6 +339,12 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -869,6 +875,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -2255,6 +2267,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
@@ -2807,14 +2825,17 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"ureq",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4910,6 +4931,18 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"chunked_transfer",
|
||||
"httpdate",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
//! TermView: an alacritty_terminal grid drawn with egui's painter. Bytes from a session go
|
||||
//! straight into the VT parser; keystrokes, paste, mouse reports and terminal replies come back
|
||||
//! out through `TermResponse` for the app to send to the session (and broadcast targets).
|
||||
|
||||
use crate::terminal_themes::TermTheme;
|
||||
use alacritty_terminal::event::{Event as TermEvent, EventListener};
|
||||
use alacritty_terminal::grid::{Dimensions, Scroll};
|
||||
use alacritty_terminal::index::{Column, Line, Point, Side};
|
||||
use alacritty_terminal::selection::{Selection, SelectionType};
|
||||
use alacritty_terminal::term::cell::Flags;
|
||||
use alacritty_terminal::term::test::TermSize;
|
||||
use alacritty_terminal::term::{Config, Term, TermMode};
|
||||
use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler};
|
||||
use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Listener(Arc<Mutex<Vec<TermEvent>>>);
|
||||
|
||||
impl EventListener for Listener {
|
||||
fn send_event(&self, e: TermEvent) {
|
||||
if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) {
|
||||
self.0.lock().unwrap().push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the context menu asked for; the app carries these out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuAction {
|
||||
Copy,
|
||||
Paste,
|
||||
SelectAll,
|
||||
Find,
|
||||
SendSnippet,
|
||||
PortForwards,
|
||||
SplitRight,
|
||||
SplitDown,
|
||||
Export,
|
||||
Clear,
|
||||
Reset,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TermResponse {
|
||||
/// User input (keys, paste, mouse reports): goes to the session and broadcast targets.
|
||||
pub input: Vec<u8>,
|
||||
/// Terminal replies (DA, DSR, …): go to this session only.
|
||||
pub reply: Vec<u8>,
|
||||
pub resized: Option<(u16, u16)>,
|
||||
pub menu: Option<MenuAction>,
|
||||
pub title: Option<String>,
|
||||
pub bell: bool,
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CursorStyle {
|
||||
#[default]
|
||||
Block,
|
||||
Underline,
|
||||
Bar,
|
||||
}
|
||||
|
||||
impl CursorStyle {
|
||||
pub fn from_id(s: &str) -> Self {
|
||||
match s {
|
||||
"underline" => Self::Underline,
|
||||
"bar" => Self::Bar,
|
||||
_ => Self::Block,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Find {
|
||||
pub query: String,
|
||||
pub case_sensitive: bool,
|
||||
pub regex: bool,
|
||||
/// (grid line, first column, last column inclusive)
|
||||
pub matches: Vec<(i32, usize, usize)>,
|
||||
pub current: usize,
|
||||
pub error: Option<String>,
|
||||
computed_for: Option<(String, bool, bool, u64)>,
|
||||
}
|
||||
|
||||
pub struct TermView {
|
||||
term: Term<Listener>,
|
||||
parser: Processor<StdSyncHandler>,
|
||||
events: Listener,
|
||||
pub font_size: f32,
|
||||
pub cursor: CursorStyle,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
drag: Option<(Point, SelectionType)>,
|
||||
last_click: (f64, u32, Pos2),
|
||||
wheel_acc: f32,
|
||||
mouse_down: Option<u8>,
|
||||
/// Bumped on every feed, so find results know when to recompute.
|
||||
generation: u64,
|
||||
pub find: Option<Find>,
|
||||
focus_requested: bool,
|
||||
}
|
||||
|
||||
const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#;
|
||||
|
||||
fn rgb(c: [u8; 4]) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
|
||||
}
|
||||
|
||||
impl TermView {
|
||||
pub fn new(scrollback: usize, font_size: f32, cursor: CursorStyle) -> TermView {
|
||||
let events = Listener::default();
|
||||
let config = Config { scrolling_history: scrollback.min(100_000), ..Config::default() };
|
||||
TermView {
|
||||
term: Term::new(config, &TermSize::new(80, 24), events.clone()),
|
||||
parser: Processor::new(),
|
||||
events,
|
||||
font_size: font_size.clamp(6.0, 48.0),
|
||||
cursor,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
drag: None,
|
||||
last_click: (0.0, 0, Pos2::ZERO),
|
||||
wheel_acc: 0.0,
|
||||
mouse_down: None,
|
||||
generation: 0,
|
||||
find: None,
|
||||
focus_requested: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.parser.advance(&mut self.term, bytes);
|
||||
self.generation += 1;
|
||||
}
|
||||
|
||||
pub fn size(&self) -> (u16, u16) {
|
||||
(self.cols, self.rows)
|
||||
}
|
||||
|
||||
pub fn request_focus(&mut self) {
|
||||
self.focus_requested = true;
|
||||
}
|
||||
|
||||
pub fn alt_screen(&self) -> bool {
|
||||
self.term.mode().contains(TermMode::ALT_SCREEN)
|
||||
}
|
||||
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.term.selection.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn selection_text(&self) -> Option<String> {
|
||||
self.term.selection_to_string().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn select_all(&mut self) {
|
||||
let top = self.term.grid().topmost_line();
|
||||
let bottom = self.term.grid().bottommost_line();
|
||||
let mut s = Selection::new(SelectionType::Simple, Point::new(top, Column(0)), Side::Left);
|
||||
s.update(Point::new(bottom, Column(self.cols.saturating_sub(1) as usize)), Side::Right);
|
||||
self.term.selection = Some(s);
|
||||
}
|
||||
|
||||
/// Clears the scrollback and the screen (the shell redraws its prompt on its own).
|
||||
pub fn clear(&mut self) {
|
||||
use ansi::Handler;
|
||||
self.term.clear_screen(ansi::ClearMode::Saved);
|
||||
self.feed(b"\x1b[H\x1b[2J");
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
use ansi::Handler;
|
||||
self.term.reset_state();
|
||||
self.generation += 1;
|
||||
}
|
||||
|
||||
/// Every line of scrollback and screen as text, for Export Output.
|
||||
pub fn buffer_text(&self) -> String {
|
||||
let g = self.term.grid();
|
||||
let mut out = String::new();
|
||||
for l in g.topmost_line().0..=g.bottommost_line().0 {
|
||||
out.push_str(self.line_text(Line(l)).0.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text of a grid line plus, for each char, the column it came from.
|
||||
fn line_text(&self, line: Line) -> (String, Vec<usize>) {
|
||||
let row = &self.term.grid()[line];
|
||||
let (mut s, mut cols) = (String::new(), vec![]);
|
||||
for c in 0..self.term.columns() {
|
||||
let cell = &row[Column(c)];
|
||||
if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
|
||||
continue;
|
||||
}
|
||||
s.push(cell.c);
|
||||
cols.push(c);
|
||||
}
|
||||
(s, cols)
|
||||
}
|
||||
|
||||
fn color(&self, c: Color, theme: &TermTheme, bold: bool) -> Color32 {
|
||||
let pal = |i: usize| -> Color32 {
|
||||
if let Some(rgb) = self.term.colors()[i] {
|
||||
return Color32::from_rgb(rgb.r, rgb.g, rgb.b);
|
||||
}
|
||||
if i < 16 {
|
||||
return crate::terminal::rgb(theme.ansi[i]);
|
||||
}
|
||||
if i < 232 {
|
||||
let i = i - 16;
|
||||
let v = |x: usize| if x == 0 { 0 } else { (55 + x * 40) as u8 };
|
||||
return Color32::from_rgb(v(i / 36), v((i / 6) % 6), v(i % 6));
|
||||
}
|
||||
let g = (8 + (i - 232) * 10) as u8;
|
||||
Color32::from_rgb(g, g, g)
|
||||
};
|
||||
match c {
|
||||
Color::Spec(rgb) => Color32::from_rgb(rgb.r, rgb.g, rgb.b),
|
||||
Color::Indexed(i) => {
|
||||
let i = i as usize;
|
||||
pal(if bold && i < 8 { i + 8 } else { i })
|
||||
}
|
||||
Color::Named(n) => match n {
|
||||
NamedColor::Foreground | NamedColor::BrightForeground => rgb(theme.foreground),
|
||||
NamedColor::Background => rgb(theme.background),
|
||||
NamedColor::Cursor => rgb(theme.cursor),
|
||||
NamedColor::DimForeground => rgb(theme.foreground).gamma_multiply(0.7),
|
||||
n => {
|
||||
let i = n as usize;
|
||||
if i < 16 {
|
||||
pal(if bold && i < 8 { i + 8 } else { i })
|
||||
} else if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize).contains(&i) {
|
||||
pal(i - NamedColor::DimBlack as usize).gamma_multiply(0.7)
|
||||
} else {
|
||||
rgb(theme.foreground)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_metrics(&self, ui: &Ui) -> (FontId, f32, f32) {
|
||||
let font = FontId::monospace(self.font_size);
|
||||
let (w, h) = ui.fonts_mut(|f| (f.glyph_width(&font, 'M'), f.row_height(&font)));
|
||||
(font, w.max(1.0), h.ceil().max(1.0))
|
||||
}
|
||||
|
||||
/// Grid point under a screen position.
|
||||
fn point_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> (Point, Side) {
|
||||
let col = ((p.x - origin.x) / cw).floor().clamp(0.0, self.cols.saturating_sub(1) as f32);
|
||||
let row = ((p.y - origin.y) / ch).floor().clamp(0.0, self.rows.saturating_sub(1) as f32);
|
||||
let side = if (p.x - origin.x) / cw - col > 0.5 { Side::Right } else { Side::Left };
|
||||
let line = Line(row as i32 - self.term.grid().display_offset() as i32);
|
||||
(Point::new(line, Column(col as usize)), side)
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut Ui, theme: &TermTheme, active: bool) -> TermResponse {
|
||||
let mut out = TermResponse::default();
|
||||
let (font, cw, ch) = self.cell_metrics(ui);
|
||||
let pad = vec2(6.0, 4.0);
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
let resp = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
let id = resp.id;
|
||||
let origin = rect.min + pad;
|
||||
|
||||
// Fit the grid to the widget.
|
||||
let cols = (((rect.width() - 2.0 * pad.x) / cw).floor() as u16).max(2);
|
||||
let rows = (((rect.height() - 2.0 * pad.y) / ch).floor() as u16).max(1);
|
||||
if (cols, rows) != (self.cols, self.rows) {
|
||||
self.cols = cols;
|
||||
self.rows = rows;
|
||||
self.term.resize(TermSize::new(cols as usize, rows as usize));
|
||||
out.resized = Some((cols, rows));
|
||||
}
|
||||
|
||||
if resp.clicked() || resp.drag_started() || (active && self.focus_requested) {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
self.focus_requested = false;
|
||||
}
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
out.focused = focused;
|
||||
if focused {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
});
|
||||
self.keyboard(ui, &mut out);
|
||||
let cur = self.term.grid().cursor.point;
|
||||
let cursor_rect = Rect::from_min_size(origin + vec2(cur.column.0 as f32 * cw, cur.line.0 as f32 * ch), vec2(cw, ch));
|
||||
ui.output_mut(|o| o.ime = Some(egui::output::IMEOutput { rect, cursor_rect }));
|
||||
}
|
||||
self.mouse(ui, &resp, origin, cw, ch, &mut out);
|
||||
|
||||
// Terminal-originated events.
|
||||
for e in std::mem::take(&mut *self.events.0.lock().unwrap()) {
|
||||
match e {
|
||||
TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()),
|
||||
TermEvent::Title(t) => out.title = Some(t),
|
||||
TermEvent::Bell => out.bell = true,
|
||||
TermEvent::ClipboardStore(_, s) => ui.ctx().copy_text(s),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.paint(ui, rect, origin, &font, cw, ch, theme, focused);
|
||||
|
||||
resp.context_menu(|ui| {
|
||||
let m = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" };
|
||||
let items: [(MenuAction, &str, String); 11] = [
|
||||
(MenuAction::Copy, "Copy", format!("{m}C")),
|
||||
(MenuAction::Paste, "Paste", format!("{m}V")),
|
||||
(MenuAction::SelectAll, "Select All", String::new()),
|
||||
(MenuAction::Find, "Find", format!("{m}F")),
|
||||
(MenuAction::SendSnippet, "Send Snippet", format!("{m}Shift+S")),
|
||||
(MenuAction::PortForwards, "Port Forwards", String::new()),
|
||||
(MenuAction::SplitRight, "Split Right", format!("{m}Shift+D")),
|
||||
(MenuAction::SplitDown, "Split Down", format!("{m}Shift+E")),
|
||||
(MenuAction::Export, "Export Output", String::new()),
|
||||
(MenuAction::Clear, "Clear", String::new()),
|
||||
(MenuAction::Reset, "Reset", String::new()),
|
||||
];
|
||||
for (i, (a, label, keys)) in items.into_iter().enumerate() {
|
||||
if matches!(i, 3 | 5 | 6 | 8) {
|
||||
ui.separator();
|
||||
}
|
||||
if ui.add(egui::Button::new(label).shortcut_text(keys).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
out.menu = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(MenuAction::Copy) = out.menu
|
||||
&& let Some(s) = self.selection_text()
|
||||
{
|
||||
ui.ctx().copy_text(s);
|
||||
out.menu = None;
|
||||
}
|
||||
if out.menu == Some(MenuAction::SelectAll) {
|
||||
self.select_all();
|
||||
out.menu = None;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn keyboard(&mut self, ui: &mut Ui, out: &mut TermResponse) {
|
||||
let events = ui.input(|i| i.events.clone());
|
||||
let mode = *self.term.mode();
|
||||
let alt_meta = !cfg!(target_os = "macos");
|
||||
let mut typed = false;
|
||||
for e in events {
|
||||
match e {
|
||||
Event::Copy => match self.selection_text() {
|
||||
Some(s) => ui.ctx().copy_text(s),
|
||||
None => out.input.push(3),
|
||||
},
|
||||
Event::Cut => out.input.push(0x18),
|
||||
Event::Paste(text) => {
|
||||
self.paste(&text, out);
|
||||
typed = true;
|
||||
}
|
||||
Event::Text(t) => {
|
||||
if t.chars().any(|c| c.is_control()) {
|
||||
continue;
|
||||
}
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
if alt && alt_meta {
|
||||
out.input.push(0x1b);
|
||||
}
|
||||
out.input.extend_from_slice(t.as_bytes());
|
||||
typed = true;
|
||||
}
|
||||
Event::Ime(egui::ImeEvent::Commit(t)) => {
|
||||
out.input.extend_from_slice(t.as_bytes());
|
||||
typed = true;
|
||||
}
|
||||
Event::Key { key, pressed: true, modifiers, .. } => {
|
||||
// Shift+PageUp/PageDown/Home/End scroll the local scrollback.
|
||||
if modifiers.shift && !modifiers.ctrl && !mode.contains(TermMode::ALT_SCREEN) {
|
||||
let page = self.rows as i32 - 1;
|
||||
let scroll = match key {
|
||||
Key::PageUp => Some(Scroll::Delta(page)),
|
||||
Key::PageDown => Some(Scroll::Delta(-page)),
|
||||
Key::Home => Some(Scroll::Top),
|
||||
Key::End => Some(Scroll::Bottom),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = scroll {
|
||||
self.term.scroll_display(s);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(bytes) = key_bytes(key, modifiers, mode) {
|
||||
out.input.extend_from_slice(&bytes);
|
||||
typed = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if typed {
|
||||
self.term.selection = None;
|
||||
self.term.scroll_display(Scroll::Bottom);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paste(&mut self, text: &str, out: &mut TermResponse) {
|
||||
let text = text.replace("\r\n", "\r").replace('\n', "\r");
|
||||
if self.term.mode().contains(TermMode::BRACKETED_PASTE) {
|
||||
out.input.extend_from_slice(b"\x1b[200~");
|
||||
out.input.extend_from_slice(text.replace('\x1b', "").as_bytes());
|
||||
out.input.extend_from_slice(b"\x1b[201~");
|
||||
} else {
|
||||
out.input.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(&mut self, ui: &mut Ui, resp: &egui::Response, origin: Pos2, cw: f32, ch: f32, out: &mut TermResponse) {
|
||||
let mode = *self.term.mode();
|
||||
let reporting = mode.intersects(TermMode::MOUSE_MODE) && !ui.input(|i| i.modifiers.shift);
|
||||
let hover = resp.hover_pos();
|
||||
let events = ui.input(|i| i.events.clone());
|
||||
let to_cell = |p: Pos2| {
|
||||
let c = ((p.x - origin.x) / cw).floor().max(0.0) as u32;
|
||||
let r = ((p.y - origin.y) / ch).floor().max(0.0) as u32;
|
||||
(c.min(self.cols as u32 - 1), r.min(self.rows as u32 - 1))
|
||||
};
|
||||
for e in &events {
|
||||
match e {
|
||||
Event::MouseWheel { delta, unit, modifiers } if hover.is_some() => {
|
||||
if modifiers.command {
|
||||
continue; // left for zoom
|
||||
}
|
||||
let lines = match unit {
|
||||
egui::MouseWheelUnit::Point => delta.y / ch,
|
||||
egui::MouseWheelUnit::Line => delta.y,
|
||||
egui::MouseWheelUnit::Page => delta.y * self.rows as f32,
|
||||
};
|
||||
self.wheel_acc += lines;
|
||||
let n = self.wheel_acc.trunc() as i32;
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
self.wheel_acc -= n as f32;
|
||||
if reporting {
|
||||
let (c, r) = to_cell(hover.unwrap());
|
||||
for _ in 0..n.unsigned_abs() {
|
||||
out.input.extend(mouse_report(if n > 0 { 64 } else { 65 }, c, r, true, mode));
|
||||
}
|
||||
} else if mode.contains(TermMode::ALT_SCREEN) {
|
||||
let seq: &[u8] = if n > 0 { b"\x1b[A" } else { b"\x1b[B" };
|
||||
for _ in 0..n.unsigned_abs() {
|
||||
out.input.extend_from_slice(seq);
|
||||
}
|
||||
} else {
|
||||
self.term.scroll_display(Scroll::Delta(n));
|
||||
}
|
||||
}
|
||||
Event::PointerButton { pos, button, pressed, modifiers } if resp.rect.contains(*pos) => {
|
||||
let b = match button {
|
||||
PointerButton::Primary => 0,
|
||||
PointerButton::Middle => 1,
|
||||
PointerButton::Secondary => 2,
|
||||
_ => continue,
|
||||
};
|
||||
if reporting && b != 2 || reporting && mode.contains(TermMode::MOUSE_MODE) && b == 2 && !*pressed {
|
||||
let (c, r) = to_cell(*pos);
|
||||
out.input.extend(mouse_report(b, c, r, *pressed, mode));
|
||||
self.mouse_down = pressed.then_some(b);
|
||||
continue;
|
||||
}
|
||||
if b != 0 {
|
||||
continue;
|
||||
}
|
||||
if *pressed {
|
||||
if modifiers.command && let Some(url) = self.url_at(origin, cw, ch, *pos) {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
continue;
|
||||
}
|
||||
let now = ui.input(|i| i.time);
|
||||
let (t, n, p) = self.last_click;
|
||||
let count = if now - t < 0.4 && p.distance(*pos) < 4.0 { n % 3 + 1 } else { 1 };
|
||||
self.last_click = (now, count, *pos);
|
||||
let (point, side) = self.point_at(origin, cw, ch, *pos);
|
||||
let ty = match count {
|
||||
2 => SelectionType::Semantic,
|
||||
3 => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
self.term.selection = (count > 1 || modifiers.shift).then(|| Selection::new(ty, point, side));
|
||||
if modifiers.shift
|
||||
&& let Some(s) = &mut self.term.selection
|
||||
{
|
||||
s.update(point, side);
|
||||
}
|
||||
self.drag = Some((point, ty));
|
||||
} else {
|
||||
self.drag = None;
|
||||
}
|
||||
}
|
||||
Event::PointerMoved(p) => {
|
||||
if reporting {
|
||||
if let Some(b) = self.mouse_down
|
||||
&& (mode.contains(TermMode::MOUSE_DRAG) || mode.contains(TermMode::MOUSE_MOTION))
|
||||
&& resp.rect.contains(*p)
|
||||
{
|
||||
let (c, r) = to_cell(*p);
|
||||
out.input.extend(mouse_report(b + 32, c, r, true, mode));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((start, ty)) = self.drag {
|
||||
let (point, side) = self.point_at(origin, cw, ch, *p);
|
||||
if self.term.selection.is_none() && point != start {
|
||||
self.term.selection = Some(Selection::new(ty, start, Side::Left));
|
||||
}
|
||||
if let Some(s) = &mut self.term.selection {
|
||||
s.update(point, side);
|
||||
}
|
||||
// Auto-scroll while dragging past the edges.
|
||||
if p.y < resp.rect.top() {
|
||||
self.term.scroll_display(Scroll::Delta(1));
|
||||
} else if p.y > resp.rect.bottom() {
|
||||
self.term.scroll_display(Scroll::Delta(-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(p) = hover
|
||||
&& ui.input(|i| i.modifiers.command)
|
||||
&& self.url_at(origin, cw, ch, p).is_some()
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
} else if hover.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
}
|
||||
|
||||
fn url_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> Option<String> {
|
||||
let (point, _) = self.point_at(origin, cw, ch, p);
|
||||
let (text, cols) = self.line_text(point.line);
|
||||
let re = regex::Regex::new(URL_RE).ok()?;
|
||||
re.find_iter(&text).find_map(|m| {
|
||||
let start = text[..m.start()].chars().count();
|
||||
let end = start + m.as_str().chars().count() - 1;
|
||||
(cols.get(start)? <= &point.column.0 && cols.get(end)? >= &point.column.0).then(|| m.as_str().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint(&mut self, ui: &mut Ui, rect: Rect, origin: Pos2, font: &FontId, cw: f32, ch: f32, theme: &TermTheme, focused: bool) {
|
||||
let painter = ui.painter_at(rect);
|
||||
let bg_default = rgb(theme.background);
|
||||
painter.rect_filled(rect, 0.0, bg_default);
|
||||
let offset = self.term.grid().display_offset() as i32;
|
||||
let selection = self.term.selection.as_ref().and_then(|s| s.to_range(&self.term));
|
||||
let sel_bg = rgb(theme.selection);
|
||||
self.refresh_find();
|
||||
let finds: Vec<(i32, usize, usize, bool)> = self
|
||||
.find
|
||||
.as_ref()
|
||||
.map(|f| f.matches.iter().enumerate().map(|(i, &(l, a, b))| (l, a, b, i == f.current)).collect())
|
||||
.unwrap_or_default();
|
||||
let ncols = self.cols as usize;
|
||||
for vr in 0..self.rows as i32 {
|
||||
let line = Line(vr - offset);
|
||||
if line.0 > self.term.grid().bottommost_line().0 {
|
||||
break;
|
||||
}
|
||||
let y = origin.y + vr as f32 * ch;
|
||||
let row = &self.term.grid()[line];
|
||||
let mut run = String::new();
|
||||
let mut run_x = 0.0;
|
||||
let mut run_color = Color32::TRANSPARENT;
|
||||
let flush = |run: &mut String, x: f32, color: Color32| {
|
||||
if !run.trim().is_empty() {
|
||||
painter.text(pos2(x, y), egui::Align2::LEFT_TOP, run.as_str(), font.clone(), color);
|
||||
}
|
||||
run.clear();
|
||||
};
|
||||
for c in 0..ncols.min(self.term.columns()) {
|
||||
let cell = &row[Column(c)];
|
||||
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||
continue;
|
||||
}
|
||||
let bold = cell.flags.contains(Flags::BOLD);
|
||||
let mut fg = self.color(cell.fg, theme, bold);
|
||||
let mut bg = self.color(cell.bg, theme, false);
|
||||
if cell.flags.contains(Flags::INVERSE) {
|
||||
std::mem::swap(&mut fg, &mut bg);
|
||||
}
|
||||
if cell.flags.contains(Flags::DIM) {
|
||||
fg = fg.gamma_multiply(0.66);
|
||||
}
|
||||
let point = Point::new(line, Column(c));
|
||||
let width = if cell.flags.contains(Flags::WIDE_CHAR) { 2.0 } else { 1.0 };
|
||||
let x = origin.x + c as f32 * cw;
|
||||
let cell_rect = Rect::from_min_size(pos2(x, y), vec2(cw * width, ch));
|
||||
if selection.as_ref().is_some_and(|s| s.contains(point)) {
|
||||
bg = sel_bg;
|
||||
} else if let Some(&(_, _, _, cur)) = finds.iter().find(|(l, a, b, _)| *l == line.0 && (*a..=*b).contains(&c)) {
|
||||
bg = if cur { Color32::from_rgb(255, 160, 40) } else { Color32::from_rgba_unmultiplied(255, 200, 60, 110) };
|
||||
if cur {
|
||||
fg = Color32::BLACK;
|
||||
}
|
||||
}
|
||||
if bg != bg_default {
|
||||
painter.rect_filled(cell_rect, 0.0, bg);
|
||||
}
|
||||
if cell.flags.intersects(Flags::ALL_UNDERLINES) {
|
||||
painter.hline(cell_rect.x_range(), y + ch - 1.5, Stroke::new(1.0, fg));
|
||||
}
|
||||
if cell.flags.contains(Flags::STRIKEOUT) {
|
||||
painter.hline(cell_rect.x_range(), y + ch * 0.55, Stroke::new(1.0, fg));
|
||||
}
|
||||
let hidden = cell.flags.contains(Flags::HIDDEN);
|
||||
let chr = if hidden { ' ' } else { cell.c };
|
||||
// ASCII runs share one text call; anything else is placed on its own cell so a
|
||||
// fallback font's advance can't push later glyphs off the grid.
|
||||
if chr.is_ascii() && width == 1.0 && fg == run_color && !run.is_empty() {
|
||||
run.push(chr);
|
||||
continue;
|
||||
}
|
||||
flush(&mut run, run_x, run_color);
|
||||
if chr.is_ascii() && width == 1.0 {
|
||||
run.push(chr);
|
||||
run_x = x;
|
||||
run_color = fg;
|
||||
} else if chr != ' ' {
|
||||
let mut s = String::from(chr);
|
||||
if let Some(z) = cell.zerowidth() {
|
||||
s.extend(z);
|
||||
}
|
||||
painter.text(cell_rect.center(), egui::Align2::CENTER_CENTER, s, font.clone(), fg);
|
||||
}
|
||||
}
|
||||
flush(&mut run, run_x, run_color);
|
||||
}
|
||||
|
||||
// Cursor.
|
||||
let mode = *self.term.mode();
|
||||
let cur = self.term.grid().cursor.point;
|
||||
let vr = cur.line.0 + offset;
|
||||
if mode.contains(TermMode::SHOW_CURSOR) && (0..self.rows as i32).contains(&vr) {
|
||||
let blink_on = !focused || (ui.input(|i| i.time) * 1.9) as i64 % 2 == 0;
|
||||
if focused {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530));
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(origin.x + cur.column.0 as f32 * cw, origin.y + vr as f32 * ch), vec2(cw, ch));
|
||||
let c = rgb(theme.cursor);
|
||||
let shape = match self.term.cursor_style().shape {
|
||||
CursorShape::Underline => CursorStyle::Underline,
|
||||
CursorShape::Beam => CursorStyle::Bar,
|
||||
CursorShape::Block if self.term.cursor_style() == alacritty_terminal::vte::ansi::CursorStyle::default() => self.cursor,
|
||||
_ => CursorStyle::Block,
|
||||
};
|
||||
if !focused {
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Inside);
|
||||
} else if blink_on {
|
||||
match shape {
|
||||
CursorStyle::Block => {
|
||||
painter.rect_filled(r, 0.0, c);
|
||||
let cell = &self.term.grid()[cur.line][cur.column];
|
||||
if cell.c != ' ' {
|
||||
painter.text(r.left_top(), egui::Align2::LEFT_TOP, cell.c, font.clone(), rgb(theme.cursor_accent));
|
||||
}
|
||||
}
|
||||
CursorStyle::Underline => painter.rect_filled(Rect::from_min_max(pos2(r.left(), r.bottom() - 2.0), r.max), 0.0, c),
|
||||
CursorStyle::Bar => painter.rect_filled(Rect::from_min_size(r.min, vec2(2.0, ch)), 0.0, c),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll position indicator when looking at history.
|
||||
if offset > 0 {
|
||||
let total = (self.term.grid().history_size() + self.rows as usize) as f32;
|
||||
let h = (rect.height() * self.rows as f32 / total).max(16.0);
|
||||
let top = rect.top() + (rect.height() - h) * (1.0 - offset as f32 / self.term.grid().history_size().max(1) as f32);
|
||||
painter.rect_filled(Rect::from_min_size(pos2(rect.right() - 5.0, top), vec2(3.0, h)), 2.0, rgb(theme.foreground).gamma_multiply(0.35));
|
||||
}
|
||||
}
|
||||
|
||||
// --- find ---------------------------------------------------------------------------
|
||||
|
||||
fn refresh_find(&mut self) {
|
||||
let generation = self.generation;
|
||||
let Some(f) = &self.find else { return };
|
||||
let key = (f.query.clone(), f.case_sensitive, f.regex, generation);
|
||||
if f.computed_for.as_ref() == Some(&key) {
|
||||
return;
|
||||
}
|
||||
let pattern = if f.regex { f.query.clone() } else { regex::escape(&f.query) };
|
||||
let pattern = if f.case_sensitive { pattern } else { format!("(?i){pattern}") };
|
||||
let mut matches = vec![];
|
||||
let mut error = None;
|
||||
if !f.query.is_empty() {
|
||||
match regex::Regex::new(&pattern) {
|
||||
Ok(re) => {
|
||||
let g = self.term.grid();
|
||||
for l in g.topmost_line().0..=g.bottommost_line().0 {
|
||||
let (text, cols) = self.line_text(Line(l));
|
||||
for m in re.find_iter(&text) {
|
||||
if m.as_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let a = text[..m.start()].chars().count();
|
||||
let b = a + m.as_str().chars().count() - 1;
|
||||
if let (Some(&ca), Some(&cb)) = (cols.get(a), cols.get(b)) {
|
||||
matches.push((l, ca, cb));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => error = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
let f = self.find.as_mut().unwrap();
|
||||
let had = f.matches.len();
|
||||
f.matches = matches;
|
||||
f.error = error;
|
||||
if had == 0 || f.current >= f.matches.len() {
|
||||
f.current = f.matches.len().saturating_sub(1);
|
||||
}
|
||||
f.computed_for = Some(key);
|
||||
}
|
||||
|
||||
/// Moves to the next (or previous) match and scrolls it into view.
|
||||
pub fn find_step(&mut self, forward: bool) {
|
||||
self.refresh_find();
|
||||
let Some(f) = &mut self.find else { return };
|
||||
if f.matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = f.matches.len();
|
||||
f.current = if forward { (f.current + 1) % n } else { (f.current + n - 1) % n };
|
||||
let line = f.matches[f.current].0;
|
||||
self.reveal(line);
|
||||
}
|
||||
|
||||
fn reveal(&mut self, line: i32) {
|
||||
let offset = self.term.grid().display_offset() as i32;
|
||||
let rows = self.rows as i32;
|
||||
let vr = line + offset;
|
||||
if !(0..rows).contains(&vr) {
|
||||
let target = (rows / 2 - line).clamp(0, self.term.grid().history_size() as i32);
|
||||
self.term.scroll_display(Scroll::Delta(target - offset));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_counts(&self) -> (usize, usize) {
|
||||
self.find.as_ref().map_or((0, 0), |f| (if f.matches.is_empty() { 0 } else { f.current + 1 }, f.matches.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// xterm encoding of a mouse event at 0-based cell (c, r).
|
||||
fn mouse_report(button: u8, c: u32, r: u32, pressed: bool, mode: TermMode) -> Vec<u8> {
|
||||
if mode.contains(TermMode::SGR_MOUSE) {
|
||||
format!("\x1b[<{button};{};{}{}", c + 1, r + 1, if pressed { 'M' } else { 'm' }).into_bytes()
|
||||
} else {
|
||||
let b = if pressed { button } else { 3 };
|
||||
let enc = |v: u32| (32 + v + 1).min(255) as u8;
|
||||
vec![0x1b, b'[', b'M', 32 + b, enc(c), enc(r)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes an xterm sends for a non-text key.
|
||||
pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option<Vec<u8>> {
|
||||
let app_cursor = mode.contains(TermMode::APP_CURSOR);
|
||||
let mods = 1 + (m.shift as u8) + 2 * (m.alt as u8) + 4 * (m.ctrl as u8);
|
||||
let csi = |final_: char| -> Vec<u8> {
|
||||
if mods > 1 {
|
||||
format!("\x1b[1;{mods}{final_}").into_bytes()
|
||||
} else if app_cursor {
|
||||
format!("\x1bO{final_}").into_bytes()
|
||||
} else {
|
||||
format!("\x1b[{final_}").into_bytes()
|
||||
}
|
||||
};
|
||||
let tilde = |n: u8| -> Vec<u8> { if mods > 1 { format!("\x1b[{n};{mods}~") } else { format!("\x1b[{n}~") }.into_bytes() };
|
||||
let ss3 = |final_: char| -> Vec<u8> { if mods > 1 { format!("\x1b[1;{mods}{final_}").into_bytes() } else { format!("\x1bO{final_}").into_bytes() } };
|
||||
let esc = |b: &[u8]| -> Vec<u8> {
|
||||
let mut v = if m.alt { vec![0x1b] } else { vec![] };
|
||||
v.extend_from_slice(b);
|
||||
v
|
||||
};
|
||||
Some(match key {
|
||||
Key::Enter => esc(b"\r"),
|
||||
Key::Backspace => esc(if m.ctrl { b"\x08" } else { b"\x7f" }),
|
||||
Key::Tab if m.shift => b"\x1b[Z".to_vec(),
|
||||
Key::Tab => b"\t".to_vec(),
|
||||
Key::Escape => b"\x1b".to_vec(),
|
||||
Key::ArrowUp => csi('A'),
|
||||
Key::ArrowDown => csi('B'),
|
||||
Key::ArrowRight => csi('C'),
|
||||
Key::ArrowLeft => csi('D'),
|
||||
Key::Home => csi('H'),
|
||||
Key::End => csi('F'),
|
||||
Key::Insert => tilde(2),
|
||||
Key::Delete => tilde(3),
|
||||
Key::PageUp => tilde(5),
|
||||
Key::PageDown => tilde(6),
|
||||
Key::F1 => ss3('P'),
|
||||
Key::F2 => ss3('Q'),
|
||||
Key::F3 => ss3('R'),
|
||||
Key::F4 => ss3('S'),
|
||||
Key::F5 => tilde(15),
|
||||
Key::F6 => tilde(17),
|
||||
Key::F7 => tilde(18),
|
||||
Key::F8 => tilde(19),
|
||||
Key::F9 => tilde(20),
|
||||
Key::F10 => tilde(21),
|
||||
Key::F11 => tilde(23),
|
||||
Key::F12 => tilde(24),
|
||||
_ if m.ctrl && !m.command_only() => {
|
||||
let b = match key {
|
||||
Key::Space | Key::Num2 => 0,
|
||||
Key::OpenBracket | Key::Num3 => 0x1b,
|
||||
Key::Backslash | Key::Num4 => 0x1c,
|
||||
Key::CloseBracket | Key::Num5 => 0x1d,
|
||||
Key::Num6 => 0x1e,
|
||||
Key::Slash | Key::Minus | Key::Num7 => 0x1f,
|
||||
Key::Num8 => 0x7f,
|
||||
k => {
|
||||
let name = k.name();
|
||||
let c = name.chars().next().filter(|c| name.len() == 1 && c.is_ascii_alphabetic())?;
|
||||
(c.to_ascii_uppercase() as u8) & 0x1f
|
||||
}
|
||||
};
|
||||
esc(&[b])
|
||||
}
|
||||
_ if m.alt && !m.ctrl && !cfg!(target_os = "macos") => return None, // arrives as Text with ESC prefix
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
trait CommandOnly {
|
||||
fn command_only(&self) -> bool;
|
||||
}
|
||||
|
||||
impl CommandOnly for Modifiers {
|
||||
/// On macOS, Cmd is the app's modifier; Ctrl still produces control codes.
|
||||
fn command_only(&self) -> bool {
|
||||
cfg!(target_os = "macos") && self.mac_cmd && !self.ctrl
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_encode_like_xterm() {
|
||||
let none = Modifiers::NONE;
|
||||
assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::empty()).unwrap(), b"\x1b[A");
|
||||
assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::APP_CURSOR).unwrap(), b"\x1bOA");
|
||||
assert_eq!(key_bytes(Key::ArrowLeft, Modifiers::CTRL, TermMode::empty()).unwrap(), b"\x1b[1;5D");
|
||||
assert_eq!(key_bytes(Key::C, Modifiers::CTRL, TermMode::empty()).unwrap(), [3]);
|
||||
assert_eq!(key_bytes(Key::F5, none, TermMode::empty()).unwrap(), b"\x1b[15~");
|
||||
assert_eq!(key_bytes(Key::Backspace, none, TermMode::empty()).unwrap(), [0x7f]);
|
||||
assert!(key_bytes(Key::A, none, TermMode::empty()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feeds_text_and_finds_it() {
|
||||
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);
|
||||
t.feed(b"hello \x1b[31mworld\x1b[0m\r\nsecond world\r\n");
|
||||
assert!(t.buffer_text().starts_with("hello world\nsecond world"));
|
||||
t.find = Some(Find { query: "WORLD".into(), ..Default::default() });
|
||||
t.refresh_find();
|
||||
assert_eq!(t.find_counts().1, 2);
|
||||
t.find.as_mut().unwrap().case_sensitive = true;
|
||||
t.refresh_find();
|
||||
assert_eq!(t.find_counts().1, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_to_device_attributes() {
|
||||
let mut t = TermView::new(10, 14.0, CursorStyle::Block);
|
||||
t.feed(b"\x1b[6n");
|
||||
let ev = t.events.0.lock().unwrap();
|
||||
assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_reports() {
|
||||
assert_eq!(mouse_report(0, 0, 0, true, TermMode::SGR_MOUSE), b"\x1b[<0;1;1M");
|
||||
assert_eq!(mouse_report(0, 2, 3, true, TermMode::empty()), vec![0x1b, b'[', b'M', 32, 35, 36]);
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,11 @@ sha1 = "0.11.0"
|
||||
portable-pty = "0.9.0"
|
||||
serialport = { version = "4", default-features = false }
|
||||
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
webpki-roots = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tiny_http = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Lightweight GitHub REST client for repository metadata and archive downloads. Works with
|
||||
//! github.com and GitHub Enterprise via a configurable API base URL.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, FixedOffset, SecondsFormat};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::GitHubRepo;
|
||||
|
||||
const MAX_JSON_BODY_BYTES: u64 = 4 << 20;
|
||||
const MAX_ERROR_BODY_BYTES: u64 = 64 << 10;
|
||||
const MAX_ARCHIVE_BYTES: u64 = 2 << 30;
|
||||
const USER_AGENT: &str = "NexusTerminal/1.0";
|
||||
const COMMIT_LOOKUP_WORKERS: usize = 5;
|
||||
|
||||
pub struct Client {
|
||||
base_url: String,
|
||||
token: String,
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiRepo {
|
||||
#[serde(default)]
|
||||
full_name: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
#[serde(default)]
|
||||
default_branch: Option<String>,
|
||||
#[serde(default)]
|
||||
private: Option<bool>,
|
||||
#[serde(default)]
|
||||
fork: Option<bool>,
|
||||
#[serde(default)]
|
||||
archived: Option<bool>,
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
#[serde(default)]
|
||||
stargazers_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pushed_at: Option<String>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
owner: Option<ApiOwner>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiOwner {
|
||||
#[serde(default)]
|
||||
login: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ApiPerson {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`.
|
||||
pub fn new(base_url: &str, token: &str) -> Client {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into();
|
||||
Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent }
|
||||
}
|
||||
|
||||
fn get(&self, url: &str) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
|
||||
self.agent
|
||||
.get(url)
|
||||
.header("Authorization", &format!("Bearer {}", self.token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.call()
|
||||
}
|
||||
|
||||
/// Confirms the token works and returns the authenticated login.
|
||||
pub fn validate_token(&self) -> crate::Result<String> {
|
||||
let mut resp = self
|
||||
.get(&format!("{}/user", self.base_url))
|
||||
.map_err(|e| format!("contacting GitHub API: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
return Err(format!("token validation failed (HTTP {status})").into());
|
||||
}
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading user response: {e}"))?;
|
||||
#[derive(Deserialize)]
|
||||
struct User {
|
||||
#[serde(default)]
|
||||
login: Option<String>,
|
||||
}
|
||||
let user: User = serde_json::from_slice(&body).map_err(|e| format!("decoding user: {e}"))?;
|
||||
Ok(user.login.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// All repositories visible to the token, newest push first, enriched with latest-commit info.
|
||||
pub fn fetch_repos(&self) -> crate::Result<Vec<GitHubRepo>> {
|
||||
self.fetch_repos_with_progress(|_, _| {})
|
||||
}
|
||||
|
||||
/// Like `fetch_repos`, reporting commit-enrichment progress as `(done, total)`; it is called
|
||||
/// from worker threads.
|
||||
pub fn fetch_repos_with_progress(
|
||||
&self,
|
||||
progress: impl Fn(usize, usize) + Sync,
|
||||
) -> crate::Result<Vec<GitHubRepo>> {
|
||||
let mut all: Vec<ApiRepo> = Vec::new();
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc",
|
||||
self.base_url
|
||||
);
|
||||
let mut resp = self.get(&url).map_err(|e| format!("fetching repos page {page}: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading response body: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status == 401 || status == 403 {
|
||||
return Err(
|
||||
format!("authentication failed (HTTP {status}) — check your access token").into()
|
||||
);
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let batch: Vec<ApiRepo> =
|
||||
serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
let short = batch.len() < 100;
|
||||
all.extend(batch);
|
||||
// Safety cap — 5000 repos should be more than enough.
|
||||
if all.len() >= 5000 || short {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
let mut parsed = Vec::with_capacity(all.len());
|
||||
for r in all {
|
||||
let pushed = parse_time(r.pushed_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
let updated = parse_time(r.updated_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
parsed.push((r, pushed, updated));
|
||||
}
|
||||
parsed.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
let mut result: Vec<GitHubRepo> = parsed
|
||||
.into_iter()
|
||||
.map(|(r, pushed, updated)| GitHubRepo {
|
||||
full_name: r.full_name.unwrap_or_default(),
|
||||
name: r.name.unwrap_or_default(),
|
||||
owner: r.owner.and_then(|o| o.login).unwrap_or_default(),
|
||||
description: r.description.unwrap_or_default(),
|
||||
html_url: r.html_url.unwrap_or_default(),
|
||||
default_branch: r.default_branch.unwrap_or_default(),
|
||||
private: r.private.unwrap_or_default(),
|
||||
fork: r.fork.unwrap_or_default(),
|
||||
archived: r.archived.unwrap_or_default(),
|
||||
language: r.language.unwrap_or_default(),
|
||||
stars: r.stargazers_count.unwrap_or_default(),
|
||||
pushed_at: format_time(pushed),
|
||||
updated_at: format_time(updated),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = result.len();
|
||||
progress(0, total);
|
||||
|
||||
// Enrich with latest commit metadata (best-effort), five lookups at a time.
|
||||
let next = AtomicUsize::new(0);
|
||||
let done = AtomicUsize::new(0);
|
||||
let lookups: Vec<(usize, String, String)> = result
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
let reference = if r.default_branch.is_empty() { "HEAD" } else { &r.default_branch };
|
||||
(i, r.full_name.clone(), reference.to_string())
|
||||
})
|
||||
.collect();
|
||||
let found: Vec<(usize, String, String)> = std::thread::scope(|s| {
|
||||
let workers: Vec<_> = (0..COMMIT_LOOKUP_WORKERS.min(total))
|
||||
.map(|_| {
|
||||
s.spawn(|| {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::SeqCst);
|
||||
let Some((idx, name, reference)) = lookups.get(i) else { break };
|
||||
if let Ok((who, when)) = self.fetch_latest_commit(name, reference) {
|
||||
out.push((*idx, who, when));
|
||||
}
|
||||
progress(done.fetch_add(1, Ordering::SeqCst) + 1, total);
|
||||
}
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect()
|
||||
});
|
||||
for (idx, who, when) in found {
|
||||
result[idx].latest_committer = who;
|
||||
result[idx].latest_commit_at = when;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Committer name and RFC 3339 commit time for a repo and ref (`HEAD` when empty).
|
||||
pub fn fetch_latest_commit(&self, full_name: &str, reference: &str) -> crate::Result<(String, String)> {
|
||||
let reference = if reference.is_empty() { "HEAD" } else { reference };
|
||||
let (owner, repo) = split_repo_full_name(full_name)?;
|
||||
let url = self.endpoint_url(&["repos", &owner, &repo, "commits", reference])?;
|
||||
let mut resp = self.get(&url).map_err(|e| format!("fetching commit: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status == 401 || status == 403 {
|
||||
return Err(format!("authentication failed (HTTP {status})").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
read_error_body(resp.body_mut().as_reader())
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading commit response: {e}"))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
#[serde(default)]
|
||||
commit: Commit,
|
||||
}
|
||||
#[derive(Deserialize, Default)]
|
||||
struct Commit {
|
||||
#[serde(default)]
|
||||
committer: Option<ApiPerson>,
|
||||
#[serde(default)]
|
||||
author: Option<ApiPerson>,
|
||||
}
|
||||
let payload: Payload = serde_json::from_slice(&body).map_err(|e| format!("decoding commit: {e}"))?;
|
||||
let committer = payload.commit.committer.unwrap_or_default();
|
||||
let author = payload.commit.author.unwrap_or_default();
|
||||
let (mut name, mut date) = (committer.name.unwrap_or_default(), committer.date);
|
||||
if name.is_empty() {
|
||||
name = author.name.unwrap_or_default();
|
||||
date = author.date;
|
||||
}
|
||||
let date = parse_time(date.as_deref()).map_err(|e| format!("decoding commit: {e}"))?;
|
||||
match date {
|
||||
Some(d) if !name.is_empty() => Ok((name, format_time(Some(d)))),
|
||||
_ => Err("commit metadata missing".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads the repository zipball (default branch when `reference` is empty) to `dest_path`.
|
||||
pub fn download_repo_archive(&self, full_name: &str, reference: &str, dest_path: &Path) -> crate::Result<()> {
|
||||
let (owner, repo) = split_repo_full_name(full_name)?;
|
||||
let mut segments = vec!["repos", &owner, &repo, "zipball"];
|
||||
if !reference.is_empty() {
|
||||
segments.push(reference);
|
||||
}
|
||||
let url = self.endpoint_url(&segments)?;
|
||||
let mut resp = self.get(&url).map_err(|e| format!("downloading archive: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status == 401 || status == 403 {
|
||||
return Err(format!("authentication failed (HTTP {status}) — check your access token").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
read_error_body(resp.body_mut().as_reader())
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let length = resp
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.trim().parse::<u64>().ok());
|
||||
if let Some(length) = length.filter(|&l| l > MAX_ARCHIVE_BYTES) {
|
||||
return Err(
|
||||
format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into()
|
||||
);
|
||||
}
|
||||
let mut file = std::fs::File::create(dest_path).map_err(|e| format!("creating file: {e}"))?;
|
||||
copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES)
|
||||
.map_err(|e| format!("writing archive: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Base URL plus path-escaped segments.
|
||||
fn endpoint_url(&self, segments: &[&str]) -> crate::Result<String> {
|
||||
let mut url = self.base_url.clone();
|
||||
for segment in segments {
|
||||
if segment.is_empty() {
|
||||
return Err("empty GitHub API path segment".into());
|
||||
}
|
||||
url.push('/');
|
||||
url.push_str(&path_escape(segment));
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Go's `url.PathEscape`.
|
||||
fn path_escape(segment: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in segment.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_time(value: Option<&str>) -> Result<Option<DateTime<FixedOffset>>, String> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(v) => {
|
||||
let t = DateTime::parse_from_rfc3339(v).map_err(|e| format!("parsing time {v:?}: {e}"))?;
|
||||
// Go's zero time means "unset".
|
||||
Ok(Some(t).filter(|t| t.timestamp() != -62135596800))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go's `time.Time.Format(time.RFC3339)`, with the zero time for a missing value.
|
||||
fn format_time(value: Option<DateTime<FixedOffset>>) -> String {
|
||||
match value {
|
||||
Some(t) => t.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
None => "0001-01-01T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_limited(r: impl Read, max_bytes: u64) -> crate::Result<Vec<u8>> {
|
||||
let mut body = Vec::new();
|
||||
r.take(max_bytes + 1).read_to_end(&mut body)?;
|
||||
if body.len() as u64 > max_bytes {
|
||||
return Err(format!("response body exceeds {max_bytes} bytes").into());
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn read_error_body(r: impl Read) -> String {
|
||||
match read_limited(r, MAX_ERROR_BODY_BYTES) {
|
||||
Ok(body) => String::from_utf8_lossy(&body).trim().to_string(),
|
||||
Err(e) => format!("<response body unavailable: {e}>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_repo_full_name(full_name: &str) -> crate::Result<(String, String)> {
|
||||
let parts: Vec<&str> = full_name.trim().split('/').collect();
|
||||
let [owner, repo] = parts.as_slice() else {
|
||||
return Err("repository name must be in owner/repo format".into());
|
||||
};
|
||||
let (owner, repo) = (owner.trim(), repo.trim());
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
return Err("repository name must be in owner/repo format".into());
|
||||
}
|
||||
Ok((owner.to_string(), repo.to_string()))
|
||||
}
|
||||
|
||||
/// Copies at most `max_bytes`; returns the number copied, or an error once the source exceeds it.
|
||||
fn copy_limited_archive(dst: &mut impl Write, src: impl Read, max_bytes: u64) -> crate::Result<u64> {
|
||||
let n = std::io::copy(&mut src.take(max_bytes + 1), dst)?;
|
||||
if n > max_bytes {
|
||||
return Err(format!("archive exceeds {max_bytes} bytes").into());
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn read_limited_rejects_oversized_response() {
|
||||
let err = read_limited(&b"abcd"[..], 3).unwrap_err();
|
||||
assert!(err.to_string().contains("response body exceeds"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_limited_allows_response_at_limit() {
|
||||
assert_eq!(read_limited(&b"abc"[..], 3).unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_url_escapes_repo_path_segments() {
|
||||
let client = Client::new("https://ghe.example.com/api/v3", "");
|
||||
let got = client.endpoint_url(&["repos", "owner", "repo", "commits", "feature/a b"]).unwrap();
|
||||
assert_eq!(got, "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b");
|
||||
assert!(client.endpoint_url(&["repos", ""]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_repo_full_name_rejects_extra_segments() {
|
||||
assert!(split_repo_full_name("owner/repo/../../user").is_err());
|
||||
assert!(split_repo_full_name("owner/").is_err());
|
||||
assert_eq!(split_repo_full_name(" a / b ").unwrap(), ("a".into(), "b".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_limited_archive_rejects_oversized_stream() {
|
||||
let mut dst = Vec::new();
|
||||
let err = copy_limited_archive(&mut dst, &b"abcd"[..], 3).unwrap_err();
|
||||
assert!(err.to_string().contains("archive exceeds"), "{err}");
|
||||
assert_eq!(dst.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_limited_archive_allows_stream_at_limit() {
|
||||
let mut dst = Vec::new();
|
||||
assert_eq!(copy_limited_archive(&mut dst, &b"abc"[..], 3).unwrap(), 3);
|
||||
assert_eq!(dst, b"abc");
|
||||
}
|
||||
|
||||
type Seen = Arc<Mutex<Vec<(String, Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A local HTTP server answering with `handler(url) -> (status, body)`; records
|
||||
/// (url, authorization, user-agent) for every request.
|
||||
fn serve(handler: impl Fn(&str) -> (u16, String) + Send + 'static) -> (String, Seen) {
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", server.server_addr().to_ip().unwrap());
|
||||
let seen: Seen = Arc::default();
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in server.incoming_requests() {
|
||||
let header = |name: &'static str| {
|
||||
req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string())
|
||||
};
|
||||
log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent")));
|
||||
let (status, body) = handler(req.url());
|
||||
let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status));
|
||||
}
|
||||
});
|
||||
(base, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_token_returns_login_and_reports_status() {
|
||||
let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into()));
|
||||
assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat");
|
||||
let (url, auth, ua) = seen.lock().unwrap()[0].clone();
|
||||
assert_eq!(url, "/user");
|
||||
assert_eq!(auth.as_deref(), Some("Bearer tok"));
|
||||
assert_eq!(ua.as_deref(), Some(USER_AGENT));
|
||||
|
||||
let (base, _) = serve(|_| (401, "{}".into()));
|
||||
let err = Client::new(&base, "bad").validate_token().unwrap_err();
|
||||
assert_eq!(err.to_string(), "token validation failed (HTTP 401)");
|
||||
}
|
||||
|
||||
fn repo_json(n: usize, pushed: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"full_name": format!("me/r{n}"), "name": format!("r{n}"), "description": null,
|
||||
"html_url": format!("https://example/me/r{n}"), "default_branch": if n == 0 { "" } else { "main" },
|
||||
"private": n % 2 == 0, "fork": false, "archived": false, "language": null,
|
||||
"stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z",
|
||||
"owner": {"login": "me"}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_repos_paginates_sorts_and_enriches() {
|
||||
let (base, seen) = serve(|url| {
|
||||
if url.starts_with("/user/repos") && url.contains("page=1&") {
|
||||
let repos: Vec<_> = (0..100)
|
||||
.map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28)))
|
||||
.collect();
|
||||
(200, serde_json::to_string(&repos).unwrap())
|
||||
} else if url.starts_with("/user/repos") {
|
||||
(200, serde_json::to_string(&[repo_json(100, "2025-06-01T12:00:00Z")]).unwrap())
|
||||
} else if url == "/repos/me/r100/commits/main" {
|
||||
(200, r#"{"commit":{"committer":{"name":"","date":"0001-01-01T00:00:00Z"},"author":{"name":"Ann","date":"2025-06-01T11:00:00Z"}}}"#.into())
|
||||
} else if url.starts_with("/repos/me/r0/commits/HEAD") {
|
||||
(200, r#"{"commit":{"committer":{"name":"Bob","date":"2024-01-05T10:00:00+00:00"}}}"#.into())
|
||||
} else {
|
||||
(404, "nope".into())
|
||||
}
|
||||
});
|
||||
let calls = Mutex::new(Vec::new());
|
||||
let repos = Client::new(&base, "t")
|
||||
.fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total)))
|
||||
.unwrap();
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[0].full_name, "me/r100");
|
||||
assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z");
|
||||
assert_eq!(repos[0].latest_committer, "Ann");
|
||||
assert_eq!(repos[0].latest_commit_at, "2025-06-01T11:00:00Z");
|
||||
assert!(repos.windows(2).all(|w| w[0].pushed_at >= w[1].pushed_at));
|
||||
let r0 = repos.iter().find(|r| r.name == "r0").unwrap();
|
||||
assert_eq!((r0.latest_committer.as_str(), r0.latest_commit_at.as_str()), ("Bob", "2024-01-05T10:00:00Z"));
|
||||
assert_eq!(r0.owner, "me");
|
||||
assert!(r0.private && r0.description.is_empty() && r0.language.is_empty());
|
||||
assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99);
|
||||
|
||||
let mut calls = calls.into_inner().unwrap();
|
||||
assert_eq!(calls[0], (0, 101));
|
||||
calls.sort();
|
||||
assert_eq!(calls.last(), Some(&(101, 101)));
|
||||
assert_eq!(calls.len(), 102);
|
||||
assert_eq!(seen.lock().unwrap().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_repos_reports_auth_and_api_errors() {
|
||||
let (base, _) = serve(|_| (403, "{}".into()));
|
||||
let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string();
|
||||
assert_eq!(err, "authentication failed (HTTP 403) — check your access token");
|
||||
|
||||
let (base, _) = serve(|_| (500, "boom".into()));
|
||||
let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string();
|
||||
assert_eq!(err, "GitHub API returned HTTP 500: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_repo_archive_writes_file() {
|
||||
let (base, seen) = serve(|url| match url {
|
||||
"/repos/me/r/zipball/v1%2F2" => (200, "ZIPDATA".into()),
|
||||
_ => (404, " missing \n".into()),
|
||||
});
|
||||
let dir = std::env::temp_dir().join(format!("nexus-gh-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("a.zip");
|
||||
let client = Client::new(&base, "t");
|
||||
client.download_repo_archive("me/r", "v1/2", &dest).unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA");
|
||||
let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string();
|
||||
assert_eq!(err, "GitHub API returned HTTP 404: missing");
|
||||
assert_eq!(seen.lock().unwrap()[1].0, "/repos/me/r/zipball");
|
||||
assert!(client.download_repo_archive("bad", "", &dest).is_err());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
//! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not
|
||||
//! fire `on_close` re-entrantly while holding locks.
|
||||
|
||||
pub mod github;
|
||||
pub mod irc;
|
||||
pub mod local;
|
||||
pub mod sftp;
|
||||
pub mod ssh;
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! mIRC formatting (^B ^] ^^ ^_ ^V ^Q ^O ^C fg,bg ^D hex) to styled spans, the nick colour hash
|
||||
//! and the IRC event line layout. Ported from the Wails frontend's `irc.js`.
|
||||
//!
|
||||
//! Colours are xterm-256 palette indices: IRC colour codes are mapped through `IRC_COLOURS`.
|
||||
|
||||
use crate::clients::irc::Event;
|
||||
|
||||
/// IRC colours 00-98 mapped to the xterm 256-colour palette (modern IRC formatting spec).
|
||||
/// Colour 99, and anything out of range, means the terminal default.
|
||||
pub const IRC_COLOURS: [u8; 99] = [
|
||||
15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7, //
|
||||
52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, //
|
||||
88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, //
|
||||
124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, //
|
||||
196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, //
|
||||
203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, //
|
||||
217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, //
|
||||
16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
|
||||
];
|
||||
|
||||
/// Palette indices used for nicks.
|
||||
pub const NICK_COLOURS: [u8; 12] = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220];
|
||||
|
||||
const GREY: u8 = 244;
|
||||
const RED: u8 = 203;
|
||||
const GREEN: u8 = 83;
|
||||
const YELLOW: u8 = 221;
|
||||
const CYAN: u8 = 81;
|
||||
const MAGENTA: u8 = 177;
|
||||
|
||||
/// Text style. `fg`/`bg` are xterm-256 indices; `fg_rgb`/`bg_rgb` come from ^D hex colours.
|
||||
/// At most one of `fg`/`fg_rgb` (and of `bg`/`bg_rgb`) is set. `None` is the default colour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Style {
|
||||
pub fg: Option<u8>,
|
||||
pub bg: Option<u8>,
|
||||
pub fg_rgb: Option<[u8; 3]>,
|
||||
pub bg_rgb: Option<[u8; 3]>,
|
||||
pub bold: bool,
|
||||
pub dim: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub reverse: bool,
|
||||
pub strike: bool,
|
||||
pub monospace: bool,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
fn fg(colour: u8) -> Style {
|
||||
Style { fg: Some(colour), ..Style::default() }
|
||||
}
|
||||
fn set_fg(&mut self, colour: u8) {
|
||||
self.fg = Some(colour);
|
||||
self.fg_rgb = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub text: String,
|
||||
pub style: Style,
|
||||
}
|
||||
|
||||
/// Spans under construction; `style` applies to the next text pushed.
|
||||
#[derive(Default)]
|
||||
struct Line {
|
||||
spans: Vec<Span>,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl Line {
|
||||
fn push(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
match self.spans.last_mut() {
|
||||
Some(last) if last.style == self.style => last.text.push_str(text),
|
||||
_ => self.spans.push(Span { text: text.to_string(), style: self.style }),
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in `style`, then back to the default style.
|
||||
fn styled(&mut self, style: Style, text: &str) {
|
||||
self.style = style;
|
||||
self.push(text);
|
||||
self.style = Style::default();
|
||||
}
|
||||
|
||||
/// A bold, nick-coloured name layered on the current style, then reset.
|
||||
fn nick(&mut self, nick: &str) {
|
||||
let mut style = self.style;
|
||||
style.bold = true;
|
||||
style.set_fg(nick_colour(nick));
|
||||
self.styled(style, nick);
|
||||
}
|
||||
|
||||
/// Applies mIRC formatting codes in `value` on top of the current style.
|
||||
fn mirc(&mut self, value: &str) {
|
||||
let input: Vec<char> = strip_terminal_escapes(value).chars().collect();
|
||||
// Toggle states start off regardless of the inherited style, as in irc.js.
|
||||
let (mut bold, mut italic, mut strike, mut underline, mut reverse, mut mono) = (false, false, false, false, false, false);
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
let c = input[i];
|
||||
i += 1;
|
||||
match c {
|
||||
'\x02' => {
|
||||
bold = !bold;
|
||||
self.style.bold = bold;
|
||||
if !bold {
|
||||
self.style.dim = false; // SGR 22 clears both
|
||||
}
|
||||
}
|
||||
'\x1d' => {
|
||||
italic = !italic;
|
||||
self.style.italic = italic;
|
||||
}
|
||||
'\x1e' => {
|
||||
strike = !strike;
|
||||
self.style.strike = strike;
|
||||
}
|
||||
'\x1f' => {
|
||||
underline = !underline;
|
||||
self.style.underline = underline;
|
||||
}
|
||||
'\x16' => {
|
||||
reverse = !reverse;
|
||||
self.style.reverse = reverse;
|
||||
}
|
||||
'\x11' => {
|
||||
mono = !mono;
|
||||
self.style.monospace = mono;
|
||||
}
|
||||
'\x0f' => {
|
||||
self.style = Style::default();
|
||||
(bold, italic, strike, underline, reverse, mono) = (false, false, false, false, false, false);
|
||||
}
|
||||
'\x03' | '\x04' => {
|
||||
let hex = c == '\x04';
|
||||
let read = |at: usize| if hex { read_hex(&input, at) } else { read_decimal(&input, at) };
|
||||
let Some((fg, len)) = read(i) else {
|
||||
self.reset_colours();
|
||||
continue;
|
||||
};
|
||||
i += len;
|
||||
self.apply_colour(fg, false);
|
||||
if input.get(i) != Some(&',') {
|
||||
continue;
|
||||
}
|
||||
match read(i + 1) {
|
||||
Some((bg, len)) => {
|
||||
i += len + 1;
|
||||
self.apply_colour(bg, true);
|
||||
}
|
||||
None => {
|
||||
self.push(",");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
c if (c as u32) < 0x20 || c as u32 == 0x7f => {}
|
||||
c => self.push(c.encode_utf8(&mut [0; 4])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_colours(&mut self) {
|
||||
(self.style.fg, self.style.bg, self.style.fg_rgb, self.style.bg_rgb) = (None, None, None, None);
|
||||
}
|
||||
|
||||
fn apply_colour(&mut self, colour: Colour, background: bool) {
|
||||
let s = &mut self.style;
|
||||
let (index, rgb) = if background { (&mut s.bg, &mut s.bg_rgb) } else { (&mut s.fg, &mut s.fg_rgb) };
|
||||
match colour {
|
||||
Colour::Indexed(code) => {
|
||||
*index = IRC_COLOURS.get(code as usize).copied();
|
||||
*rgb = None;
|
||||
}
|
||||
Colour::Rgb(value) => {
|
||||
*rgb = Some(value);
|
||||
*index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Colour {
|
||||
Indexed(u8),
|
||||
Rgb([u8; 3]),
|
||||
}
|
||||
|
||||
fn read_decimal(input: &[char], at: usize) -> Option<(Colour, usize)> {
|
||||
let digits: String = input.iter().skip(at).take(2).take_while(|c| c.is_ascii_digit()).collect();
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((Colour::Indexed(digits.parse().ok()?), digits.len()))
|
||||
}
|
||||
|
||||
fn read_hex(input: &[char], at: usize) -> Option<(Colour, usize)> {
|
||||
let hex: String = input.get(at..at + 6)?.iter().collect();
|
||||
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
|
||||
Some((Colour::Rgb([byte(0)?, byte(2)?, byte(4)?]), 6))
|
||||
}
|
||||
|
||||
/// Removes untrusted terminal sequences (OSC, CSI, other ESC pairs) and C1 controls, applying the
|
||||
/// same four passes as irc.js.
|
||||
fn strip_terminal_escapes(value: &str) -> String {
|
||||
// OSC: ESC ] ... (BEL | ESC \), greedy like the JS regex.
|
||||
let mut s = String::new();
|
||||
let mut rest = value;
|
||||
while let Some(pos) = rest.find("\x1b]") {
|
||||
let body = &rest[pos + 2..];
|
||||
let run_end = body.find('\x07');
|
||||
let end = match run_end {
|
||||
Some(bel) => Some(bel + 1),
|
||||
None => body.rfind("\x1b\\").map(|st| st + 2),
|
||||
};
|
||||
match end {
|
||||
Some(end) => {
|
||||
s.push_str(&rest[..pos]);
|
||||
rest = &body[end..];
|
||||
}
|
||||
None => {
|
||||
s.push_str(&rest[..pos + 2]);
|
||||
rest = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.push_str(rest);
|
||||
|
||||
// CSI: ESC [ params intermediates final.
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut csi = String::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '\x1b' && chars.get(i + 1) == Some(&'[') {
|
||||
let mut j = i + 2;
|
||||
while chars.get(j).is_some_and(|c| ('0'..='?').contains(c)) {
|
||||
j += 1;
|
||||
}
|
||||
while chars.get(j).is_some_and(|c| (' '..='/').contains(c)) {
|
||||
j += 1;
|
||||
}
|
||||
if chars.get(j).is_some_and(|c| ('@'..='~').contains(c)) {
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
csi.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// ESC plus any character but a line terminator.
|
||||
let chars: Vec<char> = csi.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '\x1b' && chars.get(i + 1).is_some_and(|c| !matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
out.retain(|c| !('\u{80}'..='\u{9f}').contains(&c));
|
||||
out
|
||||
}
|
||||
|
||||
/// mIRC-formatted text as styled spans, starting from the default style.
|
||||
pub fn format_text(value: &str) -> Vec<Span> {
|
||||
let mut line = Line::default();
|
||||
line.mirc(value);
|
||||
line.spans
|
||||
}
|
||||
|
||||
/// The text with all formatting removed.
|
||||
pub fn plain_text(value: &str) -> String {
|
||||
format_text(value).into_iter().map(|s| s.text).collect()
|
||||
}
|
||||
|
||||
/// The palette index for a nick (Java-style string hash over code points).
|
||||
pub fn nick_colour(nick: &str) -> u8 {
|
||||
let hash = nick.chars().fold(0u32, |h, c| h.wrapping_mul(31).wrapping_add(c as u32));
|
||||
NICK_COLOURS[hash as usize % NICK_COLOURS.len()]
|
||||
}
|
||||
|
||||
/// One chat line for an event: `[time] body`, laid out per event type as the Wails viewer did.
|
||||
pub fn format_event(event: &Event, time: &str) -> Vec<Span> {
|
||||
let from = plain_text(&event.from);
|
||||
let subject = plain_text(&event.command);
|
||||
let mut l = Line::default();
|
||||
l.styled(Style { dim: true, ..Style::fg(GREY) }, &format!("[{time}]"));
|
||||
l.push(" ");
|
||||
|
||||
let nick_or = |l: &mut Line, fallback: &str| {
|
||||
if from.is_empty() { l.push(fallback) } else { l.nick(&from) }
|
||||
};
|
||||
let suffix = |l: &mut Line| {
|
||||
if !event.text.is_empty() {
|
||||
l.push(" (");
|
||||
l.mirc(&event.text);
|
||||
l.push(")");
|
||||
}
|
||||
};
|
||||
let magenta_italic = Style { italic: true, ..Style::fg(MAGENTA) };
|
||||
|
||||
match event.kind.as_str() {
|
||||
"message" => {
|
||||
l.styled(Style::fg(GREY), "<");
|
||||
nick_or(&mut l, "");
|
||||
l.styled(Style::fg(GREY), ">");
|
||||
l.push(" ");
|
||||
l.mirc(&event.text);
|
||||
}
|
||||
"action" => {
|
||||
l.style = magenta_italic;
|
||||
l.push("* ");
|
||||
nick_or(&mut l, "");
|
||||
l.style = magenta_italic;
|
||||
l.push(" ");
|
||||
l.mirc(&event.text);
|
||||
}
|
||||
"notice" => {
|
||||
l.style = Style::fg(YELLOW);
|
||||
l.push("-");
|
||||
nick_or(&mut l, "notice");
|
||||
l.styled(Style::fg(YELLOW), "-");
|
||||
l.push(" ");
|
||||
l.mirc(&event.text);
|
||||
}
|
||||
"join" => {
|
||||
l.styled(Style::fg(GREEN), "-->");
|
||||
l.push(" ");
|
||||
nick_or(&mut l, "user");
|
||||
l.push(" joined");
|
||||
}
|
||||
"part" | "quit" => {
|
||||
l.styled(Style::fg(RED), "<--");
|
||||
l.push(" ");
|
||||
nick_or(&mut l, "user");
|
||||
l.push(if event.kind == "part" { " left" } else { " quit" });
|
||||
suffix(&mut l);
|
||||
}
|
||||
"kick" => {
|
||||
l.styled(Style::fg(RED), "<--");
|
||||
l.push(" ");
|
||||
l.styled(Style { bold: true, ..Style::default() }, if subject.is_empty() { "user" } else { &subject });
|
||||
l.push(" was kicked by ");
|
||||
nick_or(&mut l, "server");
|
||||
suffix(&mut l);
|
||||
}
|
||||
"nick" => {
|
||||
nick_or(&mut l, "user");
|
||||
l.push(" is now known as ");
|
||||
l.styled(Style { bold: true, ..Style::default() }, &plain_text(&event.text));
|
||||
}
|
||||
"topic" | "error" => {
|
||||
let (colour, label) = if event.kind == "topic" { (CYAN, "topic:") } else { (RED, "error:") };
|
||||
l.styled(Style { bold: true, ..Style::fg(colour) }, label);
|
||||
l.push(" ");
|
||||
l.mirc(&event.text);
|
||||
}
|
||||
_ => {
|
||||
l.style = Style::fg(CYAN);
|
||||
l.mirc(&event.text);
|
||||
}
|
||||
}
|
||||
l.spans
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn span(text: &str, style: Style) -> Span {
|
||||
Span { text: text.into(), style }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles() {
|
||||
let b = Style { bold: true, ..Style::default() };
|
||||
let i = Style { italic: true, ..Style::default() };
|
||||
let u = Style { underline: true, ..Style::default() };
|
||||
let d = Style::default();
|
||||
assert_eq!(
|
||||
format_text("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"),
|
||||
vec![span("plain ", d), span("bold", b), span(" ", d), span("italic", i), span(" ", d), span("underlined", u)]
|
||||
);
|
||||
let all = format_text("\x16\x1e\x11x\x0fy");
|
||||
assert_eq!(all[0].style, Style { reverse: true, strike: true, monospace: true, ..Style::default() });
|
||||
assert_eq!(all[1], span("y", d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_colours() {
|
||||
assert_eq!(format_text("\x0304red\x03 default"), vec![span("red", Style::fg(9)), span(" default", Style::default())]);
|
||||
assert_eq!(
|
||||
format_text("\x0352extended \x0303,01matrix"),
|
||||
vec![span("extended ", Style::fg(196)), span("matrix", Style { bg: Some(0), ..Style::fg(2) })]
|
||||
);
|
||||
assert_eq!(format_text("\x0399default"), vec![span("default", Style::default())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_colours() {
|
||||
assert_eq!(
|
||||
format_text("\x04ff8800orange\x04 default"),
|
||||
vec![
|
||||
span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }),
|
||||
span(" default", Style::default())
|
||||
]
|
||||
);
|
||||
let s = format_text("\x0400ff00,0000FFx");
|
||||
assert_eq!(s[0].style, Style { fg_rgb: Some([0, 255, 0]), bg_rgb: Some([0, 0, 255]), ..Style::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_without_background_keeps_comma() {
|
||||
assert_eq!(format_text("\x0304,comma"), vec![span(",comma", Style::fg(9))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_strips_formatting() {
|
||||
assert_eq!(plain_text("\x02bold\x02 \x0304red\x03"), "bold red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_terminal_sequences_are_removed() {
|
||||
assert_eq!(plain_text("before\x1b[31munsafe\x1b]0;title\x07after"), "beforeunsafeafter");
|
||||
assert_eq!(plain_text("a\x1b]8;;x\x1b\\b\u{9b}c\x1bZd\x01"), "abcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nick_colour_matches_js_hash() {
|
||||
// Hashes computed with the irc.js function under node.
|
||||
assert_eq!(nick_colour(""), 33);
|
||||
assert_eq!(nick_colour("bob"), NICK_COLOURS[97717 % 12]);
|
||||
assert_eq!(nick_colour("alice"), NICK_COLOURS[92903040 % 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_lines() {
|
||||
let message = format_event(
|
||||
&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() },
|
||||
"12:34:56",
|
||||
);
|
||||
assert_eq!(message[0], span("[12:34:56]", Style { dim: true, ..Style::fg(GREY) }));
|
||||
assert!(message.iter().any(|s| s.text == "world" && s.style.bold));
|
||||
let nick = message.iter().find(|s| s.text == "alice").unwrap();
|
||||
assert_eq!(nick.style, Style { bold: true, ..Style::fg(nick_colour("alice")) });
|
||||
let text: String = message.iter().map(|s| s.text.as_str()).collect();
|
||||
assert_eq!(text, "[12:34:56] <alice> hello world");
|
||||
|
||||
let action = format_event(&Event { kind: "action".into(), from: "bob".into(), text: "waves".into(), ..Default::default() }, "01:02:03");
|
||||
assert!(action.iter().any(|s| s.text.ends_with("waves") && s.style.italic && s.style.fg == Some(MAGENTA)));
|
||||
|
||||
let kick = format_event(
|
||||
&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() },
|
||||
"t",
|
||||
);
|
||||
assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::<String>(), "[t] <-- eve was kicked by op (spam)");
|
||||
let system = format_event(&Event { kind: "system".into(), text: "hi".into(), ..Default::default() }, "t");
|
||||
assert_eq!(system.last().unwrap(), &span("hi", Style::fg(CYAN)));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
pub mod clients;
|
||||
pub mod config;
|
||||
pub mod exportimport;
|
||||
pub mod ircformat;
|
||||
pub mod keyfiles;
|
||||
pub mod models;
|
||||
pub mod recording;
|
||||
|
||||
Reference in New Issue
Block a user