diff --git a/crates/tty7-core/src/core/github/api.rs b/crates/tty7-core/src/core/github/api.rs index 100c0f58..01eb280e 100644 --- a/crates/tty7-core/src/core/github/api.rs +++ b/crates/tty7-core/src/core/github/api.rs @@ -311,6 +311,7 @@ pub fn detail(t: &dyn Transport, slug: &RepoSlug, number: u64) -> Result = page.items.iter().map(|i| i.state).collect(); assert_eq!(states, vec![ItemState::Draft, ItemState::Merged]); assert!(page.items.iter().all(|i| i.is_pr)); + let refs = page.items[0].pull.as_ref().unwrap(); + assert_eq!( + ( + refs.head_label.as_str(), + refs.head_ref.as_str(), + refs.base_ref.as_str() + ), + ("bob:feat/panel", "feat/panel", "main") + ); } #[test] @@ -622,7 +632,7 @@ pub(crate) mod tests { assert_eq!(d.item.labels[0].name, "ui", "labels come from the issue"); let pull = d.pull.unwrap(); assert_eq!( - (pull.head_ref.as_str(), pull.base_ref.as_str()), + (pull.refs.head_ref.as_str(), pull.refs.base_ref.as_str()), ("feat/panel", "main") ); assert_eq!((pull.additions, pull.deletions, pull.commits), (120, 4, 3)); diff --git a/crates/tty7-core/src/core/github/mod.rs b/crates/tty7-core/src/core/github/mod.rs index b4977b55..4c37fbd2 100644 --- a/crates/tty7-core/src/core/github/mod.rs +++ b/crates/tty7-core/src/core/github/mod.rs @@ -13,12 +13,13 @@ pub mod http; pub mod markdown; pub mod model; pub mod remote; +pub mod stack; pub mod token; pub use api::{ApiError, ListPage, ListQuery, Reply, Transport}; pub use model::{ Check, CheckState, Checks, Comment, Detail, Item, ItemState, Kind, Label, MergeState, PrFile, - PullInfo, Readiness, ReviewState, Reviewer, StateFilter, readiness, + PullInfo, PullRefs, Readiness, ReviewState, Reviewer, StateFilter, readiness, }; pub use remote::{GitHubRemote, RepoSlug}; pub use token::{Token, TokenSource}; diff --git a/crates/tty7-core/src/core/github/model.rs b/crates/tty7-core/src/core/github/model.rs index 80609b94..996b3357 100644 --- a/crates/tty7-core/src/core/github/model.rs +++ b/crates/tty7-core/src/core/github/model.rs @@ -71,6 +71,18 @@ pub struct Item { pub created_at: i64, pub updated_at: i64, pub html_url: String, + /// The branches, when the row came from `/pulls`; `/issues` has none. + pub pull: Option, +} + +/// A pull request's branches, as `/pulls` reports them. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct PullRefs { + pub head_ref: String, + /// `owner:branch` — names the repository the head lives in, so a fork's + /// branch is told apart from this repository's. + pub head_label: String, + pub base_ref: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -85,10 +97,9 @@ pub struct Comment { /// The pull-request-only half of a detail view. #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct PullInfo { - pub head_ref: String, + pub refs: PullRefs, /// The head commit — what the checks ran on. Empty if GitHub left it out. pub head_sha: String, - pub base_ref: String, pub additions: u32, pub deletions: u32, pub changed_files: u32, @@ -388,12 +399,14 @@ pub(crate) struct RawIssue { draft: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub(crate) struct RawBranchRef { #[serde(default, rename = "ref")] name: String, #[serde(default)] sha: String, + #[serde(default)] + label: String, } #[derive(Deserialize)] @@ -585,6 +598,7 @@ impl RawIssue { created_at: timestamp(&self.created_at), updated_at: timestamp(&self.updated_at), html_url: self.html_url, + pull: None, } } } @@ -597,11 +611,14 @@ impl RawPull { self.merged_at.is_some(), self.draft.unwrap_or(false), ); - let (head_ref, head_sha) = self.head.map(|h| (h.name, h.sha)).unwrap_or_default(); + let head = self.head.unwrap_or_default(); let info = PullInfo { - head_ref, - head_sha, - base_ref: self.base.map(|b| b.name).unwrap_or_default(), + refs: PullRefs { + head_ref: head.name, + head_label: head.label, + base_ref: self.base.map(|b| b.name).unwrap_or_default(), + }, + head_sha: head.sha, additions: self.additions.unwrap_or(0), deletions: self.deletions.unwrap_or(0), changed_files: self.changed_files.unwrap_or(0), @@ -619,6 +636,7 @@ impl RawPull { created_at: timestamp(&self.created_at), updated_at: timestamp(&self.updated_at), html_url: self.html_url, + pull: Some(info.refs.clone()), }; (item, info) } diff --git a/crates/tty7-core/src/core/github/stack.rs b/crates/tty7-core/src/core/github/stack.rs new file mode 100644 index 00000000..4e8b025d --- /dev/null +++ b/crates/tty7-core/src/core/github/stack.rs @@ -0,0 +1,310 @@ +//! Stacked pull requests: one whose base branch is another listed pull +//! request's head branch sits on top of it. A list draws each stack as one +//! contiguous run with every pull request above the one it is based on: a +//! pull request is stacked after its base, so this keeps the list's +//! newest-first order inside the run, and the trunk-most ends at the bottom +//! beside the branch the stack is based on. + +use std::collections::HashMap; + +use super::model::{Item, PullRefs}; + +/// One lane of a stacked row's connector column. Each lane is a column of +/// the connector; a pull request shares its base's lane when it is the first +/// one based on it, and a sibling gets a lane of its own, so a fork never +/// reads as one pull request based on the other. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Cell { + Empty, + /// A side lane's line passing the row, to a base further down. + Pipe, + /// The row's glyph. `up` joins it to the row just above; `down` to its + /// base or to the trunk. + Node { + up: bool, + down: bool, + }, + /// A side lane's line ending in the row's glyph, coming in from the right. + Join, +} + +/// A line of the list as a stack draws it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StackRow<'a> { + /// An issue or pull request; a row in no stack is one lone `Node`. + Item { item: &'a Item, cells: Vec }, + /// Under a stack's bottom row: the branch the stack is based on. A stack + /// based on another listed pull request's branch, as in a cycle, has none. + Trunk(&'a str), +} + +/// The branch of a head label `owner:branch` when it lives in `owner`'s +/// repository. GitHub owners are case-insensitive, and a remote URL can +/// spell one either way. +fn own_branch<'a>(head_label: &'a str, owner: &str) -> Option<&'a str> { + let (who, branch) = head_label.split_once(':')?; + who.eq_ignore_ascii_case(owner).then_some(branch) +} + +/// `rows` with each stack gathered into one run where its first member +/// sorted; rows in no stack keep their place. `owner` is the repository's: +/// a base branch only names another pull request's head when that head lives +/// in the same repository, never in a fork. +pub fn stack_order<'a>(rows: &'a [Item], owner: &str) -> Vec> { + let refs = |i: usize| -> Option<&'a PullRefs> { rows[i].pull.as_ref() }; + let mut by_head: HashMap<&str, usize> = HashMap::new(); + for i in 0..rows.len() { + if let Some(branch) = refs(i).and_then(|r| own_branch(&r.head_label, owner)) { + by_head.entry(branch).or_insert(i); + } + } + let parent: Vec> = (0..rows.len()) + .map(|i| { + refs(i) + .and_then(|r| by_head.get(r.base_ref.as_str()).copied()) + .filter(|&p| p != i) + }) + .collect(); + let mut children: Vec> = vec![Vec::new(); rows.len()]; + for (i, p) in parent.iter().enumerate() { + if let Some(p) = *p { + children[p].push(i); + } + } + + // Roots first, then whatever a cycle left unvisited, each from its + // earliest member. `from` is the row a walk reached each row by, which in + // a cycle is not always its `parent`; `heir_taken` marks a row whose + // lane was handed on, which is also a line up out of its glyph. + let mut seen = vec![false; rows.len()]; + let mut from: Vec> = vec![None; rows.len()]; + let mut lane = vec![0; rows.len()]; + let mut heir_taken = vec![false; rows.len()]; + let mut groups: Vec<(Vec, usize)> = Vec::new(); + let starts = (0..rows.len()) + .filter(|&i| parent[i].is_none()) + .chain(0..rows.len()); + for start in starts { + if seen[start] { + continue; + } + let mut group = Vec::new(); + let mut lanes = 1; + let mut todo = vec![(start, None)]; + while let Some((i, via)) = todo.pop() { + if std::mem::replace(&mut seen[i], true) { + continue; + } + from[i] = via; + // Lanes are handed out in walk order, so a lane's line never + // crosses the side branch of a row it passes. Every lane right of + // a row's own opened after it and runs through a row between it + // and its next side branch, so a fresh lane is never wasted. + lane[i] = match via { + Some(p) if !std::mem::replace(&mut heir_taken[p], true) => lane[p], + Some(_) => { + lanes += 1; + lanes - 1 + } + None => 0, + }; + group.push(i); + todo.extend(children[i].iter().rev().map(|&c| (c, Some(i)))); + } + // Pre-order reversed: every pull request lands above its base, the + // first based on it right above it. + group.reverse(); + groups.push((group, lanes)); + } + groups.sort_by_key(|(g, _)| g.iter().min().copied()); + + let mut out = Vec::with_capacity(rows.len()); + let mut row_of = vec![0; rows.len()]; + for (group, lanes) in groups { + let bottom = group[group.len() - 1]; + let trunk = (group.len() > 1 && parent[bottom].is_none()) + .then(|| refs(bottom).map(|r| r.base_ref.as_str())) + .flatten(); + let mut cells = vec![vec![Cell::Empty; lanes]; group.len()]; + for (k, &i) in group.iter().enumerate() { + row_of[i] = k; + cells[k][lane[i]] = Cell::Node { + up: heir_taken[i], + down: from[i].is_some() || trunk.is_some(), + }; + } + for (k, &i) in group.iter().enumerate() { + let Some(p) = from[i].filter(|&p| lane[p] != lane[i]) else { + continue; + }; + for row in &mut cells[k + 1..row_of[p]] { + row[lane[i]] = Cell::Pipe; + } + cells[row_of[p]][lane[i]] = Cell::Join; + } + out.extend(group.iter().zip(cells).map(|(&i, cells)| StackRow::Item { + item: &rows[i], + cells, + })); + out.extend(trunk.map(StackRow::Trunk)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::github::model::ItemState; + + fn pr(number: u64, head: &str, base: &str) -> Item { + Item { + number, + title: String::new(), + state: ItemState::Open, + is_pr: true, + author: String::new(), + labels: Vec::new(), + comments: 0, + created_at: 0, + updated_at: 0, + html_url: String::new(), + pull: Some(PullRefs { + head_ref: head.into(), + head_label: format!("acme:{head}"), + base_ref: base.into(), + }), + } + } + + fn order_as(items: &[Item], owner: &str) -> Vec { + stack_order(items, owner) + .into_iter() + .filter_map(|row| match row { + StackRow::Item { item, .. } => Some(item.number), + StackRow::Trunk(_) => None, + }) + .collect() + } + + fn order(items: &[Item]) -> Vec { + order_as(items, "acme") + } + + /// The list as text, one line per row and one between rows for the + /// lines that cross the gap: `●` a glyph, `│` a line, `╯` a side lane's + /// line ending in the glyph left of it, `•` a trunk. + fn drawn(items: &[Item]) -> Vec { + let mut out = Vec::new(); + for row in stack_order(items, "acme") { + let (number, cells) = match row { + StackRow::Item { item, cells } => (item.number, cells), + StackRow::Trunk(base) => { + out.push(format!("• {base}")); + continue; + } + }; + if !out.is_empty() { + let gap: String = cells + .iter() + .map(|c| match c { + Cell::Pipe | Cell::Join | Cell::Node { up: true, .. } => '│', + Cell::Empty | Cell::Node { up: false, .. } => ' ', + }) + .collect(); + out.push(gap.trim_end().to_string()); + } + let line: String = cells + .iter() + .map(|c| match c { + Cell::Empty => ' ', + Cell::Pipe => '│', + Cell::Node { .. } => '●', + Cell::Join => '╯', + }) + .collect(); + out.push(format!("{line} #{number}")); + } + out + } + + #[test] + fn a_stack_is_gathered_top_first_where_its_first_member_sorted() { + let items = [ + pr(1, "solo", "main"), + pr(3, "c", "b"), + pr(9, "other", "main"), + pr(2, "b", "a"), + pr(4, "a", "main"), + ]; + assert_eq!( + drawn(&items), + [ + "● #1", "", "● #3", "│", "● #2", "│", "● #4", "• main", "", "● #9", + ] + ); + } + + #[test] + fn issues_and_unstacked_rows_keep_their_order() { + let mut issue = pr(5, "x", "y"); + issue.is_pr = false; + issue.pull = None; + let items = [pr(7, "y", "main"), issue, pr(8, "z", "main")]; + assert_eq!(order(&items), [7, 5, 8]); + assert!(drawn(&items).iter().all(|l| !l.contains('│'))); + } + + #[test] + fn a_fork_s_branch_is_not_a_base_in_this_repository() { + let mut fork = pr(1, "feat", "main"); + fork.pull.as_mut().unwrap().head_label = "bob:feat".into(); + let items = [fork, pr(2, "more", "feat")]; + assert_eq!(drawn(&items), ["● #1", "", "● #2"]); + } + + #[test] + fn the_owner_matches_however_the_remote_spells_it() { + let items = [pr(1, "a", "main"), pr(2, "b", "a")]; + assert_eq!(order_as(&items, "ACME"), [2, 1]); + assert_eq!(order_as(&items, "Acme"), [2, 1]); + assert_eq!(order_as(&items, "acm"), [1, 2], "a prefix is not the owner"); + } + + #[test] + fn siblings_on_one_base_are_drawn_as_a_fork_not_a_line() { + let items = [pr(1, "a", "main"), pr(2, "b", "a"), pr(3, "c", "a")]; + assert_eq!( + drawn(&items), + [" ● #3", " │", "●│ #2", "││", "●╯ #1", "• main"] + ); + } + + #[test] + fn a_side_lane_passes_a_whole_substack() { + let items = [ + pr(1, "a", "main"), + pr(2, "b", "a"), + pr(3, "c", "b"), + pr(4, "d", "a"), + ]; + assert_eq!( + drawn(&items), + [ + " ● #4", + " │", + "●│ #3", + "││", + "●│ #2", + "││", + "●╯ #1", + "• main", + ] + ); + } + + #[test] + fn a_cycle_shows_every_row_once_and_no_trunk() { + let items = [pr(1, "a", "b"), pr(2, "b", "a"), pr(3, "c", "c")]; + assert_eq!(drawn(&items), ["● #2", "│", "● #1", "", "● #3"]); + } +} diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx index dd68f6b4..52ee84f2 100644 --- a/docs/window/side-panel.mdx +++ b/docs/window/side-panel.mdx @@ -106,6 +106,12 @@ is more than one. GitHub Enterprise hosts are not supported. - Each row carries a state glyph whose *shape* says open, closed, not planned, merged or draft; the number, author and last update; and the labels. **Click a label** to show only that label, and the × beside it to clear it. +- Hovering a row adds a button to open it on github.com. +- A pull request based on another listed pull request's branch is drawn in + its stack: the stack's rows sit together, each above the one it is based + on, joined by a line through their glyphs; pull requests based on the same + one branch off it in lanes side by side. Stacks show on the unfiltered + pull request list; a label filter lists rows without their branches. - **Click a row** for the detail: title, state, author, labels, the description and the conversation as Markdown. A pull request adds its branches, size and changed files — click a file to open its patch in the diff --git a/src/ui/github/detail.rs b/src/ui/github/detail.rs index 42b4bdcc..59b7b4aa 100644 --- a/src/ui/github/detail.rs +++ b/src/ui/github/detail.rs @@ -297,7 +297,7 @@ impl Tty7App { .text_size(rems(META_MONO)) .font_family(mono.clone()) .text_color(muted) - .child(format!("{} → {}", pull.head_ref, pull.base_ref)) + .child(format!("{} → {}", pull.refs.head_ref, pull.refs.base_ref)) .child( div() .text_color(added_ink) @@ -483,7 +483,7 @@ impl Tty7App { let head_ref = detail .pull .as_ref() - .map(|p| p.head_ref.clone()) + .map(|p| p.refs.head_ref.clone()) .unwrap_or_default(); let files: Arc> = Arc::new(files.to_vec()); let focused = self diff --git a/src/ui/github/mod.rs b/src/ui/github/mod.rs index 21eac732..55e9b17c 100644 --- a/src/ui/github/mod.rs +++ b/src/ui/github/mod.rs @@ -28,6 +28,7 @@ //! request pinned over the list follows a `git switch`. pub(crate) mod detail; +pub(crate) mod rows; use std::collections::HashMap; use std::sync::Arc; diff --git a/src/ui/github/rows.rs b/src/ui/github/rows.rs new file mode 100644 index 00000000..80e9d758 --- /dev/null +++ b/src/ui/github/rows.rs @@ -0,0 +1,107 @@ +//! The list rows' stack connectors. + +use gpui::{AnyElement, Div, Hsla, div, prelude::*, px, rems}; +use gpui_component::{ActiveTheme as _, h_flex}; + +use tty7_core::core::github::stack::Cell; + +use crate::ui::right_panel::{META, ROW_INSET}; + +/// A list row: one line, the Source Control tab's row pitch. +pub(crate) const ROW_H: f32 = 26.; +/// The state glyph, and the column it sits in. +pub(crate) const GLYPH: f32 = 14.; + +/// A stack's line down lane `lane` of the glyph column. +fn connector(lane: usize, h: f32, color: Hsla) -> Div { + div() + .absolute() + .left(px(lane as f32 * GLYPH + GLYPH / 2. - 0.5)) + .w(px(1.)) + .h(px(h)) + .bg(color) +} + +/// `glyph` in its row's node lane, and each lane's connector piece. +pub(crate) fn stack_glyph(glyph: AnyElement, cells: &[Cell], cx: &gpui::App) -> AnyElement { + let color = cx.theme().muted_foreground.opacity(0.5); + let gap = (ROW_H - GLYPH) / 2.; + let x = |lane: usize| lane as f32 * GLYPH + GLYPH / 2.; + let node = cells + .iter() + .position(|c| matches!(c, Cell::Node { .. })) + .unwrap_or(0); + let mut column = div() + .flex_none() + .relative() + .w(px(cells.len().max(1) as f32 * GLYPH)) + .h(px(ROW_H)) + .child( + div() + .absolute() + .top(px(gap)) + .left(px(node as f32 * GLYPH)) + .child(glyph), + ); + for (lane, &cell) in cells.iter().enumerate() { + column = match cell { + Cell::Empty => column, + Cell::Pipe => column.child(connector(lane, ROW_H, color).top_0()), + Cell::Node { up, down } => column + .when(up, |d| d.child(connector(lane, gap, color).top_0())) + .when(down, |d| d.child(connector(lane, gap, color).bottom_0())), + // Down half the gap, then across to the glyph's lane. + Cell::Join => column + .child(connector(lane, gap / 2., color).top_0()) + .child( + div() + .absolute() + .top(px(gap / 2.)) + .left(px(x(node) - 0.5)) + .w(px(x(lane) - x(node) + 1.)) + .h(px(1.)) + .bg(color), + ), + }; + } + column.into_any_element() +} + +/// The branch a stack is based on, under its bottom row: the connector ends +/// in a dot beside the branch's name. +pub(crate) fn stack_trunk(base: &str, cx: &gpui::App) -> AnyElement { + let theme = cx.theme(); + let color = theme.muted_foreground.opacity(0.5); + const H: f32 = 18.; + const DOT: f32 = 5.; + h_flex() + .h(px(H)) + .px(px(ROW_INSET)) + .gap(px(8.)) + .items_center() + .child( + div() + .flex_none() + .relative() + .w(px(GLYPH)) + .h(px(H)) + .child(connector(0, H / 2., color).top_0()) + .child( + div() + .absolute() + .top(px((H - DOT) / 2.)) + .left(px((GLYPH - DOT) / 2.)) + .size(px(DOT)) + .rounded_full() + .bg(color), + ), + ) + .child( + div() + .text_size(rems(META)) + .font_family(theme.mono_font_family.clone()) + .text_color(theme.muted_foreground) + .child(base.to_string()), + ) + .into_any_element() +} diff --git a/src/ui/panel_github.rs b/src/ui/panel_github.rs index a29d320e..58aa606c 100644 --- a/src/ui/panel_github.rs +++ b/src/ui/panel_github.rs @@ -9,27 +9,25 @@ use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; +use tty7_core::core::github::stack::{Cell, StackRow, stack_order}; use tty7_core::core::github::{ ApiError, CheckState, GitHubRemote, Item, ItemState, Kind, Label, RepoSlug, StateFilter, }; use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App}; +use crate::ui::github::rows::{GLYPH, ROW_H, stack_glyph, stack_trunk}; use crate::ui::github::{GhTarget, now_unix}; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::right_panel::{META, ROW_FILL_RADIUS, ROW_INSET, TAB_TEXT, TEXT, TEXT_INSET}; use crate::ui::scm::path::relative_time; use crate::ui::scm::state::RepoKey; -/// A list row: one line, the Source Control tab's row pitch. -const ROW_H: f32 = 26.; /// How many label chips a hovered row shows before the age. const MAX_ROW_LABELS: usize = 3; /// The widest a hovered row lets its author get before truncating it. const AUTHOR_MAX_W: f32 = 96.; /// What a hovered row leaves of its title, however much meta it shows. const TITLE_MIN_W: f32 = 48.; -/// The state glyph, and the column it sits in. -const GLYPH: f32 = 14.; /// The pinned rows' height — the Info tab's row pitch. const PINNED_ROW_H: f32 = 28.; /// The segmented switches' cells. @@ -516,8 +514,8 @@ impl Tty7App { } let now = now_unix(); let mut rows = v_flex().px(px(CONTENT_INSET)); - for item in items.iter() { - rows = rows.child(self.github_item_row(slug, item, now, cx)); + for row in self.github_stack_rows(slug, &items, now, cx) { + rows = rows.child(row); } body = body.child(rows); if let Some(page) = next_page { @@ -539,6 +537,24 @@ impl Tty7App { body.into_any_element() } + /// `items` as list rows, stacks gathered, with each stack's trunk drawn + /// under its bottom pull request so the run reads bottom-up. + fn github_stack_rows( + &self, + slug: &RepoSlug, + items: &[Item], + now: i64, + cx: &mut Context, + ) -> Vec { + stack_order(items, &slug.owner) + .into_iter() + .map(|row| match row { + StackRow::Item { item, cells } => self.github_item_row(slug, item, &cells, now, cx), + StackRow::Trunk(base) => stack_trunk(base, cx), + }) + .collect() + } + /// One line per item: state glyph, `#number`, title. The labels, the /// author and the age of the last update wait for the pointer — the /// resting list reads as a column of titles, and hovering a row answers @@ -548,6 +564,7 @@ impl Tty7App { &self, slug: &RepoSlug, item: &Item, + cells: &[Cell], now: i64, cx: &mut Context, ) -> AnyElement { @@ -555,8 +572,9 @@ impl Tty7App { let muted = cx.theme().muted_foreground; let mono = cx.theme().mono_font_family.clone(); let number = item.number; - let slug = slug.clone(); let hovered = self.github.hovered == Some(number); + let links = hovered.then(|| hovered_links(item, cx)); + let slug = slug.clone(); let title = SharedString::from(item.title.clone()); let labels = item .labels @@ -646,11 +664,11 @@ impl Tty7App { .on_click(cx.listener(move |this, _, _window, cx| { this.github_open_detail(slug.clone(), number, cx); })) - .child( - div() - .flex_none() - .child(state_glyph(item.state, item.is_pr, cx)), - ) + .child(stack_glyph( + state_glyph(item.state, item.is_pr, cx), + cells, + cx, + )) .child( div() .flex_none() @@ -669,6 +687,7 @@ impl Tty7App { .child(item.title.clone()), ) .children(meta) + .children(links) .into_any_element() } @@ -682,9 +701,27 @@ impl Tty7App { } } +/// A hovered row's way out to GitHub. The click stops at the tile, so the +/// row does not open its detail too. +fn hovered_links(item: &Item, cx: &gpui::App) -> AnyElement { + let url = item.html_url.clone(); + github_tile( + SharedString::from(format!("panel-github-row-gh-{}", item.number)), + Icon::empty().path("icons/github.svg"), + t(L10nKey::GitHubOpenOnGitHub), + cx, + ) + .cursor_pointer() + .on_click(move |_, _window, cx| { + cx.stop_propagation(); + cx.open_url(&url); + }) + .into_any_element() +} + /// A 18px chrome tile, the Info rows' size. pub(crate) fn github_tile( - id: &'static str, + id: impl Into, icon: Icon, tooltip: &'static str, cx: &gpui::App,