Merge branch 'worktree-agent-ab1cb9c88357c4b8c' into feat/v4-redesign

# Conflicts:
#	docs/design-system.md
This commit is contained in:
l0ng-ai
2026-09-24 19:19:09 +08:00
3 changed files with 278 additions and 139 deletions
+9 -1
View File
@@ -87,7 +87,15 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F`
- **文件树**:顶部是 28px 的浅底搜索框(圆角 7)。行高 26px、圆角 6;目录前有展开 chevron,
文件留空同宽;文件名中性;A/M/? 等尾部字符承载 Git 状态;目录用 5px 小点表示子项变化;
忽略项文字辅助色、图标半透明,不用斜体;冲突才整行提升。
- **Git 面板**:改动列表是主体。可执行的提交按钮才突出;未填写信息或不可提交时保持次级。
- **Git 面板**:改动列表是主体。顶部固定块(分支行 28px / 提交信息框 / 提交行)上 8、块间 10、下 14。
分支名 medium,末尾同步按钮 26px。提交信息框静止 56px、淡底、`ROW_RADIUS`。
提交按钮是分体控件(26px、圆角 6、0.5px 内嵌分隔):可提交时整体反色中性(正文色底 + 表面色字),
这是面板里唯一的实心形状;不可提交时回到信息框同款淡底,不宣传不能执行的动作。
分组之间空 16px,组标题 22px、11.5/16rem medium 辅助色、句首大写(不再全大写),数量右对齐。
文件行 26px、圆角 6:状态字母等宽 semibold 按状态着色,文件名正文色优先占宽(下限 40px),
目录右对齐、从开头省略。History 区以细线分隔,标题 32px;提交行 26px,1px 连线 + 7px 节点,
HEAD 实心、其余空心环;整页只有一条 lane 时连线与节点改用中性色;时间列固定宽度右对齐,
HEAD 引用用淡底胶囊。
- **设置**:章节标题 > 标签 > 描述;开关/滑块/主操作共享强调色;分隔线弱于控件边界。
- **菜单与命令面板**:亮度抬升、有限阴影;当前键盘行清楚,普通项不着色;小窗口内可滚动。
- **终端**:ANSI 是用户内容,不拿来充当 UI 错误/成功配色;应用设计调整不得反转状态含义。
+118 -65
View File
@@ -78,7 +78,10 @@ use crate::ui::scm::state::RepoKey;
/// A taller row against an unchanged [`GRAPH_LANE_W`] steepens a merge's
/// diagonal, which is the safe direction: the failure this pitch is guarding
/// against is an angle read as *shallower* than the jump it draws.
const GRAPH_ROW_H: f32 = 24.;
///
/// Now 26, the file list's own pitch: the section reads as one more list of
/// rows under the groups rather than as a denser instrument bolted on below.
const GRAPH_ROW_H: f32 = 26.;
/// Rows materialized above and below the visible band, so a fast scroll never
/// outruns the window into blank space, and the "load more" band — laid out
/// one row past the window's end — stays below the fold until it is real.
@@ -92,7 +95,10 @@ const GRAPH_WINDOW_MARGIN: usize = 4;
/// `round(12 × 1.618) = 20px` — so 24 is that line plus 2px of air on each
/// side. The resize constants below are counted against it, which is why it
/// has to stay honest.
const GRAPH_HEADER_H: f32 = 24.;
///
/// 32 since the section took the panel's own band rhythm: the header is where
/// the section starts, and it wants more air than a row of it.
const GRAPH_HEADER_H: f32 = 32.;
/// The header's controls: the filter tile and the scope picker beside it.
///
@@ -102,6 +108,15 @@ const GRAPH_HEADER_H: f32 = 24.;
const GRAPH_TILE: f32 = 18.;
const GRAPH_TILE_GLYPH: f32 = 12.;
/// Space after the age column, inside the section's own inset.
const GRAPH_ROW_PAD_R: f32 = 8.;
/// The section's rows sit inside this much of the panel's edge on each side,
/// so a hovered or selected commit wears a rounded band like a file row does
/// rather than a full-bleed stripe. Added to the row's own padding it lands on
/// `CONTENT_INSET`.
const GRAPH_SIDE: f32 = CONTENT_INSET - 6.;
/// Horizontal distance between lane centres.
const GRAPH_LANE_W: f32 = 12.;
@@ -118,8 +133,12 @@ const GRAPH_PAD_R: f32 = 6.;
/// It stays 3 while the row grows, because what a bead is measured against is
/// the string it is on — `GRAPH_LANE_W`, which has not moved. A quarter is the
/// floor the test below holds it to.
const GRAPH_DOT_R: f32 = 3.;
const GRAPH_LINE_W: f32 = 1.5;
///
/// Since the section went hairline: a 7px node on a 1px string, and the ring a
/// node wears is a touch heavier than the string so it closes cleanly.
const GRAPH_DOT_R: f32 = 3.5;
const GRAPH_LINE_W: f32 = 1.;
const GRAPH_RING_W: f32 = 1.2;
/// Most of the panel's width belongs to the message. Thirty percent is what
/// leaves five lanes at the 260px default and still keeps a readable column.
@@ -138,17 +157,17 @@ const _: () = assert!(GRAPH_MAX_LANES <= LANE_SLOTS);
/// file list has to keep a usable part of a short one.
///
/// Both of the fixed ones are counted in rows against the header: what they
/// mean is a number of commits, not a number of pixels. 260 is `24 + 9.8 × 24`
/// mean is a number of commits, not a number of pixels. 287 is `32 + 9.8 × 26`
/// — nine commits and most of a tenth, and the fraction is deliberate, because
/// a row cut by the bottom edge is the only honest way a fixed-height list says
/// there is more below it. 100 is `24 + 3.2 × 24`, three and a bit, which is
/// there is more below it. 116 is `32 + 3.2 × 26`, three and a bit, which is
/// the least that still looks like history rather than like a mistake.
///
/// They grew with [`GRAPH_ROW_H`] — 220 and 88 around a 20px row — precisely
/// They grew with [`GRAPH_ROW_H`] — 220 and 88 around a 20px row, 260 and 100 around 24 — precisely
/// because they are counted in commits: holding the pixels would have quietly
/// bought the file list 40px by showing two fewer commits.
const GRAPH_H_DEFAULT: f32 = 260.;
const GRAPH_H_MIN: f32 = 100.;
const GRAPH_H_DEFAULT: f32 = 287.;
const GRAPH_H_MIN: f32 = 116.;
const GRAPH_H_MAX_RATIO: f32 = 0.65;
/// The divider's grab area, matching `RESIZE_HANDLE_WIDTH` on the other axis.
@@ -343,6 +362,18 @@ struct GraphPaint {
selected_surface: Hsla,
/// Whether a "load more" band follows the last row.
more: bool,
/// Set when the whole page is one lane. A straight line of history has no
/// branch identity to colour, so it is drawn in neutrals: a hairline
/// string and muted rings, with only HEAD filled in ink.
linear: Option<Neutral>,
}
/// The inks a single-lane page is painted in.
#[derive(Clone, Copy)]
struct Neutral {
line: Hsla,
ring: Hsla,
head: Hsla,
}
/// Paint the whole gutter in one pass.
@@ -369,6 +400,12 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
.max(0) as usize;
let cx_of = |column: Lane| left + lane_center_x(column, scale);
let line_ink = |ink: u32| -> Hsla {
match p.linear {
Some(n) => n.line,
None => gpui::rgb(ink).into(),
}
};
let vline = |x: f32, y0: f32, y1: f32| {
Bounds::from_corners(
point(px(x - GRAPH_LINE_W / 2.), px(y0)),
@@ -388,14 +425,14 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
for (column, ink) in band.top.iter().enumerate() {
if let Some(ink) = ink {
window.paint_quad(fill(vline(cx_of(column as Lane), y0, mid), gpui::rgb(*ink)));
window.paint_quad(fill(vline(cx_of(column as Lane), y0, mid), line_ink(*ink)));
}
}
for (column, ink) in band.bottom.iter().enumerate() {
if let Some(ink) = ink {
window.paint_quad(fill(
vline(cx_of(column as Lane), mid, y0 + GRAPH_ROW_H),
gpui::rgb(*ink),
line_ink(*ink),
));
}
}
@@ -420,18 +457,28 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
px(mid + GRAPH_LINE_W / 2.),
),
),
gpui::rgb(*ink),
line_ink(*ink),
));
}
let node = project(row.node, p.max_lanes);
let ink = gpui::rgb(column_ink(
node,
row.color,
p.max_lanes,
p.overflowing,
&p.lanes,
));
let is_head = p
.page
.commits
.get(i)
.is_some_and(|c| c.refs.iter().any(|r| r.kind == RefKind::Head));
let ink: Hsla = match p.linear {
Some(n) if is_head => n.head,
Some(n) => n.ring,
None => gpui::rgb(column_ink(
node,
row.color,
p.max_lanes,
p.overflowing,
&p.lanes,
))
.into(),
};
let cx = cx_of(node);
let dot = |r: f32| {
Bounds::from_corners(
@@ -452,32 +499,34 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
p.surface
};
if row.parents > 1 {
// A merge is a ring. It is the one row shape a reader scans for,
// and an outline reads at 8px where a second fill colour does not.
// A merge is the larger ring. It is the one row shape a reader
// scans for, and an outline reads at 9px where a second fill
// colour does not.
let r = GRAPH_DOT_R + 1.;
window.paint_quad(quad(
dot(r),
Corners::all(px(r)),
hole,
Edges::all(px(GRAPH_LINE_W)),
Edges::all(px(GRAPH_RING_W)),
ink,
BorderStyle::Solid,
));
} else if row.parents == 0 {
// A root has nothing below it; hollow says "the line stops here"
// without needing a second glyph.
} else if is_head {
// Where you are is the one filled bead on the string.
window.paint_quad(
fill(dot(GRAPH_DOT_R), ink).corner_radii(Corners::all(px(GRAPH_DOT_R))),
);
} else {
// Every other commit is a hollow bead, so HEAD is found by shape
// before it is found by the chip beside it.
window.paint_quad(quad(
dot(GRAPH_DOT_R),
Corners::all(px(GRAPH_DOT_R)),
hole,
Edges::all(px(GRAPH_LINE_W)),
Edges::all(px(GRAPH_RING_W)),
ink,
BorderStyle::Solid,
));
} else {
window.paint_quad(
fill(dot(GRAPH_DOT_R), ink).corner_radii(Corners::all(px(GRAPH_DOT_R))),
);
}
}
@@ -491,21 +540,16 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
let column = project(*lane, p.max_lanes);
let ink = column_ink(column, *lane, p.max_lanes, p.overflowing, &p.lanes);
let x = cx_of(column);
let ink = line_ink(ink);
if p.more {
window.paint_quad(fill(
vline(x, y0, y0 + GRAPH_ROW_H),
Hsla::from(gpui::rgb(ink)).opacity(0.3),
));
window.paint_quad(fill(vline(x, y0, y0 + GRAPH_ROW_H), ink.opacity(0.3)));
} else {
// Three steps rather than a gradient: a gradient would be a
// second `Background` kind for four pixels of ink.
const STEP: f32 = 3.;
for (step, alpha) in [0.5f32, 0.3, 0.15].into_iter().enumerate() {
let a = y0 + step as f32 * STEP;
window.paint_quad(fill(
vline(x, a, a + STEP),
Hsla::from(gpui::rgb(ink)).opacity(alpha),
));
window.paint_quad(fill(vline(x, a, a + STEP), ink.opacity(alpha)));
}
}
}
@@ -537,8 +581,8 @@ impl Tty7App {
return Some(
div()
.flex_none()
.pt(px(2.))
.pb(px(4.))
.pt(px(4.))
.pb(px(12.))
.border_t_1()
.border_color(cx.theme().border)
.child(self.graph_header(repo, None, cx))
@@ -826,6 +870,11 @@ impl Tty7App {
.and_then(|oid| page.commits.iter().position(|c| c.oid == oid)),
selected_surface: gpui::rgb(sf.selected).into(),
more,
linear: (page.max_lanes <= 1).then(|| Neutral {
line: cx.theme().border,
ring: cx.theme().muted_foreground,
head: cx.theme().foreground,
}),
};
stack = stack.child(
canvas(
@@ -846,7 +895,7 @@ impl Tty7App {
.min_h_0()
.overflow_y_scroll()
.track_scroll(&self.scm.graph.scroll)
.child(stack);
.child(div().px(px(GRAPH_SIDE)).child(stack));
crate::ui::scrollbar::with_vertical_scrollbar(
"scm-graph-scrollbar",
scroller,
@@ -858,9 +907,9 @@ impl Tty7App {
///
/// Column order is fixed and every optional part has a hard cap, so the
/// worst case cannot squeeze the message to nothing: type prefix, message,
/// ref chip, age. The age goes when a ref chip is present — a chip says
/// where a branch is, which is worth more here than three characters of
/// "3d", and the full timestamp is in the tooltip either way.
/// ref chip, age. The age is a fixed right-aligned column on every row,
/// chip or not, so the times stack into one ragged-left column down the
/// section and the eye can run down it.
fn graph_row(
&self,
repo: &RepoKey,
@@ -883,10 +932,11 @@ impl Tty7App {
h_flex()
.id(SharedString::from(format!("scm-graph-row-{i}")))
.items_center()
.gap(px(4.))
.gap(px(6.))
.h(px(GRAPH_ROW_H))
.pl(px(gutter))
.pr(px(CONTENT_INSET))
.pr(px(GRAPH_ROW_PAD_R))
.rounded(px(6.))
.cursor_pointer()
// The panel's own neutral selection and hover fills, the same two
// the file list above uses. A tinted band would make this one list
@@ -903,7 +953,7 @@ impl Tty7App {
h_flex()
.flex_1()
.min_w(px(0.))
.gap(px(2.))
.gap(px(4.))
.children(
prefix.map(|(kind, breaking)| self.graph_type_prefix(kind, breaking, cx)),
)
@@ -924,21 +974,21 @@ impl Tty7App {
// The age is a mono token, so its column is measured in characters:
// the widest it ever prints is four (`12mo`), and four of the mono
// face's advances at `META_MONO` is `4 × 11 × 0.6` = 26.4, rounded
// up to 27.
.when(deco.is_none(), |d| {
d.child(
div()
.flex_none()
.min_w(px(27.))
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(relative_time(
now,
commit.author.at.unix,
))),
)
})
// up to 27. Right-aligned inside it, so `3d` and `12mo` end on the
// same edge.
.child(
div()
.flex_none()
.w(px(27.))
.text_right()
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(relative_time(
now,
commit.author.at.unix,
))),
)
.tooltip({
let text = commit_tooltip(commit, now);
move |window, cx| {
@@ -1093,10 +1143,13 @@ impl Tty7App {
// weight; everything else is context. The fill is grey, not brand:
// `theme.accent` is a neutral surface tint here, and the only
// saturated colour the section spends is a lane's.
//
// A faint neutral pill in muted ink: the filled bead in the gutter
// already says "you are here", so the chip only has to name it.
RefKind::Head => (
theme.accent.opacity(0.28),
theme.foreground,
gpui::FontWeight::SEMIBOLD,
theme.foreground.opacity(0.06),
theme.muted_foreground,
gpui::FontWeight::NORMAL,
),
// Tags are yellow because tags are yellow — in git's own output,
// in every other client, and in the reader's memory.
+151 -73
View File
@@ -32,9 +32,9 @@ use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App};
use crate::ui::host_ops::{HostId, SharedHost};
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::right_panel::{
HEADING, META, META_MONO, ROW_INSET, SEARCH_H, action_strip, git_badge, info_chip,
META, META_MONO, ROW_INSET, SEARCH_H, action_strip, git_badge, info_chip,
};
use crate::ui::rounding::{CARD_RADIUS, HAIRLINE, RoundedCorners as _, segment_corners};
use crate::ui::rounding::{HAIRLINE, ROW_RADIUS, RoundedCorners as _, segment_corners};
use crate::ui::scm::ScmIntent;
use crate::ui::scm::path::split_display_path;
use crate::ui::scm::state::{RepoKey, ScmGroup};
@@ -65,25 +65,21 @@ const BRANCHES_IN_MENU: usize = 12;
/// changes the user came to look at.
const UNTRACKED_AUTO_COLLAPSE: usize = 20;
/// Message fields share the search field's 30px height and grow with content.
/// The message box rests at 56px — room for a subject and the start of a body
/// — and grows with content from there.
const MSG_LINE: f32 = 20.;
const MSG_PAD_X: f32 = 9.;
const MSG_PAD_Y: f32 = 5.;
const MSG_MIN_H: f32 = MSG_PAD_Y + MSG_LINE + MSG_PAD_Y;
const MSG_PAD_X: f32 = 10.;
const MSG_PAD_Y: f32 = 8.;
const MSG_MIN_H: f32 = 56.;
/// The ceiling, as `MSG_ROWS_MAX` bare lines. It is a rail, not a border-box
/// sum: the wrapper's own hairline and padding are not in it, so at the very
/// top of the box's growth the last row is clipped rather than framed.
const MSG_MAX_H: f32 = MSG_ROWS_MAX as f32 * MSG_LINE;
/// The invariant the comment above describes, made unbreakable: the resting
/// message box and the panel's search strip are the same height, or this does
/// not build.
const _: () = assert!(MSG_MIN_H == SEARCH_H);
/// One line at rest and it grows into the message. The box is one row in a
/// column of rows, and a box that stands four lines tall before anything has
/// been typed pushes the file list — the thing the panel is for — off the
/// bottom of a 260px-wide sidebar.
/// One line of input at rest inside the 56px box, growing into the message.
/// The box's resting height is the wrapper's floor, not the row count: a box
/// that stood six lines tall before anything was typed would push the file
/// list — the thing the panel is for — off the bottom of the panel.
const MSG_ROWS: usize = 1;
const MSG_ROWS_MAX: usize = 6;
@@ -110,6 +106,27 @@ const MSG_ROWS_MAX: usize = 6;
const COMMIT_H: f32 = ROW_H;
const COMMIT_CHEVRON_W: f32 = COMMIT_H - 2.;
const COMMIT_GLYPH: f32 = 11.;
const COMMIT_RADIUS: gpui::Pixels = px(6.);
/// The block pinned above the file list — branch, message, commit — reads as
/// one unit: 8px under the tab row, 10px between its three parts and 14px
/// before the first group.
const PINNED_TOP: f32 = 8.;
const PINNED_GAP: f32 = 10.;
const PINNED_BOTTOM: f32 = 14.;
/// The branch row's trailing refresh/sync tile.
const BRANCH_TILE: f32 = 26.;
/// Space between two change groups, and the height of a group's header.
const GROUP_GAP: f32 = 16.;
const GROUP_HEADER_H: f32 = 22.;
/// Group headers are band labels: a half step under `META`, medium weight,
/// muted — the file names under them are what the panel is for.
const GROUP_HEADING: f32 = 11.5 / 16.;
/// A file row's name keeps at least this much before the directory beside it
/// has given up all of its width.
const NAME_FLOOR: f32 = 40.;
/// How long to wait before asking git again about a directory that answered
/// with nothing.
@@ -316,6 +333,7 @@ impl Tty7App {
// it is the same 24, so the row has one interior height and the
// type sits inside it rather than setting it.
.min_h(rems(28. / 16.))
.mt(px(PINNED_TOP))
.pl(px(CONTENT_INSET))
.pr(px(crate::ui::app::tile_trailing_inset_sm()))
.child(
@@ -367,6 +385,7 @@ impl Tty7App {
.min_w(px(0.))
.line_height(relative(1.))
.truncate()
.font_weight(gpui::FontWeight::MEDIUM)
.child(head_label(&status.head)),
)
.w_full()
@@ -409,12 +428,12 @@ impl Tty7App {
} else {
Icon::empty().path("icons/git-sync.svg")
}),
crate::ui::app::TILE_SIZE_SM,
BRANCH_TILE,
crate::ui::app::TILE_GLYPH_SM,
false,
cx,
)
.rounded_md()
.rounded(px(6.))
// With no branch to move the tile could only ever lose (#545):
// upstream is None at a detached or unborn HEAD by definition,
// so sync degenerates into scm_push, which has nothing to
@@ -797,7 +816,7 @@ impl Tty7App {
.key_context(COMMIT_KEY_CONTEXT)
.flex_none()
.px(px(CONTENT_INSET))
.pt(px(6.))
.pt(px(PINNED_GAP))
.child(
div()
// `MSG_MIN_H` is `panel_search`'s height and the paddings
@@ -808,7 +827,7 @@ impl Tty7App {
// ever measures itself differently.
.min_h(px(MSG_MIN_H))
.max_h(px(MSG_MAX_H))
.rounded(CARD_RADIUS)
.rounded(ROW_RADIUS)
// Half a rung, not a whole one. The hover step is what a
// row wears when the pointer is on it — a transient state —
// and the message box wears its fill all the time, so at
@@ -928,16 +947,15 @@ impl Tty7App {
let repo_for_button = repo.clone();
let live = plan.enabled;
let staged = status.staged().count();
let theme = cx.theme();
let (muted, fg) = (theme.muted_foreground, theme.primary_foreground);
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let muted = cx.theme().muted_foreground;
let paint = commit_paint(live, cx);
h_flex()
.flex_none()
.items_center()
.gap(px(8.))
.px(px(CONTENT_INSET))
.pt(px(6.))
.pb(px(8.))
.pt(px(PINNED_GAP))
.pb(px(PINNED_BOTTOM))
// The reading the button acts on, in the row it acts from. It
// gives way first: a long count is still a count, and the control
// beside it is the thing that has to keep its shape.
@@ -965,13 +983,18 @@ impl Tty7App {
// `segment_corners` so their own hit shapes stay inside the
// frame's radius — `overflow_hidden` would do that too, and
// would take the chevron's popup menu with it.
//
// Committable, the whole frame inverts — ink fill, surface
// label — which is the one prominent shape on the panel. With
// nothing to commit it sinks back to the message box's faint
// fill, so it never advertises an action that cannot run.
h_flex()
.flex_none()
.items_center()
.h(px(COMMIT_H))
.rounded(CARD_RADIUS)
.bg(gpui::rgb(field_fill(sf)))
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.rounded(COMMIT_RADIUS)
.bg(paint.fill)
.hover(move |s| s.bg(paint.hover))
.child(
// `xsmall` is the token that sets a `Button`'s label to
// `META`, and a control's label is what `META` is for:
@@ -985,19 +1008,19 @@ impl Tty7App {
// ghost hovers to a colour close enough to it that
// the pointer would get no answer. This walks the
// same two rungs the file rows walk.
.custom(commit_half(cx))
.when(live, |button| button.primary())
.custom(commit_half(paint.ink, cx))
.xsmall()
.label(t(plan.label))
.h_full()
.px(px(10.))
.rounded_corners(segment_corners(0, 2, CARD_RADIUS, HAIRLINE))
.px(px(12.))
.font_weight(gpui::FontWeight::MEDIUM)
.rounded_corners(segment_corners(0, 2, COMMIT_RADIUS, HAIRLINE))
// The ink is named here rather than left to the
// variant, so the disabled half greys out: it lands
// over the variant's own paint because
// `refine_style` runs after everything the variant
// does.
.text_color(if live { fg } else { muted })
.text_color(if live { paint.ink } else { muted })
.disabled(!live)
.when(!live, |b| b.tooltip(t(plan.reason)))
.on_click(cx.listener(move |this, _, window, cx| {
@@ -1010,20 +1033,20 @@ impl Tty7App {
);
})),
)
// The seam between the halves is the panel showing through
// the fill, not a line drawn on top of it — a rule would be
// the one stroke left on the panel. A div rather than
// The seam is a short rule in the label's own ink, inset
// from both edges so it reads as a divider inside one shape
// rather than a cut through it. A div rather than
// `border_l_1` on the chevron, because a ghost `Button`
// repaints its border colour transparent in every state it
// has and the seam would vanish under the pointer.
.child(
div()
.flex_none()
.w(HAIRLINE)
.h_full()
.bg(gpui::rgb(sf.base)),
.w(px(0.5))
.h(px(COMMIT_H - 12.))
.bg(paint.seam),
)
.child(self.scm_commit_menu(repo, cx)),
.child(self.scm_commit_menu(repo, paint.ink, cx)),
)
.into_any_element()
}
@@ -1033,10 +1056,15 @@ impl Tty7App {
/// Never disabled, whatever the button beside it is doing: stash and amend
/// still mean something with nothing staged, and this is the only way to
/// reach them.
fn scm_commit_menu(&self, repo: &RepoKey, cx: &mut Context<Self>) -> AnyElement {
fn scm_commit_menu(
&self,
repo: &RepoKey,
ink: gpui::Hsla,
cx: &mut Context<Self>,
) -> AnyElement {
let amend = self.scm.amend;
Button::new("scm-commit-menu")
.custom(commit_half(cx))
.custom(commit_half(ink, cx))
.icon(Icon::new(IconName::ChevronDown))
// `Button` sizes an icon off its own `Size`, so the glyph is asked
// for by way of the size that produces it — the same conversion
@@ -1044,7 +1072,7 @@ impl Tty7App {
.with_size(px(COMMIT_GLYPH / crate::ui::tab_strip::BUTTON_ICON_SCALE))
.w(px(COMMIT_CHEVRON_W))
.h_full()
.rounded_corners(segment_corners(1, 2, CARD_RADIUS, HAIRLINE))
.rounded_corners(segment_corners(1, 2, COMMIT_RADIUS, HAIRLINE))
.dropdown_menu_with_anchor(gpui::Anchor::TopRight, {
let app = cx.entity().downgrade();
let repo = repo.clone();
@@ -1291,7 +1319,8 @@ impl Tty7App {
status: &Arc<WorkingTreeStatus>,
cx: &mut Context<Self>,
) -> AnyElement {
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.));
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).pb(px(12.));
let mut first = true;
for group in ScmGroup::ORDER {
let entries: Vec<&StatusEntry> = status
.entries
@@ -1302,6 +1331,12 @@ impl Tty7App {
continue;
}
let collapsed = self.scm.group_collapsed(group, entries.len());
// Groups are told apart by the pause before each one, not by a
// rule: 16px, and none above the first.
if !first {
list = list.child(div().flex_none().h(px(GROUP_GAP)));
}
first = false;
list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx));
if collapsed {
continue;
@@ -1356,7 +1391,6 @@ impl Tty7App {
) -> AnyElement {
let count = entries.len();
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let mono = cx.theme().mono_font_family.clone();
let id = SharedString::from(format!("scm-group-{group:?}"));
let actions = self.scm_group_actions(&id, repo, group, entries, sf.hover, cx);
h_flex()
@@ -1364,10 +1398,11 @@ impl Tty7App {
.group(id)
.relative()
.items_center()
.gap(px(8.))
.min_h(rems(ROW_H / 16.))
.gap(px(6.))
.min_h(rems(GROUP_HEADER_H / 16.))
.mb(px(1.))
.px(px(ROW_INSET))
.rounded(px(5.))
.rounded(px(6.))
.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.on_click(cx.listener(move |this, _, _window, cx| {
@@ -1388,24 +1423,25 @@ impl Tty7App {
} else {
IconName::ChevronDown
})
.xsmall(),
.size(px(COMMIT_GLYPH)),
),
)
// Sentence case, medium, muted: a band label over the rows rather
// than a heading competing with them.
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(HEADING))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_size(rems(GROUP_HEADING))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(cx.theme().muted_foreground)
.child(t(group_label(group)).to_uppercase()),
.child(t(group_label(group))),
)
.child(
div()
.flex_none()
.text_size(rems(META_MONO))
.font_family(mono)
.text_size(rems(GROUP_HEADING))
.text_color(cx.theme().muted_foreground)
.child(count.to_string()),
)
@@ -1449,7 +1485,7 @@ impl Tty7App {
.min_w_0()
.px(px(ROW_INSET))
.py(px(3.))
.rounded(px(5.))
.rounded(px(6.))
.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.when(selected, |s| s.bg(gpui::rgb(sf.selected)))
@@ -1483,10 +1519,12 @@ impl Tty7App {
})
.child(git_badge(letter, status_color(deco, cx), &mono))
// Names use the same resting/selected hierarchy as sidebar rows.
// The name takes its own width first and only then shrinks, down
// to a floor; the directory beside it gets whatever is left.
.child(
div()
.flex_1()
.min_w_0()
.flex_shrink(1.)
.min_w(px(NAME_FLOOR))
.truncate()
.text_size(rems(crate::ui::right_panel::TEXT))
.text_color(if deco == DecoStatus::Conflict {
@@ -1500,21 +1538,22 @@ impl Tty7App {
.when(deco == DecoStatus::Deleted, |s| s.line_through())
.child(name.to_string()),
)
// Cap the secondary directory while the filename fills the rest.
// A rem cap also gives short paths their intrinsic width inside
// the context-menu wrapper's flex layout.
.when(!dir.is_empty(), |this| {
this.child(
div()
.flex_none()
.max_w(rems(3.))
.min_w_0()
.truncate()
.text_size(rems(META))
.text_color(cx.theme().muted_foreground)
.child(dir.to_string()),
)
})
// The directory is flush right and gives way from its *start*:
// the folder nearest the file is the part that says where it is,
// and `src/ui/…` cut from the end keeps the part that says least.
// The row tooltip has the whole path either way.
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis_start()
.text_right()
.text_size(rems(META))
.text_color(cx.theme().muted_foreground)
.child(dir.to_string()),
)
.child(actions)
.into_any_element()
}
@@ -1954,15 +1993,54 @@ fn field_fill(sf: crate::ui::presets::Surface) -> u32 {
/// `active` slot, and a dropdown marks its trigger selected for as long as
/// the menu is open. Anything but transparent there parks a block on the
/// chevron for the whole time the user is reading the menu.
fn commit_half(cx: &gpui::App) -> ButtonCustomVariant {
fn commit_half(ink: gpui::Hsla, cx: &gpui::App) -> ButtonCustomVariant {
let clear = gpui::transparent_black();
ButtonCustomVariant::new(cx)
.color(clear)
.foreground(cx.theme().foreground)
.foreground(ink)
.hover(clear)
.active(clear)
}
/// How the split commit control is painted.
#[derive(Clone, Copy)]
struct CommitPaint {
fill: gpui::Hsla,
hover: gpui::Hsla,
ink: gpui::Hsla,
seam: gpui::Hsla,
}
/// Committable: an inverted neutral — the panel's ink as the fill and its own
/// surface as the label, so it is the one solid shape in a panel of rows, and
/// it stays neutral rather than borrowing the accent the sidebar and focus
/// rings already spend. Not committable: the message box's faint fill and the
/// panel's ink, a control that is there but is not asking for anything.
///
/// The label ink is the opaque surface rather than `theme.background`, which
/// carries the window's transparency when one is configured.
fn commit_paint(live: bool, cx: &gpui::App) -> CommitPaint {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let fg = cx.theme().foreground;
match live {
true => {
let ink: gpui::Hsla = gpui::rgb(sf.base).into();
CommitPaint {
fill: fg,
hover: fg.blend(ink.opacity(0.14)),
ink,
seam: ink.opacity(0.24),
}
}
false => CommitPaint {
fill: gpui::rgb(field_fill(sf)).into(),
hover: gpui::rgb(sf.hover).into(),
ink: fg,
seam: fg.opacity(0.18),
},
}
}
fn branch_note(text: &str, ink: gpui::Hsla, mono: &SharedString) -> AnyElement {
div()
.flex_none()