fix(editor): polish the git panel ready view

The expanded milestone card painted full-width over the timeline rail
(breaking the connector and blending into the list) — inset it as a
proper card clear of the rail. Unify Restore/Copy-hash under the
panel's shared secondary button chrome, add row hover, anchor the
pull/push/overflow cluster to the right edge, and size the panel to
its content with a clamped max instead of a fixed filler. Split the
expanded-card subsystem out of the over-cap ready file.
This commit is contained in:
Fini 2026-07-18 16:34:24 +08:00
parent e5e41fbefe
commit 84772d2d0e
4 changed files with 537 additions and 302 deletions

View file

@ -0,0 +1,313 @@
//! The ready-view history list's expanded inline detail card
//! (里程碑详情 — semantic diff summary + patch list + 恢复/复制哈希
//! buttons). Split out of `git_panel_ready.rs` to keep that file
//! under the repo's 800-line cap.
//!
//! The card paints as a proper inset panel (jian `Card` — a muted
//! fill plus a border at a radius, the same primitive
//! `agent_settings_panel_card` uses for provider cards) rather than a
//! full-width flush band, for two reasons: it reads as a distinct
//! "detail" surface instead of bleeding into the timeline flow, AND —
//! since a full-width band would paint directly over the timeline
//! rail's dot column — insetting it past the rail keeps the vertical
//! connector line visible underneath the expanded card instead of
//! visually severing it.
use crate::widgets::git_panel::{GitPanel, GitPanelHit};
use crate::widgets::git_panel_ready::{MAX_COMMITS, MSG_X, READY_PAD, ROW_H};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect};
use jian_widgets::components::card::Card;
use op_editor_core::CommitDiffSummary;
use op_editor_core::CommitDiffView;
/// Base height of the inline commit-detail card (里程碑详情 title +
/// one status/summary line + a restore/copy-hash button row) under
/// an expanded commit row (TS `HistoryMilestoneRow` detail block).
/// Grows by one [`PATCH_ROW_H`] per rendered patch line. The extra
/// height over the button row (anchored at `height - 52`) is bottom
/// padding so the next commit row stays well clear of the buttons.
const CARD_BASE_H: f32 = 104.0;
/// Per-patch-line height in the expanded diff list.
const PATCH_ROW_H: f32 = 14.0;
/// Cap on patch lines drawn in the card (TS scrolls a `max-h-24`
/// list; the summary counts still report the full totals above the
/// list).
const MAX_PATCH_ROWS: usize = 6;
impl GitPanel<'_> {
/// Number of patch lines the expanded card will draw (0 unless
/// the diff is `Ready`, capped at [`MAX_PATCH_ROWS`]).
fn card_patch_rows(&self) -> usize {
match &self.state.expanded_commit_diff {
Some(CommitDiffView::Ready(s)) => s.patches.len().min(MAX_PATCH_ROWS),
_ => 0,
}
}
/// Total height of the open card — base chrome plus one row per
/// drawn patch line. Shared by paint + the hit-test walk so they
/// agree.
pub(super) fn expanded_card_height(&self) -> f32 {
CARD_BASE_H + self.card_patch_rows() as f32 * PATCH_ROW_H
}
/// Extra height contributed by an open inline commit-detail card
/// — [`Self::expanded_card_height`] when a valid row is expanded,
/// else 0. Shared by [`GitPanel::ready_height`] + the history
/// paint / hit-test walk so they stay in lockstep.
pub(super) fn expanded_card_extra(&self) -> f32 {
let n = self.state.recent_commits.len().min(MAX_COMMITS);
match self.state.expanded_commit {
Some(e) if e < n => self.expanded_card_height(),
_ => 0.0,
}
}
/// Vertical offset inserted before commit row `i` by a detail
/// card open under an earlier row.
pub(super) fn expand_offset_before(&self, i: usize) -> f32 {
let n = self.state.recent_commits.len().min(MAX_COMMITS);
match self.state.expanded_commit {
Some(e) if e < i && e < n => self.expanded_card_height(),
_ => 0.0,
}
}
/// The inline detail card's own rect at `card_top` — inset so
/// its left edge clears the timeline rail + dot column (see the
/// module doc). Shared by paint + the button-rect geometry so
/// they can never drift apart.
fn card_rect_at(&self, rect: Rect, card_top: f32) -> Rect {
let card_x = rect.origin.x + MSG_X - 8.0;
Rect {
origin: Point2D::new(card_x, card_top),
size: Point2D::new(
(rect.origin.x + rect.size.x - READY_PAD - card_x).max(0.0),
self.expanded_card_height(),
),
}
}
/// `(恢复, 复制哈希)` button rects for the inline card whose top
/// edge is `card_top`. Backend-free fixed widths keep paint +
/// hit aligned. The button row is bottom-anchored so a growing
/// patch list pushes it down in lockstep with paint.
fn commit_card_button_rects(&self, rect: Rect, card_top: f32) -> (Rect, Rect) {
// Button row sits at content position `52 + patches`; the rest of the
// card height (CARD_BASE_H - 52 = 52px) is bottom padding so the next
// commit row keeps well clear of the buttons.
let card = self.card_rect_at(rect, card_top);
let btn_y = card_top + self.expanded_card_height() - 52.0;
let h = 24.0;
let x = card.origin.x + 12.0;
let restore = Rect {
origin: Point2D::new(x, btn_y),
size: Point2D::new(64.0, h),
};
let copy = Rect {
origin: Point2D::new(x + 64.0 + 8.0, btn_y),
size: Point2D::new(84.0, h),
};
(restore, copy)
}
/// `(恢复, 复制哈希)` rects for the currently-expanded card, or
/// `None` when nothing is expanded. Mirrors the history paint
/// y-walk so the hit-test lands exactly where paint drew the
/// buttons.
pub(super) fn ready_commit_card_buttons(&self, rect: Rect) -> Option<(Rect, Rect)> {
let n = self.state.recent_commits.len().min(MAX_COMMITS);
let e = self.state.expanded_commit.filter(|&e| e < n)?;
// Row `e`'s text baseline (no prior card offsets — only one card
// can be open) then `+ ROW_H` to the card top, matching paint.
let card_top = rect.origin.y + self.history_first() + (e as f32 + 1.0) * ROW_H - 6.0;
Some(self.commit_card_button_rects(rect, card_top))
}
/// Paint the inline commit-detail card (里程碑详情) — an inset
/// card (see the module doc) holding the detail title, the
/// semantic diff (TS `GitPanelHistoryDiff`: a summary row + an
/// `op nodeId` patch list, or a loading / initial / no-changes /
/// error line), and a `恢复` / `复制哈希` button row.
pub(super) fn paint_commit_card(&self, cx: &mut PaintCx<'_>, rect: Rect, card_top: f32) {
let t = self.theme;
let card = self.card_rect_at(rect, card_top);
Card {
fill: Some(alpha(t.muted, 0.55)),
border: Some(alpha(t.border, 0.60)),
radius: 8.0,
}
.paint(
cx.backend,
card,
&crate::widgets::button::tokens_from_theme(&t),
);
let body_x = card.origin.x + 12.0;
// Title — `text-[11px] font-medium`.
self.text(
cx,
self.t("git.history.milestoneDetailTitle"),
body_x,
card_top + 18.0,
11.0,
t.foreground,
);
// Diff body (TS `GitPanelHistoryDiff` states).
let status_y = card_top + 38.0;
match &self.state.expanded_commit_diff {
None | Some(CommitDiffView::Loading) => {
self.text(
cx,
self.t("git.history.diff.loading"),
body_x,
status_y,
10.0,
alpha(t.muted_foreground, 0.85),
);
}
Some(CommitDiffView::Initial) => {
self.text(
cx,
self.t("git.history.diff.initialCommit"),
body_x,
status_y,
10.0,
alpha(t.muted_foreground, 0.85),
);
}
Some(CommitDiffView::NoChanges) => {
self.text(
cx,
self.t("git.history.diff.noChanges"),
body_x,
status_y,
10.0,
alpha(t.muted_foreground, 0.85),
);
}
Some(CommitDiffView::Error(msg)) => {
let label = self.t("git.history.diff.error").replace("{{message}}", msg);
self.text(cx, &label, body_x, status_y, 10.0, t.destructive);
}
Some(CommitDiffView::Ready(summary)) => {
self.paint_diff_summary(cx, summary, body_x, status_y);
// Patch list — one `op nodeId` line each (TS font-mono).
for (k, p) in summary.patches.iter().take(MAX_PATCH_ROWS).enumerate() {
let py = card_top + 54.0 + k as f32 * PATCH_ROW_H;
self.text(cx, &p.op, body_x, py, 10.0, t.foreground);
let opw = cx.backend.measure_text(&p.op, 10.0);
self.text(
cx,
&p.node_id,
body_x + opw + 6.0,
py,
10.0,
alpha(t.muted_foreground, 0.70),
);
}
}
}
let (restore, copy) = self.commit_card_button_rects(rect, card_top);
if let Some(e) = self.state.expanded_commit {
// Restore / Copy hash share the same secondary-button look
// (muted fill + border + hover wash) as every other
// non-primary button in the panel — a bordered "Restore"
// next to a bare-text "Copy hash" used to read as two
// different affordances.
self.paint_button_with_hit(
cx,
restore,
self.t("git.history.restoreButton"),
true,
false,
Some(GitPanelHit::RestoreCommit(e)),
);
self.paint_button_with_hit(
cx,
copy,
self.t("git.history.copyHashButton"),
true,
false,
Some(GitPanelHit::CopyCommitHash(e)),
);
}
}
/// Paint the diff summary row — coloured `framesChanged` / `+added` /
/// `-removed` / `~modified` segments left-to-right (TS `GitPanelHistoryDiff`
/// summary spans). Only non-zero counts render.
fn paint_diff_summary(&self, cx: &mut PaintCx<'_>, s: &CommitDiffSummary, x: f32, y: f32) {
let t = self.theme;
// Build the (label, colour) segments first so the draw loop borrows
// `self` only through `self.text` / the backend measure.
let mut segments: Vec<(String, Color)> = Vec::new();
if s.frames_changed > 0 {
segments.push((
self.plural(
"git.history.diff.framesChanged_one",
"git.history.diff.framesChanged_other",
s.frames_changed,
),
t.muted_foreground,
));
}
if s.nodes_added > 0 {
segments.push((
format!(
"+{}",
self.plural(
"git.history.diff.nodesAdded_one",
"git.history.diff.nodesAdded_other",
s.nodes_added,
)
),
t.primary,
));
}
if s.nodes_removed > 0 {
segments.push((
format!(
"-{}",
self.plural(
"git.history.diff.nodesRemoved_one",
"git.history.diff.nodesRemoved_other",
s.nodes_removed,
)
),
t.destructive,
));
}
if s.nodes_modified > 0 {
segments.push((
format!(
"~{}",
self.plural(
"git.history.diff.nodesModified_one",
"git.history.diff.nodesModified_other",
s.nodes_modified,
)
),
t.muted_foreground,
));
}
let mut cur = x;
for (label, color) in &segments {
self.text(cx, label, cur, y, 10.0, *color);
cur += cx.backend.measure_text(label, 10.0) + 10.0;
}
}
/// Pick the `_one` / `_other` plural form (TS i18next English rule: 1 →
/// one) and substitute `{{count}}`. Both keys are `&'static` so they
/// satisfy [`GitPanel::t`]'s static-key contract.
fn plural(&self, one_key: &'static str, other_key: &'static str, count: u32) -> String {
let key = if count == 1 { one_key } else { other_key };
self.t(key).replace("{{count}}", &count.to_string())
}
}
/// A colour at `factor` of its current alpha (Tailwind `/NN`).
fn alpha(c: Color, factor: f32) -> Color {
crate::util::alpha(c, factor)
}

