use std::{ path::{MAIN_SEPARATOR, Path, PathBuf}, time::Instant, }; use ely_gpui_component::{ buttons::{ButtonVariant, IconButton}, lists::ListItem, primitives::{Icon, IconName, Tooltip}, theme::{ActiveTheme, ControlSize, IconSize}, }; use gpui::{AnyElement, App, Entity, SharedString, Subscription, div, prelude::*, rems}; use crate::{ term::{Kind, Record, Records, State, TerminalView}, workspace::{TITLE, Workspace}, }; pub mod orb; use orb::Look; /// A program on its own terminal, as the sessions column lists it. pub struct Session { pub terminal: Entity, /// The program's title; empty while it names none. pub title: String, /// The folder it reports, else the one it started in. pub folder: PathBuf, /// Its shell has integration, or it reported a folder anyway. pub reports: bool, /// A bell rang while it was hidden. pub bell: bool, /// It started waiting on the user while hidden. pub waiting: bool, /// The look its mark shows, and since when. pub look: Option<(Look, Instant)>, pub _events: Subscription, } impl Session { /// The look its mark takes from its terminal now. pub fn looks(&self, cx: &App) -> Option { let view = self.terminal.read(cx); let failed = view.ended().is_some_and(|code| code != 0); look(view.records().shown().map(|(_, record)| record), failed) } /// Notes the look its mark takes `now`; true while the mark moves. pub fn note(&mut self, now: Instant, cx: &App) -> bool { let shows = self.looks(cx); if self.look.map(|(shown, _)| shown) != shows { self.look = shows.map(|shows| (shows, now)); } self.look .is_some_and(|(look, since)| orb::moves(look, now.saturating_duration_since(since))) } } /// The most records hover lists, and the most characters it shows of each part. const LISTED: usize = 10; const SHORT: usize = 120; /// `folder` as a row shows it: the home folder as `~`, on one line. pub fn shown(folder: &Path, home: Option<&Path>) -> String { let shown = match home.and_then(|home| folder.strip_prefix(home).ok()) { Some(rest) if rest.as_os_str().is_empty() => "~".into(), Some(rest) => format!("~{MAIN_SEPARATOR}{}", rest.display()), None => folder.display().to_string(), }; one_line(&shown) } /// `text` on one line, as gpui shapes labels: each control character as its picture, `␊` for a newline. pub fn one_line(text: &str) -> String { text.chars() .map(|c| match c { '\0'..='\x1f' => char::from_u32(0x2400 + c as u32).expect("a control picture"), '\x7f' => '\u{2421}', c => c, }) .collect() } /// The session shown once session `closed` of `left + 1` is gone, `active` having been shown. pub fn after_close(active: usize, closed: usize, left: usize) -> usize { if closed < active || active == left { active - 1 } else { active } } /// Session `index`'s row: its title and folder, the mark of its status, a bell while one rang unseen, a button that closes it. pub fn row( workspace: &Entity, index: usize, session: &Session, active: bool, home: Option<&Path>, now: Instant, cx: &App, ) -> ListItem { let terminal = session.terminal.read(cx); let pid = terminal.pid() as usize; let title = if session.title.is_empty() { TITLE } else { &session.title }; let (select, close) = (workspace.clone(), workspace.clone()); let item = ListItem::new(("session", pid), title.to_string()) .description(shown(&session.folder, home)) .selected(active) .strong(session.bell || session.waiting) .on_click(move |_, window, cx| select.update(cx, |this, cx| this.select(index, window, cx))) .trailing( IconButton::new(("close-session", pid), IconName::X) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip("Close session") .on_click(move |_, window, cx| { cx.stop_propagation(); close.update(cx, |this, cx| this.close(index, window, cx)) }), ); let mark = mark(session, now, cx); let bell = session .bell .then(|| Icon::new(IconName::Bell).color(cx.theme().colors.warning)); if mark.is_none() && bell.is_none() { return item; } item.leading( div() .flex() .items_center() .gap_1() .children(mark) .children(bell), ) } /// What a mark shows: the most pressing record's state, else a failed last command. pub fn look(record: Option<&Record>, failed: bool) -> Option { Some( match (record.map(|record| (record.state, record.progress)), failed) { (Some((State::Blocked, _)), _) => Look::Waiting, (Some((State::Error, _)), _) => Look::Error, (Some((State::Working, progress)), _) => Look::Working(progress), (Some((State::Done, _)), _) => Look::Done, (_, true) => Look::Error, (Some((State::Idle, _)), false) => Look::Idle, (None, false) => return None, }, ) } /// The session's mark: its look's dots in its state's color; hover lists the records. fn mark(session: &Session, now: Instant, cx: &App) -> Option { let (look, since) = session.look?; let theme = cx.theme(); let colors = &theme.colors; let color = match look { Look::Working(_) => colors.info, Look::Waiting => colors.warning, Look::Error => colors.danger, Look::Done => colors.success, Look::Idle => colors.fg_muted, }; let pid = session.terminal.read(cx).pid() as usize; // Hover reads the records as they stand, so an open one follows them, and scrolls within the window. let watched = session.terminal.downgrade(); let tip = Tooltip::rich(move |window, cx| { let lines = watched.upgrade().map_or_else(Vec::new, |terminal| { let view = terminal.read(cx); hover(view.records(), view.ended().filter(|&code| code != 0)) }); let room = window.viewport_size().height - rems(3.).to_pixels(window.rem_size()); div() .id("session-hover") .max_h(room) .overflow_y_scroll() .flex() .flex_col() .children(lines) .into_any_element() }); // Every mark takes a 24-pixel box, so the title stays put as the state changes. Some( div() .id(("session-status", pid)) .size(theme.icon_size(IconSize::Xl)) .flex() .items_center() .justify_center() .hoverable_tooltip(tip) .child(orb::lattice(look, color, now, since)) .into_any_element(), ) } /// What hover shows: a line for each record, ten at most, then a failed command's status. pub fn hover(records: &Records, failed: Option) -> Vec { let mut lines: Vec = records .iter() .take(LISTED) .map(|(id, record)| line(records, id, record).into()) .collect(); let more = records.iter().count().saturating_sub(LISTED); if more > 0 { lines.push(format!("{more} more").into()); } if let Some(code) = failed { lines.push(format!("Last command exited with {code}").into()); } lines } /// A record as hover and notifications say it: its state, then its app, title and message. pub fn line(records: &Records, id: &str, record: &Record) -> String { let parts = [ records.app(id), record.title.as_deref(), record.msg.as_deref(), ]; let said: Vec = parts.into_iter().flatten().map(plain).collect(); let state = label(record); if said.is_empty() { state } else { format!("{state}: {}", said.join(" · ")) } } /// A record's state, as hover names it. fn label(record: &Record) -> String { match (record.state, record.kind, record.progress) { (State::Idle, ..) => "Idle".into(), (State::Working, _, Some(value)) => format!("Working, {value}%"), (State::Working, ..) => "Working".into(), (State::Blocked, Some(Kind::Permission), _) => "Waiting for permission".into(), (State::Blocked, Some(Kind::Question), _) => "Waiting for an answer".into(), (State::Blocked, Some(Kind::Auth), _) => "Waiting for a login".into(), (State::Blocked, None, _) => "Waiting".into(), (State::Done, ..) => "Done".into(), (State::Error, ..) => "Error".into(), } } /// A program's text as hover shows it: on one line, 120 characters at most, without what could disguise it. pub fn plain(text: &str) -> String { let kept: String = text.chars().filter(|&c| !invisible(c)).collect(); let line = one_line(&kept); match line.char_indices().nth(SHORT) { Some((at, _)) => format!("{}…", &line[..at]), None => line, } } /// C1 controls, direction controls and invisible formatting; joiners stay, as scripts and emoji need them. fn invisible(c: char) -> bool { matches!( c, '\u{80}'..='\u{9f}' | '\u{ad}' | '\u{61c}' | '\u{180e}' | '\u{200b}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{feff}' | '\u{fff9}'..='\u{fffb}' | '\u{e0000}'..='\u{e007f}' ) } #[cfg(test)] mod tests { use super::*; #[test] fn folders_under_home_read_from_a_tilde() { let home = PathBuf::from("/Users/ely"); let shown = |folder: &str| shown(Path::new(folder), Some(&home)); assert_eq!(shown("/Users/ely"), "~"); assert_eq!( shown("/Users/ely/src/app"), format!("~{MAIN_SEPARATOR}src/app") ); assert_eq!( shown("/Users/elyse"), "/Users/elyse", "a sibling is no child" ); assert_eq!(shown("/tmp"), "/tmp"); assert_eq!( shown("/Users/ely/line\nbreak"), format!("~{MAIN_SEPARATOR}line␊break") ); } #[test] fn control_characters_show_as_their_pictures() { assert_eq!(one_line("a\nb\tc\rd\0e\u{7f}"), "a␊b␉c␍d␀e␡"); assert_eq!(one_line("café ✓"), "café ✓", "printable text stays"); } #[test] fn closing_shows_the_next_session_or_keeps_the_shown_one() { assert_eq!(after_close(2, 0, 3), 1, "one before it went"); assert_eq!(after_close(1, 1, 3), 1, "it went: the next one"); assert_eq!(after_close(3, 3, 3), 2, "the last went: the one before"); assert_eq!(after_close(1, 2, 3), 1, "one after it went"); } #[test] fn the_mark_shows_the_most_pressing_look() { let record = |state, progress| Record { state, kind: None, progress, app: None, title: None, msg: None, }; let waiting = look(Some(&record(State::Blocked, None)), true); assert_eq!( waiting, Some(Look::Waiting), "waiting outranks a failed command" ); let working = look(Some(&record(State::Working, Some(40))), false); assert_eq!(working, Some(Look::Working(Some(40)))); let idle = record(State::Idle, None); assert_eq!( look(Some(&idle), true), Some(Look::Error), "a failed command outranks idle" ); assert_eq!(look(Some(&idle), false), Some(Look::Idle)); assert_eq!( look(Some(&record(State::Done, None)), true), Some(Look::Done) ); assert_eq!( look(Some(&record(State::Error, None)), false), Some(Look::Error) ); assert_eq!(look(None, false), None); } #[test] fn hover_shows_a_program_s_text_as_text() { assert_eq!(plain("evil\u{202e}txt.exe"), "eviltxt.exe"); assert_eq!( plain("a\u{200b}b\u{feff}c\u{e0041}d\u{85}e\u{2066}f"), "abcdef" ); assert_eq!( plain("👨\u{200d}💻 two\nlines"), "👨\u{200d}💻 two␊lines", "joiners stay" ); assert_eq!(plain(&"x".repeat(SHORT)), "x".repeat(SHORT)); assert_eq!( plain(&"é".repeat(SHORT + 1)), format!("{}…", "é".repeat(SHORT)) ); } #[test] fn hover_lists_each_record_with_its_app_then_a_failed_command() { let record = |state: State, kind: Option, progress: Option, texts: [Option<&str>; 3]| { let [app, title, msg] = texts.map(|text| text.map(String::from)); Record { state, kind, progress, app, title, msg, } }; let mut records = Records::default(); records.report( String::new(), record( State::Working, None, Some(40), [Some("deploy"), None, Some("Deploying")], ), ); records.report( "eu".into(), record( State::Blocked, Some(Kind::Permission), None, [None, Some("EU West"), Some("Approve?")], ), ); records.report( "eu/db".into(), record(State::Done, None, None, [Some("db"), None, None]), ); let lines: Vec = hover(&records, Some(2)) .iter() .map(ToString::to_string) .collect(); assert_eq!( lines, [ "Working, 40%: deploy · Deploying", "Waiting for permission: deploy · EU West · Approve?", "Done: db", "Last command exited with 2" ] ); for at in 0..LISTED { records.report(format!("r{at}"), record(State::Idle, None, None, [None; 3])); } let lines = hover(&records, None); assert_eq!(lines.len(), LISTED + 1); assert_eq!(lines[LISTED].to_string(), "3 more"); } }