From 6e3d2a414d2ea0de3ea1628480c5bce6e932b85e Mon Sep 17 00:00:00 2001 From: Adam Hitchcock Date: Mon, 19 Oct 2026 22:16:18 -0700 Subject: [PATCH] feat(github): row links to GitHub; stacked pull requests drawn as one run (#1116) * feat(github): row links to GitHub; stacked pull requests drawn as one run A hovered list row shows Open on GitHub. Pull requests based on another listed pull request's head branch (in the same repository, the owner compared case-insensitively) are gathered into one run, each above its base, joined by a connector through their glyphs and ending in the stack's base branch. Two pull requests on the same base branch off it in lanes side by side; a cycle draws no trunk. The branches come from /pulls, already fetched. Co-Authored-By: Claude Opus 5.5 * refactor(github): stack rows as per-row cells A stacked row is now one Cell per lane (Empty, Pipe, Node, Join) and the trunk its own StackRow, so the UI and the tests both just map cells. The row constants move to github::rows, which no longer imports panel_github, and stack_order takes the list's &[Item]. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/tty7-core/src/core/github/api.rs | 12 +- crates/tty7-core/src/core/github/mod.rs | 3 +- crates/tty7-core/src/core/github/model.rs | 32 ++- crates/tty7-core/src/core/github/stack.rs | 310 ++++++++++++++++++++++ docs/window/side-panel.mdx | 6 + src/ui/github/detail.rs | 4 +- src/ui/github/mod.rs | 1 + src/ui/github/rows.rs | 107 ++++++++ src/ui/panel_github.rs | 63 ++++- 9 files changed, 514 insertions(+), 24 deletions(-) create mode 100644 crates/tty7-core/src/core/github/stack.rs create mode 100644 src/ui/github/rows.rs 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,