View file

@ -16,7 +16,7 @@ use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect};
use jian_widgets::components::text_area::TextArea;
use op_editor_core::{CommitDiffSummary, CommitDiffView, GitButton};
use op_editor_core::GitButton;
/// Header bar height (TS `px-2.5 py-1.5` around a 28 px `icon-sm`
/// button = 6 + 28 + 6 = 40 px row).
@ -26,7 +26,9 @@ const ICON_BTN: f32 = 28.0;
/// Horizontal inset for the ready view — tighter than the classic
/// body's `PAD` (16) to match the TS header `px-2.5` (10 px). Shared
/// by the header, commit box, and history so the column lines up.
const READY_PAD: f32 = 10.0;
/// `pub(super)` — the commit-detail card (`git_panel_commit_card.rs`)
/// insets against this same edge.
pub(super) const READY_PAD: f32 = 10.0;
/// Commit-box geometry — the TS `p-3` outer wrapper (12 px) around a
/// `rounded-lg` card. The card = a 2-row `text-xs` `leading-relaxed`
/// textarea (`pt-2.5`+`pb-1` ≈ 53 px) over the `justify-end` button row
@ -36,25 +38,31 @@ const COMMIT_H: f32 = 83.0;
/// Height of the commit-signature form (`提交署名`) when it replaces the
/// commit box — heading + subheading + name + email inputs + button row.
const AUTHOR_FORM_H: f32 = 168.0;
const ROW_H: f32 = 26.0;
const MAX_COMMITS: usize = 8;
/// Extra empty body height appended below the recent-commit history so the
/// ready panel reads roomy (TS fixed-height feel) instead of hugging a short
/// history. Constant, so the panel still grows with more commits.
const READY_FILL: f32 = 200.0;
/// `pub(super)` — the commit-detail card computes the card-top offset
/// from a row's position using this same row height.
pub(super) const ROW_H: f32 = 26.0;
/// `pub(super)` — the commit-detail card clamps `expanded_commit`
/// against this same cap.
pub(super) const MAX_COMMITS: usize = 8;
/// Largest height the ready view ever reports. `ready_height` fits
/// its actual content (header + commit box + history rows + one open
/// detail card) rather than padding with fixed filler space, but a
/// long history + a card expanded with a full patch list is clamped
/// here rather than growing the floating popover without bound; the
/// history section clips to the panel rect in that case (see
/// `paint_ready`). True scroll-wheel paging past the clamp is a
/// follow-up — this only guarantees the panel never visually
/// overflows its own rounded-rect background.
const READY_MAX_HEIGHT: f32 = 520.0;
const SUMMARY_MAX: usize = 34;
/// Base height of the inline commit-detail card (里程碑详情 title + one
/// status/summary line + a restore/copy-hash button row) under an expanded
/// commit row (TS `HistoryMilestoneRow` detail block). Grows by one
/// [`PATCH_ROW_H`] per rendered patch line. The extra height over the button
/// row (anchored at `height - 52`) is bottom padding so the next commit row
/// stays well clear of the buttons.
const CARD_BASE_H: f32 = 104.0;
/// Per-patch-line height in the expanded diff list.
const PATCH_ROW_H: f32 = 14.0;
/// Cap on patch lines drawn in the card (TS scrolls a `max-h-24` list;
/// the summary counts still report the full totals above the list).
const MAX_PATCH_ROWS: usize = 6;
/// Timeline rail x-offset from the panel edge — the dot column. Only
/// used within this file; the commit-detail card insets against
/// [`MSG_X`] instead (see `git_panel_commit_card.rs`).
const RAIL_X: f32 = 20.0;
/// Message-column x-offset from the panel edge — clears the rail +
/// dot with a small gutter. `pub(super)` — the commit-detail card
/// anchors its own left edge relative to this same column.
pub(super) const MSG_X: f32 = 40.0;
/// Per-char advance heuristic for the branch label (keeps paint +
/// hit-test aligned without measuring text).
const BRANCH_CHAR_W: f32 = 7.5;
@ -78,7 +86,7 @@ impl GitPanel<'_> {
/// Y of the first history row — below the commit box, or below the taller
/// commit-signature form when it has replaced the box.
fn history_first(&self) -> f32 {
pub(super) fn history_first(&self) -> f32 {
let box_h = if self.state.author_prompt {
AUTHOR_FORM_H
} else {
@ -87,82 +95,15 @@ impl GitPanel<'_> {
COMMIT_TOP + box_h + 24.0
}
/// The panel height for the ready view — header + commit box (or signature
/// form) + the recent-commit rows, plus [`READY_FILL`] body space.
/// The panel height for the ready view — header + commit box (or
/// signature form) + the recent-commit rows + one open detail
/// card (if any), fit to that actual content and capped at
/// [`READY_MAX_HEIGHT`] (see that constant's doc for the overflow
/// story).
pub(super) fn ready_height(&self) -> f32 {
let rows = self.state.recent_commits.len().clamp(1, MAX_COMMITS);
self.history_first() + rows as f32 * ROW_H + PAD + self.expanded_card_extra() + READY_FILL
}
/// Number of patch lines the expanded card will draw (0 unless the diff
/// is `Ready`, capped at [`MAX_PATCH_ROWS`]).
fn card_patch_rows(&self) -> usize {
match &self.state.expanded_commit_diff {
Some(CommitDiffView::Ready(s)) => s.patches.len().min(MAX_PATCH_ROWS),
_ => 0,
}
}
/// Total height of the open card — base chrome plus one row per drawn
/// patch line. Shared by paint + the hit-test walk so they agree.
fn expanded_card_height(&self) -> f32 {
CARD_BASE_H + self.card_patch_rows() as f32 * PATCH_ROW_H
}
/// Extra height contributed by an open inline commit-detail card —
/// [`Self::expanded_card_height`] when a valid row is expanded, else 0.
/// Shared by [`GitPanel::ready_height`] + the history paint / hit-test
/// walk so they stay in lockstep.
fn expanded_card_extra(&self) -> f32 {
let n = self.state.recent_commits.len().min(MAX_COMMITS);
match self.state.expanded_commit {
Some(e) if e < n => self.expanded_card_height(),
_ => 0.0,
}
}
/// Vertical offset inserted before commit row `i` by a detail card
/// open under an earlier row.
fn expand_offset_before(&self, i: usize) -> f32 {
let n = self.state.recent_commits.len().min(MAX_COMMITS);
match self.state.expanded_commit {
Some(e) if e < i && e < n => self.expanded_card_height(),
_ => 0.0,
}
}
/// `(恢复, 复制哈希)` button rects for the inline card whose top edge
/// is `card_top`. Backend-free fixed widths keep paint + hit aligned.
/// The button row is bottom-anchored so a growing patch list pushes it
/// down in lockstep with paint.
fn commit_card_button_rects(&self, rect: Rect, card_top: f32) -> (Rect, Rect) {
// Button row sits at content position `52 + patches`; the rest of the
// card height (CARD_BASE_H - 52 = 52px) is bottom padding so the next
// commit row keeps well clear of the buttons.
let btn_y = card_top + self.expanded_card_height() - 52.0;
let h = 24.0;
let x = rect.origin.x + 40.0; // align with the message column (`pl-10`)
let restore = Rect {
origin: Point2D::new(x, btn_y),
size: Point2D::new(64.0, h),
};
let copy = Rect {
origin: Point2D::new(x + 64.0 + 8.0, btn_y),
size: Point2D::new(84.0, h),
};
(restore, copy)
}
/// `(恢复, 复制哈希)` rects for the currently-expanded card, or `None`
/// when nothing is expanded. Mirrors the history paint y-walk so the
/// hit-test lands exactly where paint drew the buttons.
pub(super) fn ready_commit_card_buttons(&self, rect: Rect) -> Option<(Rect, Rect)> {
let n = self.state.recent_commits.len().min(MAX_COMMITS);
let e = self.state.expanded_commit.filter(|&e| e < n)?;
// Row `e`'s text baseline (no prior card offsets — only one card
// can be open) then `+ ROW_H` to the card top, matching paint.
let card_top = rect.origin.y + self.history_first() + (e as f32 + 1.0) * ROW_H - 6.0;
Some(self.commit_card_button_rects(rect, card_top))
let natural = self.history_first() + rows as f32 * ROW_H + PAD + self.expanded_card_extra();
natural.min(READY_MAX_HEIGHT)
}
/// Resolved branch label (`detached HEAD` fallback).
@ -193,12 +134,17 @@ impl GitPanel<'_> {
}
/// The three header icon buttons: (pull `↓`, push `↑`, overflow `…`).
/// Anchored as a fixed right-aligned cluster (not relative to the
/// branch button) so the gap between every pair — branch↔pull,
/// pull↔push, push↔overflow — is the same 2 px regardless of how
/// much of its clamped width the branch label actually uses; a
/// short branch name used to leave push↔overflow visibly wider
/// than the other two gaps.
pub(super) fn ready_header_buttons(&self, rect: Rect) -> (Rect, Rect, Rect) {
let y = rect.origin.y + (HEADER_H - ICON_BTN) / 2.0;
let branch = self.ready_branch_rect(rect);
let pull_x = branch.origin.x + branch.size.x + 2.0;
let push_x = pull_x + ICON_BTN + 2.0;
let overflow_x = rect.origin.x + rect.size.x - READY_PAD - ICON_BTN;
let push_x = overflow_x - ICON_BTN - 2.0;
let pull_x = push_x - ICON_BTN - 2.0;
let mk = |x: f32| Rect {
origin: Point2D::new(x, y),
size: Point2D::new(ICON_BTN, ICON_BTN),
@ -519,8 +465,11 @@ impl GitPanel<'_> {
// ── Recent-commit history (TS `git-panel-history-list`) ──
// No section header — the timeline / empty line sits directly
// under the commit-box divider.
let mut y = top + self.history_first();
// under the commit-box divider. Clipped to the panel rect —
// `ready_height` clamps at `READY_MAX_HEIGHT`, so a long
// history + an expanded card must clip rather than bleed past
// the panel's rounded background (see that constant's doc).
let history_top = top + self.history_first();
if self.state.recent_commits.is_empty() {
// Empty log → a single centered `git.history.empty` line
// (TS `flex items-center justify-center p-6 text-xs
@ -532,25 +481,34 @@ impl GitPanel<'_> {
cx,
label,
rect.origin.x + (rect.size.x - tw) / 2.0,
y + 14.0,
history_top + 14.0,
12.0,
t.muted_foreground,
);
} else {
cx.backend.save();
cx.backend.clip_rect(rect);
// TS `git-panel-history-list`: a timeline with a 1px rail down
// the dot column (`left-5`), a 7px milestone dot per row, the
// message (`text-[12px] text-foreground`) left, and the author
// (`font-mono text-[10px] text-muted-foreground/80`) right.
let rail_x = rect.origin.x + 20.0;
let msg_x = rect.origin.x + 40.0;
let rail_x = rect.origin.x + RAIL_X;
let msg_x = rect.origin.x + MSG_X;
let n = self.state.recent_commits.len().min(MAX_COMMITS);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(rail_x, y - 8.0),
origin: Point2D::new(rail_x, history_top - 8.0),
size: Point2D::new(1.0, n as f32 * ROW_H + self.expanded_card_extra()),
},
alpha(t.border, 0.60),
);
// Row rects are the SAME geometry `ready_hit` walks — sharing
// them here means the hover wash (added below) can never land
// on a different rect than the click target, and the dot /
// text baseline derive from the row's own vertical center
// instead of a hand-tuned offset from the paint cursor.
let row_rects = self.ready_commit_row_rects(rect);
for (i, commit) in self
.state
.recent_commits
@ -558,14 +516,18 @@ impl GitPanel<'_> {
.take(MAX_COMMITS)
.enumerate()
{
let row_rect = row_rects[i];
self.wash_if_hovered(cx, row_rect, 4.0, GitPanelHit::ShowCommitDiff(i));
let dot_cy = row_rect.origin.y + row_rect.size.y / 2.0;
cx.backend.fill_round_rect(
Rect {
origin: Point2D::new(rail_x - 3.5, y - 7.5),
origin: Point2D::new(rail_x - 3.5, dot_cy - 3.5),
size: Point2D::new(7.0, 7.0),
},
3.5,
t.foreground,
);
let text_y = jian_widgets::centered_text_baseline_y(row_rect, 12.0);
// Right meta — `<author-first-token> · <relative-time>`
// (TS `{authorShort} · {timeAgo}`).
let meta = format!(
@ -582,7 +544,7 @@ impl GitPanel<'_> {
cx,
&truncate(&commit.summary, chars.min(SUMMARY_MAX)),
msg_x,
y,
text_y,
12.0,
t.foreground,
);
@ -590,210 +552,21 @@ impl GitPanel<'_> {
cx,
&meta,
author_x,
y,
text_y,
10.0,
alpha(t.muted_foreground, 0.80),
);
y += ROW_H;
// Inline detail card under the expanded row (TS
// `HistoryMilestoneRow` detail block). Captures the card
// top right after the row advance so the hit-test helper
// `ready_commit_card_buttons` lands on the same geometry.
// `HistoryMilestoneRow` detail block).
if self.state.expanded_commit == Some(i) {
let card_top = y - 6.0;
let card_top = row_rect.origin.y + row_rect.size.y + 11.0;
self.paint_commit_card(cx, rect, card_top);
y += self.expanded_card_height();
}
}
cx.backend.restore();
}
}
/// Paint the inline commit-detail card (里程碑详情) — a muted band with
/// the detail title, the semantic diff (TS `GitPanelHistoryDiff`: a
/// summary row + an `op nodeId` patch list, or a loading / initial /
/// no-changes / error line), and a `恢复` / `复制哈希` button row.
fn paint_commit_card(&self, cx: &mut PaintCx<'_>, rect: Rect, card_top: f32) {
let t = self.theme;
let h = self.expanded_card_height();
cx.backend.fill_rect(
Rect {
origin: Point2D::new(rect.origin.x, card_top),
size: Point2D::new(rect.size.x, h),
},
alpha(t.muted, 0.30),
);
let body_x = rect.origin.x + 40.0;
// Title — `text-[11px] font-medium`.
self.text(
cx,
self.t("git.history.milestoneDetailTitle"),
body_x,
card_top + 18.0,
11.0,
t.foreground,
);
// Diff body (TS `GitPanelHistoryDiff` states).
let status_y = card_top + 38.0;
match &self.state.expanded_commit_diff {
None | Some(CommitDiffView::Loading) => {
self.text(
cx,
self.t("git.history.diff.loading"),
body_x,
status_y,
10.0,
alpha(t.muted_foreground, 0.85),
);
}
Some(CommitDiffView::Initial) => {
self.text(
cx,
self.t("git.history.diff.initialCommit"),
body_x,
status_y,
10.0,
alpha(t.muted_foreground, 0.85),
);
}
Some(CommitDiffView::NoChanges) => {
self.text(
cx,
self.t("git.history.diff.noChanges"),
body_x,
status_y,
10.0,
alpha(t.muted_foreground, 0.85),
);
}
Some(CommitDiffView::Error(msg)) => {
let label = self.t("git.history.diff.error").replace("{{message}}", msg);
self.text(cx, &label, body_x, status_y, 10.0, t.destructive);
}
Some(CommitDiffView::Ready(summary)) => {
self.paint_diff_summary(cx, summary, body_x, status_y);
// Patch list — one `op nodeId` line each (TS font-mono).
for (k, p) in summary.patches.iter().take(MAX_PATCH_ROWS).enumerate() {
let py = card_top + 54.0 + k as f32 * PATCH_ROW_H;
self.text(cx, &p.op, body_x, py, 10.0, t.foreground);
let opw = cx.backend.measure_text(&p.op, 10.0);
self.text(
cx,
&p.node_id,
body_x + opw + 6.0,
py,
10.0,
alpha(t.muted_foreground, 0.70),
);
}
}
}
let (restore, copy) = self.commit_card_button_rects(rect, card_top);
if let Some(e) = self.state.expanded_commit {
self.wash_if_hovered(cx, restore, 6.0, GitPanelHit::RestoreCommit(e));
self.wash_if_hovered(cx, copy, 6.0, GitPanelHit::CopyCommitHash(e));
}
// 恢复 — outline button.
cx.backend.stroke_round_rect(restore, 6.0, t.border, 1.0);
self.center_label(
cx,
self.t("git.history.restoreButton"),
restore,
t.foreground,
);
// 复制哈希 — ghost button (no border).
self.center_label(
cx,
self.t("git.history.copyHashButton"),
copy,
alpha(t.foreground, 0.80),
);
}
/// Draw an 11px label horizontally + vertically centred in `r`.
fn center_label(&self, cx: &mut PaintCx<'_>, label: &str, r: Rect, color: Color) {
let tw = cx.backend.measure_text(label, 11.0);
self.text(
cx,
label,
r.origin.x + (r.size.x - tw) / 2.0,
r.origin.y + r.size.y / 2.0 + 4.0,
11.0,
color,
);
}
/// Paint the diff summary row — coloured `framesChanged` / `+added` /
/// `-removed` / `~modified` segments left-to-right (TS `GitPanelHistoryDiff`
/// summary spans). Only non-zero counts render.
fn paint_diff_summary(&self, cx: &mut PaintCx<'_>, s: &CommitDiffSummary, x: f32, y: f32) {
let t = self.theme;
// Build the (label, colour) segments first so the draw loop borrows
// `self` only through `self.text` / the backend measure.
let mut segments: Vec<(String, Color)> = Vec::new();
if s.frames_changed > 0 {
segments.push((
self.plural(
"git.history.diff.framesChanged_one",
"git.history.diff.framesChanged_other",
s.frames_changed,
),
t.muted_foreground,
));
}
if s.nodes_added > 0 {
segments.push((
format!(
"+{}",
self.plural(
"git.history.diff.nodesAdded_one",
"git.history.diff.nodesAdded_other",
s.nodes_added,
)
),
t.primary,
));
}
if s.nodes_removed > 0 {
segments.push((
format!(
"-{}",
self.plural(
"git.history.diff.nodesRemoved_one",
"git.history.diff.nodesRemoved_other",
s.nodes_removed,
)
),
t.destructive,
));
}
if s.nodes_modified > 0 {
segments.push((
format!(
"~{}",
self.plural(
"git.history.diff.nodesModified_one",
"git.history.diff.nodesModified_other",
s.nodes_modified,
)
),
t.muted_foreground,
));
}
let mut cur = x;
for (label, color) in &segments {
self.text(cx, label, cur, y, 10.0, *color);
cur += cx.backend.measure_text(label, 10.0) + 10.0;
}
}
/// Pick the `_one` / `_other` plural form (TS i18next English rule: 1 →
/// one) and substitute `{{count}}`. Both keys are `&'static` so they
/// satisfy [`GitPanel::t`]'s static-key contract.
fn plural(&self, one_key: &'static str, other_key: &'static str, count: u32) -> String {
let key = if count == 1 { one_key } else { other_key };
self.t(key).replace("{{count}}", &count.to_string())
}
/// One ghost icon button — a faint rounded slot + a centred glyph,
/// dimmed when disabled.
fn paint_ready_icon(

View file

@ -4,9 +4,10 @@
use crate::widgets::git_panel::*;
use crate::{Color, Point2D, Rect, RenderBackend, TextLayout};
use op_editor_core::{
ButtonPressTarget, CloneField, CloneFormState, EditorState, GitBranchPickerMode,
GitCandidateFile, GitCommitSummary, GitDiffView, GitFileEntry, GitOverflowView, GitPanelState,
MergeConflictRow, MergeResolveFile, MergeResolveState,
ButtonPressTarget, CloneField, CloneFormState, CommitDiffPatch, CommitDiffSummary,
CommitDiffView, EditorState, GitBranchPickerMode, GitCandidateFile, GitCommitSummary,
GitDiffView, GitFileEntry, GitOverflowView, GitPanelState, MergeConflictRow, MergeResolveFile,
MergeResolveState,
};
#[derive(Default)]
@ -1082,3 +1083,150 @@ fn dirty_bound_repo_still_shows_the_ready_view() {
"a dirty bound repo must still show the ready view",
);
}
// --- Ready-view polish (milestone detail card, buttons, timeline, height) --
fn one_commit() -> GitCommitSummary {
GitCommitSummary {
short_hash: "abc1234".into(),
summary: "first".into(),
author: "Ada".into(),
time_label: "now".into(),
is_initial: false,
}
}
#[test]
fn expanded_card_buttons_clear_the_timeline_rail_column() {
// The inline detail card insets past the timeline rail/dot column
// (20px from the panel edge) so the vertical connector line stays
// visible underneath an expanded card instead of a full-width card
// background painting over (and visually severing) it.
let s = state_with(GitPanelState {
recent_commits: vec![one_commit()],
expanded_commit: Some(0),
..open_repo()
});
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let (restore, copy) = panel.ready_commit_card_buttons(rect).unwrap();
assert!(
restore.origin.x > rect.origin.x + 28.0,
"the card's Restore button must sit clear of the rail + dot column"
);
assert!(
copy.origin.x > restore.origin.x,
"Copy hash sits after Restore"
);
}
#[test]
fn expanded_card_restore_and_copy_share_the_same_button_style() {
// Restore used to paint a bordered outline while Copy hash painted
// bare text with no chrome at all — two different-looking
// affordances for two sibling actions. Both must now paint the
// SAME secondary-button fill (unified via `paint_button_with_hit`).
let s = state_with(GitPanelState {
recent_commits: vec![one_commit()],
expanded_commit: Some(0),
..open_repo()
});
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let (restore, copy) = panel.ready_commit_card_buttons(rect).unwrap();
let mut backend = RoundFillBackend::default();
let mut cx = crate::widgets::PaintCx {
backend: &mut backend,
};
panel.paint(&mut cx, rect);
let paints_button_chrome = |target: Rect| {
backend
.fills
.iter()
.any(|(r, radius, _)| *r == target && (*radius - 6.0).abs() < 0.01)
};
assert!(
paints_button_chrome(restore),
"Restore should paint the shared button fill"
);
assert!(
paints_button_chrome(copy),
"Copy hash should paint the SAME shared button fill it used to skip entirely"
);
}
#[test]
fn ready_height_fits_content_instead_of_padding_with_fixed_filler() {
// A short history used to always add 200px of flat filler space
// below it regardless of content ("大片空白" under a one-line
// history). The panel must now size close to its actual content.
let s = state_with(open_repo());
let h = GitPanel::for_editor(&s).unwrap().height();
assert!(
h < 300.0,
"a short history must not pad the panel with fixed filler space (got {h})"
);
}
#[test]
fn ready_height_clamps_at_the_max_instead_of_growing_unbounded() {
// A full 8-row history plus a card expanded with a full patch list
// is the worst case for content height — it must clamp rather than
// grow the floating popover without bound.
let commits: Vec<_> = (0..8)
.map(|i| GitCommitSummary {
short_hash: format!("hash{i}"),
summary: format!("commit {i}"),
author: "Ada".into(),
time_label: "now".into(),
is_initial: false,
})
.collect();
let s = state_with(GitPanelState {
recent_commits: commits,
expanded_commit: Some(0),
expanded_commit_diff: Some(CommitDiffView::Ready(CommitDiffSummary {
frames_changed: 1,
nodes_added: 2,
nodes_removed: 0,
nodes_modified: 1,
patches: (0..6)
.map(|i| CommitDiffPatch {
op: "update".into(),
node_id: format!("n{i}"),
})
.collect(),
})),
..open_repo()
});
let h = GitPanel::for_editor(&s).unwrap().height();
assert!(
h <= 520.0 + 0.01,
"the ready view must clamp at READY_MAX_HEIGHT (got {h})"
);
}
#[test]
fn commit_rows_paint_a_hover_wash() {
// Hovering a commit row used to show no feedback at all even
// though clicking it toggles the detail card — the row needs the
// same shared hover wash every other clickable row in the panel
// paints.
let mut s = state_with(GitPanelState {
recent_commits: vec![one_commit()],
..open_repo()
});
s.editor_ui.git_panel.button_hover = Some(op_editor_core::GitButton::ShowCommitDiff(0));
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let row = panel.ready_commit_row_rects(rect)[0];
let mut backend = RoundFillBackend::default();
let mut cx = crate::widgets::PaintCx {
backend: &mut backend,
};
panel.paint(&mut cx, rect);
assert!(
backend.fills.iter().any(|(r, _, _)| *r == row),
"a hovered commit row should paint the shared hover wash"
);
}

View file

@ -244,6 +244,7 @@ pub mod file_drop_overlay;
pub mod file_menu;
pub mod git_panel;
mod git_panel_clone;
mod git_panel_commit_card;
mod git_panel_diff;
mod git_panel_empty;
mod git_panel_hit;