feat(panels): add git-panel button to the top bar

The top bar had no affordance to open the git panel (only a
keyboard/menu path). Add a TS-style git button just right of the file
name: a `GitBranch` glyph + the current branch name when in a repo.
Always shown (per request) — a click toggles the git panel, which
offers `init` when the doc isn't yet a repo.

- new `Icon::GitBranch` lucide glyph;
- `TopBar.git_branch` from `git_panel.branch`; `git_button_rect`
  (CJK-aware width estimate so it clears a CJK file name) shared by
  paint + hit-test; `TopBarHit::ToggleGitPanel`;
- native host mirrors `main.rs` toggle bookkeeping (per-frame refresh
  does the scan); web host toggles `git_panel.open`.
This commit is contained in:
Kayshen-X 2026-05-29 22:12:38 +08:00
parent e2f0e4e1a0
commit a4b70fa055
6 changed files with 110 additions and 0 deletions

View file

@ -74,6 +74,8 @@ pub enum Icon {
PanelLeft, PanelLeft,
/// FolderOpen — TopBar folder. /// FolderOpen — TopBar folder.
FolderOpen, FolderOpen,
/// GitBranch — TopBar git-panel toggle next to the file name.
GitBranch,
/// Sparkles — agent active indicator. /// Sparkles — agent active indicator.
Sparkles, Sparkles,
/// X — close affordance. /// X — close affordance.
@ -257,6 +259,7 @@ impl Icon {
Icon::Hash => HASH, Icon::Hash => HASH,
Icon::PanelLeft => PANEL_LEFT, Icon::PanelLeft => PANEL_LEFT,
Icon::FolderOpen => FOLDER_OPEN, Icon::FolderOpen => FOLDER_OPEN,
Icon::GitBranch => GIT_BRANCH,
Icon::Sparkles => SPARKLES, Icon::Sparkles => SPARKLES,
Icon::Close => CLOSE, Icon::Close => CLOSE,
Icon::Trash => TRASH, Icon::Trash => TRASH,
@ -402,6 +405,7 @@ impl Icon {
"download" => Icon::Download, "download" => Icon::Download,
"file-text" => Icon::FileText, "file-text" => Icon::FileText,
"folder-open" | "folder" => Icon::FolderOpen, "folder-open" | "folder" => Icon::FolderOpen,
"git-branch" | "git" => Icon::GitBranch,
"sparkles" => Icon::Sparkles, "sparkles" => Icon::Sparkles,
"diamond" => Icon::Diamond, "diamond" => Icon::Diamond,
"component" => Icon::Component, "component" => Icon::Component,

View file

@ -122,6 +122,14 @@ pub(super) const FOLDER_OPEN: &[&str] = &[
"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2", "m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2",
]; ];
pub(super) const GIT_BRANCH: &[&str] = &[
// lucide git-branch: line + two r=3 circles + connecting arc.
"M6 3v12",
"M18 6 m-3 0 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0",
"M6 18 m-3 0 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0",
"M18 9a9 9 0 0 1-9 9",
];
pub(super) const SPARKLES: &[&str] = &[ pub(super) const SPARKLES: &[&str] = &[
"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z", "M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z",
"M20 2v4", "M20 2v4",

View file

@ -85,6 +85,7 @@ fn every_variant_paints_at_least_one_primitive() {
Icon::Hash, Icon::Hash,
Icon::PanelLeft, Icon::PanelLeft,
Icon::FolderOpen, Icon::FolderOpen,
Icon::GitBranch,
Icon::Sparkles, Icon::Sparkles,
Icon::Close, Icon::Close,
Icon::ChevronUp, Icon::ChevronUp,

View file

@ -79,6 +79,8 @@ pub enum TopBarHit {
ToggleLocale, ToggleLocale,
/// Agents and MCP chip — open the agent settings modal. /// Agents and MCP chip — open the agent settings modal.
OpenAgentSettings, OpenAgentSettings,
/// Git-branch button next to the file name — toggle the git panel.
ToggleGitPanel,
} }
pub struct TopBar { pub struct TopBar {
@ -108,6 +110,10 @@ pub struct TopBar {
/// Dark mode (click to go light), a Moon glyph in Light mode /// Dark mode (click to go light), a Moon glyph in Light mode
/// (click to go dark). /// (click to go dark).
pub theme_mode: op_editor_core::ThemeMode, pub theme_mode: op_editor_core::ThemeMode,
/// Current git branch when the open document is in a repo — shown
/// beside the file name. `None` = no branch (the button still
/// paints, icon-only, as a toggle for the git panel).
pub git_branch: Option<String>,
} }
impl TopBar { impl TopBar {
@ -125,6 +131,7 @@ impl TopBar {
traffic_hover: false, traffic_hover: false,
fullscreen: false, fullscreen: false,
theme_mode: op_editor_core::ThemeMode::Dark, theme_mode: op_editor_core::ThemeMode::Dark,
git_branch: None,
} }
} }
@ -161,6 +168,7 @@ impl TopBar {
traffic_hover: ui.topbar_traffic_hover, traffic_hover: ui.topbar_traffic_hover,
fullscreen: ui.window_fullscreen, fullscreen: ui.window_fullscreen,
theme_mode: ui.theme_mode, theme_mode: ui.theme_mode,
git_branch: ui.git_panel.branch.clone(),
} }
} }
@ -263,6 +271,34 @@ impl TopBar {
} }
} }
/// Git-panel toggle button — sits just right of the centred file
/// name. Width holds the branch glyph plus an optional branch
/// label. Shared by paint + hit-test so they can't drift.
fn git_button_rect(&self, top_bar_rect: Rect) -> Rect {
let center_y = top_bar_rect.origin.y + top_bar_rect.size.y / 2.0;
// The name is *centred* using the 9 px/char heuristic, but a
// CJK glyph renders ~14 px wide, so the real right edge is
// further out — use a CJK-aware estimate so the button clears
// the (often CJK) file name instead of overlapping it.
let center_approx = self.file_name.chars().count() as f32 * 9.0;
let render_w: f32 = self
.file_name
.chars()
.map(|c| if is_wide_glyph(c) { 14.0 } else { 7.5 })
.sum();
let filename_left = top_bar_rect.origin.x + (top_bar_rect.size.x - center_approx) / 2.0;
let filename_right = filename_left + render_w;
let branch_w = self
.git_branch
.as_deref()
.map(|b| 6.0 + b.chars().count() as f32 * 7.0)
.unwrap_or(0.0);
Rect {
origin: Point2D::new(filename_right + 10.0, center_y - ICON_BUTTON / 2.0),
size: Point2D::new(ICON_SIZE + 8.0 + branch_w, ICON_BUTTON),
}
}
/// Resolve a press on the left-edge window-control dots. /// Resolve a press on the left-edge window-control dots.
/// `None` for a press anywhere else (including the app's own /// `None` for a press anywhere else (including the app's own
/// buttons). The desktop runner consults this before its normal /// buttons). The desktop runner consults this before its normal
@ -334,6 +370,10 @@ impl TopBar {
if rect_contains(figma_rect, point) { if rect_contains(figma_rect, point) {
return Some(TopBarHit::OpenFigmaImport); return Some(TopBarHit::OpenFigmaImport);
} }
// Git-panel toggle, just right of the centred file name.
if rect_contains(self.git_button_rect(rect), point) {
return Some(TopBarHit::ToggleGitPanel);
}
// Right cluster: Maximize / Sun / Globe-with-chevron (right→left). // Right cluster: Maximize / Sun / Globe-with-chevron (right→left).
// Maximize + Sun are normal ICON_BUTTON wide; Globe is // Maximize + Sun are normal ICON_BUTTON wide; Globe is
// GLOBE_BUTTON_WIDTH wide because it carries a chevron. // GLOBE_BUTTON_WIDTH wide because it carries a chevron.
@ -546,6 +586,33 @@ impl Widget for TopBar {
), ),
); );
// Git-panel button just right of the file name (TS GitButton):
// a branch glyph + optional branch name. Always shown — a
// click toggles the git panel (which offers `init` when the
// doc isn't yet in a repo).
let git_rect = self.git_button_rect(rect);
draw_icon(
cx.backend,
Icon::GitBranch,
Point2D::new(git_rect.origin.x, glyph_top(center_y, ICON_SIZE)),
ICON_SIZE,
self.theme.muted_foreground,
1.4,
);
if let Some(branch) = self.git_branch.as_deref() {
let label = TextLayout::single_run(
branch,
"system-ui",
11.0,
to_jian_color(self.theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(git_rect.origin.x + ICON_SIZE + 6.0, center_y + 4.0),
);
}
// ── Right cluster ────────────────────────────────────── // ── Right cluster ──────────────────────────────────────
// Right → left: Maximize | Sun | Globe+Chevron. Globe is a // Right → left: Maximize | Sun | Globe+Chevron. Globe is a
// wider compound button (signals the dropdown affordance). // wider compound button (signals the dropdown affordance).
@ -741,6 +808,14 @@ fn paint_figma_button(cx: &mut PaintCx<'_>, theme: &Theme, x: f32, center_y: f32
); );
} }
/// Rough "is this a full-width (CJK/Hangul/full-width-form) glyph"
/// test — used only to estimate the rendered file-name width so the
/// git button clears it.
fn is_wide_glyph(c: char) -> bool {
let cp = c as u32;
matches!(cp, 0x1100..=0x11FF | 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF)
}
/// Top-left y for a glyph of `size` vertically centred on `center_y`. /// Top-left y for a glyph of `size` vertically centred on `center_y`.
/// Every top-bar glyph routes through this so the whole bar shares /// Every top-bar glyph routes through this so the whole bar shares
/// one center line. /// one center line.

View file

@ -332,6 +332,25 @@ impl WidgetHostNative {
self.mark_dirty(); self.mark_dirty();
return true; return true;
} }
TopBarHit::ToggleGitPanel => {
// Mirror `main.rs` A::ToggleGitPanel bookkeeping; the
// binary's per-frame `if git_panel.open { refresh }`
// performs the actual repo scan.
let panel = &mut self.editor_state.editor_ui.git_panel;
let opening = !panel.open;
panel.open = opening;
if opening {
panel.loading = true;
} else {
panel.commit_focused = false;
panel.remote_focused = false;
panel.https_focused = false;
panel.diff = None;
panel.merge_resolve = None;
}
self.mark_dirty();
return true;
}
} }
} }
if rect_contains(top_bar_rect, Point2D::new(x, y)) { if rect_contains(top_bar_rect, Point2D::new(x, y)) {

View file

@ -261,6 +261,9 @@ impl WidgetHost {
TopBarHit::OpenFigmaImport => { TopBarHit::OpenFigmaImport => {
self.editor_state.editor_ui.figma_import_open = true; self.editor_state.editor_ui.figma_import_open = true;
} }
TopBarHit::ToggleGitPanel => {
self.editor_state.editor_ui.git_panel.open ^= true;
}
} }
self.mark_dirty(); self.mark_dirty();
return true; return true;