feat(desktop): in-app Git panel with branch, diff and merge UI

The floating Git panel — opened from the View menu — shows branch,
working-tree status and recent commits, and offers commit / refresh /
pull plus one-click branch switching. Clicking the status line, a
commit row or a conflicted file opens an in-panel scrollable
unified-diff viewer (the panel widens to 620 px with ▲/▼/✕ controls
and per-line colouring). Each non-current branch row carries a "⤵"
button that requests an isolated worktree merge.

GitPanelState / GitDiffView / GitPanelAction are plain data on
op-editor-core so the widget layer stays wasm-clean — it never calls
git itself. Diff rendering is split into git_panel_diff.rs and the
native press dispatch into git_press.rs to honour the 800-line cap;
the panel is hit-tested before the right-rail blocks so its wide
diff mode cannot lose clicks to the property rail underneath.
This commit is contained in:
Kayshen-X 2026-05-18 21:45:21 +08:00
parent 0730230ddb
commit b8f030b3bb
13 changed files with 1575 additions and 14 deletions

View file

@ -180,6 +180,113 @@ pub enum UpdateStatus {
Error,
}
/// One commit row shown in the Git panel — plain data snapshotted
/// by the desktop host from its git session. The platform-free
/// widget layer only paints it; it never calls git itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommitSummary {
/// Abbreviated commit hash.
pub short_hash: String,
/// First line of the commit message.
pub summary: String,
/// Author display name.
pub author: String,
}
/// What a Git-panel diff request should diff.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitDiffTarget {
/// The whole working tree's unstaged changes (`git diff`).
WorkingTree,
/// One repo-relative path's working-tree changes (`git diff -- <path>`).
Path(String),
/// The full patch a commit introduced (`git show <rev>`).
Commit(String),
}
/// A unified-diff view open inside the Git panel — filled by the
/// desktop host from a background `git diff` / `git show` job.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitDiffView {
/// Human label for the diff (a path, or a commit summary).
pub title: String,
/// The diff text split into lines for per-line colouring.
pub lines: Vec<String>,
/// Index of the first visible line — paged by the ▲ / ▼ buttons.
pub scroll: usize,
}
/// An interactive action requested from the Git panel. The desktop
/// host drains it from [`GitPanelState::pending_action`] and runs it
/// against its `GitSession` (the widget layer never calls git).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitPanelAction {
/// Re-read repository state into the panel.
Refresh,
/// Pull the current branch's upstream.
Pull,
/// Stage + commit the tracked document with the panel's
/// `commit_message`.
Commit,
/// Switch the working tree to the named branch.
SwitchBranch(String),
/// Merge the named branch into the current one through an
/// isolated worktree (the live tree is never marked up).
MergeBranch(String),
/// Abort an in-progress merge, restoring the pre-merge state.
AbortMerge,
/// Finalize an in-progress merge once its conflicts are resolved.
CompleteMerge,
/// Compute a unified diff and open it in the panel's diff view.
ShowDiff(GitDiffTarget),
}
/// Git panel state — a plain-data snapshot the desktop host fills
/// from its `GitSession`. The widget layer reads it to paint the
/// floating Git panel; it carries no git handles, so it stays
/// wasm-clean. Refreshed whenever the panel is opened.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitPanelState {
/// Whether the floating Git panel is currently shown.
pub open: bool,
/// Whether the open document lives inside a git repository.
pub in_repo: bool,
/// Current branch name of that repository.
pub branch: Option<String>,
/// All local branch names, sorted — the panel lists them for
/// one-click switching.
pub branches: Vec<String>,
/// Number of changed (dirty) files in the working tree.
pub dirty_count: usize,
/// Number of files with unresolved merge conflicts.
pub conflicted_count: usize,
/// Whether a merge is in progress — drives the panel's conflict
/// mode (conflicted-file list + Abort / Complete actions).
pub merging: bool,
/// Repo-relative paths with unresolved merge conflicts.
pub conflicted_files: Vec<String>,
/// Most-recent commits, newest first.
pub recent_commits: Vec<GitCommitSummary>,
/// Commit-message draft typed into the panel's input box.
pub commit_message: String,
/// Whether the commit-message input holds keyboard focus.
pub commit_focused: bool,
/// Interactive action requested by a panel click / Enter —
/// drained and executed by the desktop host.
pub pending_action: Option<GitPanelAction>,
/// Whether a background `git pull` is currently in flight — the
/// panel shows a "Pulling…" status and disables the Pull button.
pub pulling: bool,
/// Whether the panel is awaiting its first repository snapshot
/// after opening / a repo switch. While `true` the panel shows a
/// "Loading…" state instead of the (possibly stale) prior data.
pub loading: bool,
/// Open diff view — `Some` puts the panel into diff mode, showing
/// a scrollable unified diff instead of the status / action area.
/// Closed by the diff view's ✕ button.
pub diff: Option<GitDiffView>,
}
/// File-menu "Recent files" entry — host persists via settings IO.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentFile {
@ -352,6 +459,11 @@ pub struct EditorUiState {
/// Latest result of the desktop host's background update probe.
/// Transient: never serialized, rebuilt each launch.
pub update_status: UpdateStatus,
// --- In-app Git -------------------------------------------------
/// Floating Git panel snapshot — filled by the desktop host from
/// its `GitSession`. Transient: never serialized.
pub git_panel: GitPanelState,
}
impl Default for EditorUiState {
@ -402,6 +514,7 @@ impl Default for EditorUiState {
last_canvas_click: None,
active_guides: Vec::new(),
update_status: UpdateStatus::Idle,
git_panel: GitPanelState::default(),
}
}
}

View file

@ -70,8 +70,10 @@ pub use command::{BatchInsertItem, EditorCommand, EffectField, NodeFlag, Variabl
pub use components::{Component, ComponentLibrary};
pub use editor_ui_state::{
BooleanOp, EditorUiState, ExportFormat, FileAction, FileMenuChoice, FillType, FlexLayout,
LayerContextMenuState, Locale, PageRenameState, PropertyTab, RecentFile, ShapeChoice,
ThemeMode, UpdateStatus, VariableRowFocus,
GitCommitSummary, GitDiffTarget, GitDiffView, GitPanelAction, GitPanelState,
LayerContextMenuState, Locale,
PageRenameState, PropertyTab, RecentFile, ShapeChoice, ThemeMode, UpdateStatus,
VariableRowFocus,
};
pub use fills::{first_fill_type, first_solid_fill_hex, first_solid_stroke_hex, node_effects};
pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect};

View file

@ -0,0 +1,703 @@
//! `GitPanel` — the floating in-app Git panel.
//!
//! Shows the open document's git repository — current branch,
//! working-tree change counts, recent commits — and offers the
//! interactive actions: a commit-message input + Commit / Refresh /
//! Pull buttons. Clicking the status line or a commit / conflict row
//! opens an in-panel scrollable unified-diff view.
//!
//! The panel is platform-free: it is filled by the desktop host
//! from its `GitSession` and never calls git itself. A click is
//! mapped to a [`GitPanelHit`] by [`GitPanel::hit_test`]; the host
//! turns that into focus changes / a `GitPanelState::pending_action`.
use crate::theme::Theme;
use crate::widgets::editor_state_ext::theme_for;
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout};
use op_editor_core::{EditorState, GitPanelState};
/// Panel width in logical px.
pub const GIT_PANEL_WIDTH: f32 = 320.0;
/// Panel width while a diff is open — wide enough for ~95-column diffs.
pub const GIT_DIFF_PANEL_WIDTH: f32 = 620.0;
/// Inset from the canvas corner the panel floats at.
pub const GIT_PANEL_INSET: f32 = 16.0;
pub(super) const PAD: f32 = 16.0;
// Element positions as fixed offsets from the panel's top edge.
// Keeping them constant (the action area never shifts with the
// commit count) lets paint + hit-test share the exact same maths.
pub(super) const HEADER_BASELINE: f32 = 30.0;
const BRANCH_BASELINE: f32 = 56.0;
const STATUS_BASELINE: f32 = 78.0;
const DIVIDER_1_Y: f32 = 90.0;
const INPUT_TOP: f32 = 100.0;
const INPUT_H: f32 = 28.0;
const BUTTON_TOP: f32 = 138.0;
const BUTTON_H: f32 = 28.0;
const DIVIDER_2_Y: f32 = 180.0;
const COMMITS_LABEL_BASELINE: f32 = 200.0;
/// Baseline of the first commit row.
const COMMITS_FIRST_BASELINE: f32 = 222.0;
const COMMIT_ROW_H: f32 = 22.0;
const BRANCH_ROW_H: f32 = 22.0;
/// Gap from the "Branches" label baseline to the first branch row.
const BRANCH_LABEL_GAP: f32 = 10.0;
pub(super) const FOOTER_H: f32 = 22.0;
const BUTTON_GAP: f32 = 8.0;
/// Gap between the commit list and the Branches section.
const SECTION_GAP: f32 = 16.0;
/// Most commits the panel shows.
const MAX_COMMITS: usize = 8;
/// Most branches the panel lists.
const MAX_BRANCHES: usize = 8;
/// Commit-summary truncation length (chars).
const SUMMARY_MAX: usize = 38;
/// Fixed panel height while a diff view is open. The remaining
/// diff-view metrics + rendering live in the `git_panel_diff`
/// sibling module (split out for the 800-line file cap).
pub(super) const DIFF_VIEW_HEIGHT: f32 = 484.0;
/// What a click landed on inside the Git panel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitPanelHit {
/// The commit-message input box — focus it.
CommitInput,
/// The Commit button.
Commit,
/// The Refresh button.
Refresh,
/// The Pull button.
Pull,
/// The "Abort Merge" button (shown while a merge is in progress).
AbortMerge,
/// The "Complete Merge" button (shown while a merge is in
/// progress; only actionable once conflicts are resolved).
CompleteMerge,
/// A branch row — switch to `branches[index]`.
SwitchBranch(usize),
/// A branch row's merge button — merge `branches[index]` into
/// the current branch.
MergeBranch(usize),
/// The working-tree status line — open the whole-repo diff.
ShowWorkingDiff,
/// A recent-commit row — open that commit's patch.
ShowCommitDiff(usize),
/// A conflicted-file row (merge mode) — open that file's diff.
ShowFileDiff(usize),
/// The diff view's ✕ — close the diff, returning to status mode.
CloseDiff,
/// The diff view's ▲ — page the diff up.
DiffScrollUp,
/// The diff view's ▼ — page the diff down.
DiffScrollDown,
/// Inside the panel but not on an interactive target — the
/// click is swallowed (and the commit input defocused).
Inside,
}
/// Sub-rectangles of the panel's interactive action area.
pub(super) struct ActionRects {
pub(super) input: Rect,
pub(super) commit: Rect,
pub(super) refresh: Rect,
pub(super) pull: Rect,
}
/// The floating Git panel, built from a [`GitPanelState`] snapshot.
pub struct GitPanel<'a> {
pub(super) state: &'a GitPanelState,
pub(super) theme: Theme,
}
impl<'a> GitPanel<'a> {
/// Build the panel for the editor, or `None` when it is closed.
pub fn for_editor(state: &'a EditorState) -> Option<GitPanel<'a>> {
let panel = &state.editor_ui.git_panel;
if !panel.open {
return None;
}
Some(GitPanel {
state: panel,
theme: theme_for(&state.editor_ui),
})
}
/// Panel width for the current mode — wider while a diff is open.
pub fn panel_width(&self) -> f32 {
if self.state.diff.is_some() {
GIT_DIFF_PANEL_WIDTH
} else {
GIT_PANEL_WIDTH
}
}
/// Row count of the list slot — conflicted files while a merge
/// is in progress, recent commits otherwise. At least one (the
/// placeholder row), capped at `MAX_COMMITS`.
fn list_rows(&self) -> usize {
let len = if self.state.merging {
self.state.conflicted_files.len()
} else {
self.state.recent_commits.len()
};
len.clamp(1, MAX_COMMITS)
}
/// The panel's total height for the current content.
pub fn height(&self) -> f32 {
// Diff mode is a fixed-height scrollable view.
if self.state.diff.is_some() {
return DIFF_VIEW_HEIGHT;
}
if self.state.loading || !self.state.in_repo {
// Header + one status line ("Loading…" / "not a repo")
// + footer — no branch / action area / commit list.
return HEADER_BASELINE + 24.0 + FOOTER_H + PAD;
}
// At least one commit row — either the commits or the single
// "No commits yet." placeholder line — capped at `MAX_COMMITS`.
let commit_rows = self.list_rows();
let branch_count = self.state.branches.len().min(MAX_BRANCHES);
// The Branches section is omitted entirely when empty.
let branches_h = if branch_count == 0 {
0.0
} else {
SECTION_GAP + BRANCH_LABEL_GAP + branch_count as f32 * BRANCH_ROW_H
};
COMMITS_FIRST_BASELINE
+ commit_rows as f32 * COMMIT_ROW_H
+ branches_h
+ FOOTER_H
+ PAD
}
/// The Branches section layout — the "Branches" label baseline
/// and one clickable rect per listed branch. The list is empty
/// when the repository has no branches yet. Shared by
/// [`GitPanel::paint`] + [`GitPanel::hit_test`].
pub(super) fn branch_layout(&self, panel: Rect) -> (f32, Vec<Rect>) {
let commit_rows = self.list_rows();
let label_baseline = panel.origin.y
+ COMMITS_FIRST_BASELINE
+ commit_rows as f32 * COMMIT_ROW_H
+ SECTION_GAP;
let left = panel.origin.x + PAD;
let inner_w = panel.size.x - PAD * 2.0;
let first_row_top = label_baseline + BRANCH_LABEL_GAP;
let rects = (0..self.state.branches.len().min(MAX_BRANCHES))
.map(|i| Rect {
origin: Point2D::new(left, first_row_top + i as f32 * BRANCH_ROW_H),
size: Point2D::new(inner_w, BRANCH_ROW_H),
})
.collect();
(label_baseline, rects)
}
/// The interactive action-area sub-rects, derived from the
/// panel rect. Shared by [`GitPanel::paint`] + [`GitPanel::hit_test`].
pub(super) fn action_rects(panel: Rect) -> ActionRects {
let left = panel.origin.x + PAD;
let inner_w = panel.size.x - PAD * 2.0;
let input = Rect {
origin: Point2D::new(left, panel.origin.y + INPUT_TOP),
size: Point2D::new(inner_w, INPUT_H),
};
let button_w = (inner_w - 2.0 * BUTTON_GAP) / 3.0;
let button_top = panel.origin.y + BUTTON_TOP;
let nth = |i: f32| Rect {
origin: Point2D::new(left + i * (button_w + BUTTON_GAP), button_top),
size: Point2D::new(button_w, BUTTON_H),
};
ActionRects {
input,
commit: nth(0.0),
refresh: nth(1.0),
pull: nth(2.0),
}
}
/// One clickable rect per displayed list row — recent commits, or
/// conflicted files in merge mode. Mirrors the [`GitPanel::paint`]
/// list walk so paint + hit-test agree.
pub(super) fn list_row_rects(&self, panel: Rect) -> Vec<Rect> {
let count = if self.state.merging {
self.state.conflicted_files.len()
} else {
self.state.recent_commits.len()
}
.min(MAX_COMMITS);
let left = panel.origin.x + PAD;
let inner_w = panel.size.x - PAD * 2.0;
let first = panel.origin.y + COMMITS_FIRST_BASELINE;
(0..count)
.map(|i| Rect {
origin: Point2D::new(left, first + i as f32 * COMMIT_ROW_H - 15.0),
size: Point2D::new(inner_w, COMMIT_ROW_H),
})
.collect()
}
/// The working-tree status-line rect — a diff trigger when the
/// tree has changes.
pub(super) fn status_rect(&self, panel: Rect) -> Rect {
Rect {
origin: Point2D::new(
panel.origin.x + PAD,
panel.origin.y + STATUS_BASELINE - 14.0,
),
size: Point2D::new(panel.size.x - PAD * 2.0, 18.0),
}
}
/// The "merge into current" button at the right edge of a
/// (non-current) branch row.
pub(super) fn branch_merge_button(row: Rect) -> Rect {
let size = BRANCH_ROW_H - 4.0;
Rect {
origin: Point2D::new(
row.origin.x + row.size.x - size - 4.0,
row.origin.y + 2.0,
),
size: Point2D::new(size, size),
}
}
/// Map a click at `point` onto a [`GitPanelHit`]. `None` when the
/// click is outside `panel_rect` entirely.
pub fn hit_test(&self, panel_rect: Rect, point: Point2D) -> Option<GitPanelHit> {
if !contains(panel_rect, point) {
return None;
}
// Diff mode — the only interactive targets are the header's
// ▲ / ▼ / ✕; the diff body itself swallows clicks.
if self.state.diff.is_some() {
let [up, down, close] = Self::diff_header_buttons(panel_rect);
return Some(if contains(up, point) {
GitPanelHit::DiffScrollUp
} else if contains(down, point) {
GitPanelHit::DiffScrollDown
} else if contains(close, point) {
GitPanelHit::CloseDiff
} else {
GitPanelHit::Inside
});
}
// While loading / outside a repo there are no action targets;
// an in-bounds click is just swallowed.
if self.state.loading || !self.state.in_repo {
return Some(GitPanelHit::Inside);
}
// During a merge the action area becomes the conflict
// controls — slot 0 = Abort, slot 1 = Refresh, slot 2 =
// Complete; the commit input is inert.
let rects = Self::action_rects(panel_rect);
let merging = self.state.merging;
if contains(rects.input, point) {
return Some(if merging {
GitPanelHit::Inside
} else {
GitPanelHit::CommitInput
});
}
if contains(rects.commit, point) {
return Some(if merging {
GitPanelHit::AbortMerge
} else {
GitPanelHit::Commit
});
}
if contains(rects.refresh, point) {
return Some(GitPanelHit::Refresh);
}
if contains(rects.pull, point) {
return Some(if merging {
// Complete is inert until every conflict is resolved
// — mirror the button's disabled paint state so a
// click on the disabled button dispatches nothing.
if self.state.conflicted_files.is_empty() {
GitPanelHit::CompleteMerge
} else {
GitPanelHit::Inside
}
} else {
GitPanelHit::Pull
});
}
// The status line opens the whole-tree diff — but only when
// the working tree actually has something to diff.
if contains(self.status_rect(panel_rect), point)
&& (self.state.dirty_count > 0 || self.state.conflicted_count > 0)
{
return Some(GitPanelHit::ShowWorkingDiff);
}
// List rows — a commit's patch (normal) or a conflicted
// file's diff (merge mode).
for (i, row) in self.list_row_rects(panel_rect).iter().enumerate() {
if contains(*row, point) {
return Some(if merging {
GitPanelHit::ShowFileDiff(i)
} else {
GitPanelHit::ShowCommitDiff(i)
});
}
}
// Branch rows — the row body switches to a non-current
// branch; its right-edge button merges that branch into the
// current one. The current branch's own row is a no-op.
let (_, branch_rects) = self.branch_layout(panel_rect);
for (index, row) in branch_rects.iter().enumerate() {
if contains(*row, point) {
let is_current = self.state.branches.get(index) == self.state.branch.as_ref();
if is_current {
return Some(GitPanelHit::Inside);
}
if contains(Self::branch_merge_button(*row), point) {
return Some(GitPanelHit::MergeBranch(index));
}
return Some(GitPanelHit::SwitchBranch(index));
}
}
Some(GitPanelHit::Inside)
}
/// Paint the panel into `rect`.
pub fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_round_rect(rect, 10.0, self.theme.popover);
cx.backend
.stroke_round_rect(rect, 10.0, self.theme.border, 1.0);
// Diff mode replaces the whole body with the scrollable view.
if let Some(view) = &self.state.diff {
self.paint_diff(cx, rect, view);
return;
}
let left = rect.origin.x + PAD;
let top = rect.origin.y;
self.text(cx, "Git", left, top + HEADER_BASELINE, 15.0, self.theme.foreground);
// Loading: the prior data is for a since-switched repository,
// so show a neutral "Loading…" rather than stale branch /
// commits until the new snapshot lands.
if self.state.loading {
self.text(
cx,
"Loading repository…",
left,
top + HEADER_BASELINE + 24.0,
12.0,
self.theme.muted_foreground,
);
self.footer(cx, left, top + self.height() - PAD);
return;
}
if !self.state.in_repo {
self.text(
cx,
"Not a git repository.",
left,
top + HEADER_BASELINE + 24.0,
12.0,
self.theme.muted_foreground,
);
self.footer(cx, left, top + self.height() - PAD);
return;
}
// Branch + working-tree status.
let branch = self.state.branch.as_deref().unwrap_or("(detached HEAD)");
self.text(
cx,
&format!("Branch: {branch}"),
left,
top + BRANCH_BASELINE,
13.0,
self.theme.foreground,
);
let (status_text, status_color) = self.status_line();
self.text(cx, &status_text, left, top + STATUS_BASELINE, 12.0, status_color);
self.divider(cx, left, top + DIVIDER_1_Y, rect.size.x);
// Action area. Normal mode: commit input + Commit / Refresh
// / Pull. Merge mode: a warning banner + Abort / Refresh /
// Complete (Complete only once conflicts are resolved).
let rects = Self::action_rects(rect);
if self.state.merging {
self.paint_merge_banner(cx, rects.input);
self.paint_button(cx, rects.commit, "Abort Merge", true, false);
self.paint_button(cx, rects.refresh, "Refresh", true, false);
let can_complete = self.state.conflicted_files.is_empty();
self.paint_button(cx, rects.pull, "Complete", can_complete, true);
} else {
self.paint_input(cx, rects.input);
let commit_enabled = !self.state.commit_message.trim().is_empty();
self.paint_button(cx, rects.commit, "Commit", commit_enabled, true);
self.paint_button(cx, rects.refresh, "Refresh", true, false);
// The Pull button is disabled while a pull already runs.
self.paint_button(cx, rects.pull, "Pull", !self.state.pulling, false);
}
self.divider(cx, left, top + DIVIDER_2_Y, rect.size.x);
// List section — conflicted files during a merge, recent
// commits otherwise.
let conflict_red = Color { r: 0.94, g: 0.27, b: 0.27, a: 1.0 };
let mut y = top + COMMITS_FIRST_BASELINE;
if self.state.merging {
self.text(
cx,
"Conflicts",
left,
top + COMMITS_LABEL_BASELINE,
12.0,
self.theme.muted_foreground,
);
if self.state.conflicted_files.is_empty() {
self.text(
cx,
"No conflicts — ready to complete.",
left,
y,
12.0,
self.theme.muted_foreground,
);
}
for path in self.state.conflicted_files.iter().take(MAX_COMMITS) {
self.text(
cx,
&format!("⚠ {}", truncate(path, SUMMARY_MAX)),
left,
y,
12.0,
conflict_red,
);
y += COMMIT_ROW_H;
}
} else {
self.text(
cx,
"Recent commits",
left,
top + COMMITS_LABEL_BASELINE,
12.0,
self.theme.muted_foreground,
);
if self.state.recent_commits.is_empty() {
self.text(cx, "No commits yet.", left, y, 12.0, self.theme.muted_foreground);
}
for commit in self.state.recent_commits.iter().take(MAX_COMMITS) {
let summary = truncate(&commit.summary, SUMMARY_MAX);
self.text(
cx,
&format!("{} {}", commit.short_hash, summary),
left,
y,
12.0,
self.theme.foreground,
);
y += COMMIT_ROW_H;
}
}
// Branches section — one row per local branch, the current
// one marked + faintly highlighted, the rest click-to-switch.
if !self.state.branches.is_empty() {
let (label_baseline, branch_rects) = self.branch_layout(rect);
self.text(
cx,
"Branches",
left,
label_baseline,
12.0,
self.theme.muted_foreground,
);
for (i, row) in branch_rects.iter().enumerate() {
let name = &self.state.branches[i];
let is_current = Some(name) == self.state.branch.as_ref();
if is_current {
cx.backend.fill_round_rect(*row, 4.0, self.theme.muted);
}
let (marker, color) = if is_current {
("● ", self.theme.primary)
} else {
(" ", self.theme.foreground)
};
let baseline = row.origin.y + BRANCH_ROW_H - 7.0;
self.text(
cx,
&format!("{marker}{name}"),
left + 4.0,
baseline,
12.0,
color,
);
// Non-current branches carry a "merge into current"
// button at the row's right edge.
if !is_current {
self.paint_glyph_button(cx, Self::branch_merge_button(*row), "⤵");
}
}
}
// Footer — always pinned a fixed inset above the panel foot.
self.footer(cx, left, top + self.height() - PAD);
}
/// The working-tree status line text + colour.
fn status_line(&self) -> (String, Color) {
if self.state.pulling {
return (
"Pulling…".to_string(),
Color { r: 0.23, g: 0.51, b: 0.96, a: 1.0 },
);
}
if self.state.conflicted_count > 0 {
(
format!(
"{} changed · {} conflicted",
self.state.dirty_count, self.state.conflicted_count
),
Color { r: 0.94, g: 0.27, b: 0.27, a: 1.0 },
)
} else if self.state.dirty_count > 0 {
(
format!("{} changed", self.state.dirty_count),
Color { r: 0.96, g: 0.62, b: 0.04, a: 1.0 },
)
} else {
(
"Working tree clean".to_string(),
Color { r: 0.22, g: 0.78, b: 0.42, a: 1.0 },
)
}
}
/// Paint the merge-in-progress banner into the action-input slot
/// (the commit input is replaced by it during a merge).
fn paint_merge_banner(&self, cx: &mut PaintCx<'_>, rect: Rect) {
let amber = Color { r: 0.96, g: 0.62, b: 0.04, a: 1.0 };
cx.backend.fill_round_rect(rect, 6.0, self.theme.muted);
cx.backend.stroke_round_rect(rect, 6.0, amber, 1.0);
let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0;
self.text(cx, "⚠ Merge in progress", rect.origin.x + 8.0, baseline, 12.0, amber);
}
/// Paint the commit-message input box.
fn paint_input(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_round_rect(rect, 6.0, self.theme.muted);
let border = if self.state.commit_focused {
self.theme.primary
} else {
self.theme.border
};
cx.backend.stroke_round_rect(rect, 6.0, border, 1.0);
let text_x = rect.origin.x + 8.0;
let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0;
let msg = &self.state.commit_message;
if msg.is_empty() && !self.state.commit_focused {
self.text(cx, "Commit message…", text_x, baseline, 12.0, self.theme.muted_foreground);
} else {
let shown = if self.state.commit_focused {
format!("{msg}|")
} else {
msg.clone()
};
self.text(cx, &shown, text_x, baseline, 12.0, self.theme.foreground);
}
}
/// Paint one action button. `enabled` dims a disabled button;
/// `primary` paints the accent (Commit) style.
fn paint_button(&self, cx: &mut PaintCx<'_>, rect: Rect, label: &str, enabled: bool, primary: bool) {
let (fill, text_color) = match (enabled, primary) {
(true, true) => (self.theme.primary, self.theme.primary_foreground),
(true, false) => (self.theme.muted, self.theme.foreground),
(false, _) => (self.theme.muted, self.theme.muted_foreground),
};
cx.backend.fill_round_rect(rect, 6.0, fill);
if !primary {
cx.backend.stroke_round_rect(rect, 6.0, self.theme.border, 1.0);
}
// Roughly centre the label (no per-glyph measurement here).
let label_w = label.chars().count() as f32 * 6.5;
let text_x = rect.origin.x + (rect.size.x - label_w).max(6.0) / 2.0;
let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0;
self.text(cx, label, text_x, baseline, 12.0, text_color);
}
/// Paint a 1-px divider line.
pub(super) fn divider(&self, cx: &mut PaintCx<'_>, left: f32, y: f32, panel_width: f32) {
cx.backend.fill_rect(
Rect {
origin: Point2D::new(left, y),
size: Point2D::new(panel_width - PAD * 2.0, 1.0),
},
self.theme.border,
);
}
/// Paint the menu hint at the panel foot.
fn footer(&self, cx: &mut PaintCx<'_>, left: f32, y: f32) {
self.text(
cx,
"View ▸ Git Panel to close",
left,
y,
10.0,
self.theme.muted_foreground,
);
}
/// Draw one line of text.
pub(super) fn text(
&self,
cx: &mut PaintCx<'_>,
s: &str,
x: f32,
baseline_y: f32,
size: f32,
color: Color,
) {
let layout = TextLayout::single_run(
s,
"system-ui",
size,
to_jian(color),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(&layout, Point2D::new(x, baseline_y));
}
}
/// Whether `point` is inside `rect`.
pub(super) fn contains(rect: Rect, point: Point2D) -> bool {
point.x >= rect.origin.x
&& point.x <= rect.origin.x + rect.size.x
&& point.y >= rect.origin.y
&& point.y <= rect.origin.y + rect.size.y
}
/// Char truncation with an ellipsis.
pub(super) fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
fn to_jian(c: Color) -> jian_core::scene::Color {
fn ch(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a))
}

View file

@ -0,0 +1,163 @@
//! Diff-view rendering + scroll metrics for [`GitPanel`].
//!
//! Split out of `git_panel.rs` to keep that file under the repo's
//! 800-line cap. The diff view replaces the panel's status / action
//! body while [`op_editor_core::GitPanelState::diff`] is set; the
//! header carries ▲ / ▼ / ✕ controls and the body paints a
//! per-line-coloured unified diff.
use crate::widgets::git_panel::{
truncate, GitPanel, DIFF_VIEW_HEIGHT, FOOTER_H, HEADER_BASELINE, PAD,
};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect};
use op_editor_core::GitDiffView;
/// Diff body line height.
const DIFF_LINE_H: f32 = 14.0;
/// Baseline of the first diff body line, from the panel top.
const DIFF_BODY_TOP: f32 = 56.0;
/// Diff-header button side length + gap between buttons.
const DIFF_BTN: f32 = 22.0;
const DIFF_BTN_GAP: f32 = 6.0;
impl GitPanel<'_> {
/// The diff header's `[▲, ▼, ✕]` button rects, right-aligned.
pub(super) fn diff_header_buttons(panel: Rect) -> [Rect; 3] {
let y = panel.origin.y + 12.0;
let right = panel.origin.x + panel.size.x - PAD;
// `from_right` 0 = ✕ (rightmost), 1 = ▼, 2 = ▲.
let slot = |from_right: f32| Rect {
origin: Point2D::new(
right - (from_right + 1.0) * DIFF_BTN - from_right * DIFF_BTN_GAP,
y,
),
size: Point2D::new(DIFF_BTN, DIFF_BTN),
};
[slot(2.0), slot(1.0), slot(0.0)]
}
/// Number of diff lines visible in the body at once.
fn diff_visible_lines() -> usize {
let body_h = DIFF_VIEW_HEIGHT - DIFF_BODY_TOP - FOOTER_H - PAD;
((body_h / DIFF_LINE_H).floor() as usize).max(1)
}
/// Lines a single ▲/▼ press scrolls — one body-page less a small
/// overlap so context carries across the jump.
pub fn diff_page_step() -> usize {
Self::diff_visible_lines().saturating_sub(2).max(1)
}
/// Largest valid scroll offset for the open diff (0 when none).
pub fn diff_max_scroll(&self) -> usize {
match &self.state.diff {
Some(view) => view
.lines
.len()
.saturating_sub(Self::diff_visible_lines()),
None => 0,
}
}
/// Paint the scrollable unified-diff view that replaces the
/// status / action body while `GitPanelState::diff` is set.
pub(super) fn paint_diff(&self, cx: &mut PaintCx<'_>, rect: Rect, view: &GitDiffView) {
let left = rect.origin.x + PAD;
let top = rect.origin.y;
// Header — title + ▲ / ▼ / ✕ buttons.
let title = truncate(&format!("Diff · {}", view.title), 64);
self.text(cx, &title, left, top + HEADER_BASELINE, 13.0, self.theme.foreground);
let [up, down, close] = Self::diff_header_buttons(rect);
self.paint_glyph_button(cx, up, "▲");
self.paint_glyph_button(cx, down, "▼");
self.paint_glyph_button(cx, close, "✕");
self.divider(cx, left, top + 42.0, rect.size.x);
// Body — a visible window of diff lines, per-line coloured.
let visible = Self::diff_visible_lines();
let max_scroll = self.diff_max_scroll();
let scroll = view.scroll.min(max_scroll);
let max_chars = ((rect.size.x - PAD * 2.0) / 6.0) as usize;
if view.lines.is_empty() {
self.text(
cx,
"No changes.",
left,
top + DIFF_BODY_TOP,
12.0,
self.theme.muted_foreground,
);
}
for (row, line) in view.lines.iter().skip(scroll).take(visible).enumerate() {
let baseline = top + DIFF_BODY_TOP + row as f32 * DIFF_LINE_H;
self.text(
cx,
&truncate(line, max_chars),
left,
baseline,
11.0,
self.diff_line_color(line),
);
}
// Footer — scroll position + close hint.
let total = view.lines.len();
let shown_end = (scroll + visible).min(total);
let start = if total == 0 { 0 } else { scroll + 1 };
self.text(
cx,
&format!("lines {start}–{shown_end} of {total} · ✕ to close"),
left,
top + self.height() - PAD,
10.0,
self.theme.muted_foreground,
);
}
/// Per-line colour for a unified-diff line.
fn diff_line_color(&self, line: &str) -> Color {
let green = Color { r: 0.32, g: 0.73, b: 0.42, a: 1.0 };
let red = Color { r: 0.92, g: 0.38, b: 0.38, a: 1.0 };
let blue = Color { r: 0.38, g: 0.63, b: 0.93, a: 1.0 };
if line.starts_with("@@") {
blue
} else if line.starts_with("+++")
|| line.starts_with("---")
|| line.starts_with("diff ")
|| line.starts_with("index ")
|| line.starts_with("new file")
|| line.starts_with("deleted file")
|| line.starts_with("rename ")
|| line.starts_with("similarity ")
|| line.starts_with("commit ")
|| line.starts_with("Author:")
|| line.starts_with("Date:")
{
self.theme.muted_foreground
} else if line.starts_with('+') {
green
} else if line.starts_with('-') {
red
} else {
self.theme.foreground
}
}
/// Paint one small square glyph button — the diff-view controls
/// and the branch-row "merge into current" button.
pub(super) fn paint_glyph_button(&self, cx: &mut PaintCx<'_>, rect: Rect, glyph: &str) {
cx.backend.fill_round_rect(rect, 5.0, self.theme.muted);
cx.backend.stroke_round_rect(rect, 5.0, self.theme.border, 1.0);
let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0;
self.text(
cx,
glyph,
rect.origin.x + rect.size.x / 2.0 - 4.0,
baseline,
11.0,
self.theme.foreground,
);
}
}

View file

@ -0,0 +1,342 @@
//! Tests for `widgets::git_panel` — moved to a sibling file to keep
//! `git_panel.rs` under the 800-line cap.
use crate::widgets::git_panel::*;
use crate::{Point2D, Rect};
use op_editor_core::{EditorState, GitCommitSummary, GitDiffView, GitPanelState};
fn state_with(panel: GitPanelState) -> EditorState {
let mut s = EditorState::new();
s.editor_ui.git_panel = panel;
s
}
fn open_repo() -> GitPanelState {
GitPanelState {
open: true,
in_repo: true,
..GitPanelState::default()
}
}
fn centre(r: Rect) -> Point2D {
Point2D::new(r.origin.x + r.size.x / 2.0, r.origin.y + r.size.y / 2.0)
}
/// A panel rect sized to the panel's current mode.
fn panel_rect(panel: &GitPanel<'_>) -> Rect {
Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(panel.panel_width(), panel.height()),
}
}
#[test]
fn closed_panel_yields_none() {
let s = state_with(GitPanelState::default());
assert!(GitPanel::for_editor(&s).is_none());
}
#[test]
fn open_panel_height_grows_with_commits() {
let base = state_with(open_repo());
let h0 = GitPanel::for_editor(&base).unwrap().height();
let with_commits = state_with(GitPanelState {
recent_commits: vec![
GitCommitSummary {
short_hash: "abc1234".into(),
summary: "first".into(),
author: "Ada".into(),
};
3
],
..open_repo()
});
let h3 = GitPanel::for_editor(&with_commits).unwrap().height();
assert!(h3 > h0, "more commits → taller panel");
}
#[test]
fn empty_history_reserves_a_placeholder_row() {
let empty = state_with(open_repo());
let one = state_with(GitPanelState {
recent_commits: vec![GitCommitSummary {
short_hash: "abc1234".into(),
summary: "only".into(),
author: "Ada".into(),
}],
..open_repo()
});
assert_eq!(
GitPanel::for_editor(&empty).unwrap().height(),
GitPanel::for_editor(&one).unwrap().height(),
);
}
#[test]
fn hit_test_maps_each_action_region() {
let s = state_with(open_repo());
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let rects = GitPanel::action_rects(rect);
assert_eq!(panel.hit_test(rect, centre(rects.input)), Some(GitPanelHit::CommitInput));
assert_eq!(panel.hit_test(rect, centre(rects.commit)), Some(GitPanelHit::Commit));
assert_eq!(panel.hit_test(rect, centre(rects.refresh)), Some(GitPanelHit::Refresh));
assert_eq!(panel.hit_test(rect, centre(rects.pull)), Some(GitPanelHit::Pull));
// Header area → swallowed, not an action.
assert_eq!(
panel.hit_test(rect, Point2D::new(20.0, 8.0)),
Some(GitPanelHit::Inside)
);
// Outside the panel entirely → None.
assert_eq!(
panel.hit_test(rect, Point2D::new(GIT_PANEL_WIDTH + 50.0, 8.0)),
None
);
}
#[test]
fn branch_rows_switch_to_non_current_branch() {
let s = state_with(GitPanelState {
branch: Some("main".to_string()),
branches: vec!["feature".to_string(), "main".to_string()],
..open_repo()
});
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let (_, rows) = panel.branch_layout(rect);
assert_eq!(rows.len(), 2);
// Row 0 is `feature` (not current) → a switch.
assert_eq!(
panel.hit_test(rect, centre(rows[0])),
Some(GitPanelHit::SwitchBranch(0))
);
// Row 1 is `main` (the current branch) → a no-op click.
assert_eq!(panel.hit_test(rect, centre(rows[1])), Some(GitPanelHit::Inside));
}
#[test]
fn branch_row_merge_button_dispatches_a_merge() {
let s = state_with(GitPanelState {
branch: Some("main".to_string()),
branches: vec!["feature".to_string(), "main".to_string()],
..open_repo()
});
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let (_, rows) = panel.branch_layout(rect);
// Row 0 = `feature` (not current). Its right-edge button merges;
// the row body switches.
let merge_btn = GitPanel::branch_merge_button(rows[0]);
assert_eq!(
panel.hit_test(rect, centre(merge_btn)),
Some(GitPanelHit::MergeBranch(0))
);
// A point on the row's far left (clear of the button) → switch.
let left_of_row = Point2D::new(rows[0].origin.x + 8.0, centre(rows[0]).y);
assert_eq!(
panel.hit_test(rect, left_of_row),
Some(GitPanelHit::SwitchBranch(0))
);
// The current branch's row has no merge button — its whole row
// (button area included) is a no-op.
let current_btn = GitPanel::branch_merge_button(rows[1]);
assert_eq!(panel.hit_test(rect, centre(current_btn)), Some(GitPanelHit::Inside));
}
#[test]
fn merge_mode_remaps_the_action_buttons() {
// Conflicts still present — Complete is disabled, so its slot
// dispatches nothing (a swallowed `Inside`).
let blocked = state_with(GitPanelState {
merging: true,
conflicted_files: vec!["doc.op".to_string()],
..open_repo()
});
let panel = GitPanel::for_editor(&blocked).unwrap();
let rect = panel_rect(&panel);
let rects = GitPanel::action_rects(rect);
// Slot 0 = Abort, slot 1 = Refresh; the commit input is inert.
assert_eq!(panel.hit_test(rect, centre(rects.commit)), Some(GitPanelHit::AbortMerge));
assert_eq!(panel.hit_test(rect, centre(rects.refresh)), Some(GitPanelHit::Refresh));
assert_eq!(panel.hit_test(rect, centre(rects.input)), Some(GitPanelHit::Inside));
// Complete slot — inert while conflicts remain.
assert_eq!(panel.hit_test(rect, centre(rects.pull)), Some(GitPanelHit::Inside));
// Conflicts resolved — Complete becomes actionable.
let ready = state_with(GitPanelState {
merging: true,
conflicted_files: vec![],
..open_repo()
});
let panel = GitPanel::for_editor(&ready).unwrap();
let rect = panel_rect(&panel);
let rects = GitPanel::action_rects(rect);
assert_eq!(panel.hit_test(rect, centre(rects.pull)), Some(GitPanelHit::CompleteMerge));
}
#[test]
fn non_repo_panel_has_no_action_targets() {
let s = state_with(GitPanelState {
open: true,
in_repo: false,
..GitPanelState::default()
});
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
// Any in-bounds click is just swallowed.
assert_eq!(
panel.hit_test(rect, Point2D::new(40.0, 40.0)),
Some(GitPanelHit::Inside)
);
}
#[test]
fn truncate_caps_long_summaries() {
assert_eq!(truncate("short", 38), "short");
let long = "x".repeat(50);
let t = truncate(&long, 38);
assert_eq!(t.chars().count(), 38);
assert!(t.ends_with('…'));
}
// --- Diff view ----------------------------------------------------
#[test]
fn dirty_status_line_opens_the_working_diff() {
// A clean tree → the status line is inert.
let clean = state_with(open_repo());
let panel = GitPanel::for_editor(&clean).unwrap();
let rect = panel_rect(&panel);
let clean_hit = panel.hit_test(rect, centre(panel.status_rect(rect)));
assert_eq!(clean_hit, Some(GitPanelHit::Inside));
// A dirty tree → clicking the status line opens the diff.
let dirty = state_with(GitPanelState {
dirty_count: 3,
..open_repo()
});
let panel = GitPanel::for_editor(&dirty).unwrap();
let rect = panel_rect(&panel);
assert_eq!(
panel.hit_test(rect, centre(panel.status_rect(rect))),
Some(GitPanelHit::ShowWorkingDiff)
);
}
#[test]
fn commit_rows_open_a_commit_diff() {
let s = state_with(GitPanelState {
recent_commits: vec![
GitCommitSummary {
short_hash: "aaa1111".into(),
summary: "first".into(),
author: "Ada".into(),
},
GitCommitSummary {
short_hash: "bbb2222".into(),
summary: "second".into(),
author: "Bo".into(),
},
],
..open_repo()
});
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let rows = panel.list_row_rects(rect);
assert_eq!(rows.len(), 2);
assert_eq!(
panel.hit_test(rect, centre(rows[0])),
Some(GitPanelHit::ShowCommitDiff(0))
);
assert_eq!(
panel.hit_test(rect, centre(rows[1])),
Some(GitPanelHit::ShowCommitDiff(1))
);
}
#[test]
fn conflict_rows_open_a_file_diff_in_merge_mode() {
let s = state_with(GitPanelState {
merging: true,
conflicted_files: vec!["a.op".into(), "b.op".into()],
..open_repo()
});
let panel = GitPanel::for_editor(&s).unwrap();
let rect = panel_rect(&panel);
let rows = panel.list_row_rects(rect);
assert_eq!(rows.len(), 2);
assert_eq!(
panel.hit_test(rect, centre(rows[1])),
Some(GitPanelHit::ShowFileDiff(1))
);
}
#[test]
fn diff_mode_widens_the_panel_and_fixes_its_height() {
let normal = state_with(open_repo());
let normal_w = GitPanel::for_editor(&normal).unwrap().panel_width();
assert_eq!(normal_w, GIT_PANEL_WIDTH);
let diffing = state_with(GitPanelState {
diff: Some(GitDiffView {
title: "Working tree".into(),
lines: vec!["+a".into(), "-b".into()],
scroll: 0,
}),
..open_repo()
});
let panel = GitPanel::for_editor(&diffing).unwrap();
assert_eq!(panel.panel_width(), GIT_DIFF_PANEL_WIDTH);
// Diff mode is a tall fixed-height view.
assert!(panel.height() > 400.0);
}
#[test]
fn diff_header_buttons_map_to_scroll_and_close() {
let diffing = state_with(GitPanelState {
diff: Some(GitDiffView {
title: "Working tree".into(),
lines: (0..200).map(|i| format!("+line {i}")).collect(),
scroll: 0,
}),
..open_repo()
});
let panel = GitPanel::for_editor(&diffing).unwrap();
let rect = panel_rect(&panel);
let [up, down, close] = GitPanel::diff_header_buttons(rect);
assert_eq!(panel.hit_test(rect, centre(up)), Some(GitPanelHit::DiffScrollUp));
assert_eq!(panel.hit_test(rect, centre(down)), Some(GitPanelHit::DiffScrollDown));
assert_eq!(panel.hit_test(rect, centre(close)), Some(GitPanelHit::CloseDiff));
// The diff body itself swallows clicks.
assert_eq!(
panel.hit_test(rect, Point2D::new(40.0, 200.0)),
Some(GitPanelHit::Inside)
);
}
#[test]
fn diff_scroll_metrics_clamp_to_the_line_count() {
// A short diff fits in one page → nothing to scroll.
let short = state_with(GitPanelState {
diff: Some(GitDiffView {
title: "t".into(),
lines: vec!["+a".into(), "+b".into()],
scroll: 0,
}),
..open_repo()
});
assert_eq!(GitPanel::for_editor(&short).unwrap().diff_max_scroll(), 0);
// A long diff → a positive max scroll, and a non-zero page step.
let long = state_with(GitPanelState {
diff: Some(GitDiffView {
title: "t".into(),
lines: (0..500).map(|i| format!("+{i}")).collect(),
scroll: 0,
}),
..open_repo()
});
assert!(GitPanel::for_editor(&long).unwrap().diff_max_scroll() > 0);
assert!(GitPanel::diff_page_step() >= 1);
}

View file

@ -88,6 +88,10 @@ pub mod color_picker;
pub mod export_dialog;
pub mod figma_import;
pub mod file_menu;
pub mod git_panel;
mod git_panel_diff;
#[cfg(test)]
mod git_panel_tests;
pub mod locale_picker;
pub mod shape_picker;
pub mod status_bar;
@ -118,6 +122,7 @@ pub use align_toolbar::{AlignToolbar, ALIGN_TOOLBAR_HEIGHT, ALIGN_TOOLBAR_WIDTH}
pub use export_dialog::{ExportDialog, ExportDialogHit, ExportFormat};
pub use locale_picker::{LocalePicker, LOCALE_PICKER_WIDTH};
pub use shape_picker::{ShapeChoice, ShapePicker, SHAPE_PICKER_WIDTH};
pub use git_panel::{GitPanel, GitPanelHit, GIT_PANEL_INSET, GIT_PANEL_WIDTH};
pub use status_bar::{StatusBar, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH};
pub use top_bar::{TopBar, TopBarHit, TOP_BAR_HEIGHT};
// Re-export panel/toolbar width constants + hit enums so the host

View file

@ -35,6 +35,7 @@ use op_editor_ui::{Rect, Theme};
mod click;
mod frame_backend;
mod geometry;
mod git_press;
mod helpers;
mod input;
#[cfg(test)]

View file

@ -7,13 +7,14 @@
//! input-dispatch contract keeps `layout_scene` fresh before any
//! hit-testing input event (see `widget_host.rs`).
use super::helpers::{PANEL_RESIZE_GUTTER, TOOLBAR_INSET_X, TOOLBAR_INSET_Y};
use super::helpers::{rect_contains, PANEL_RESIZE_GUTTER, TOOLBAR_INSET_X, TOOLBAR_INSET_Y};
use super::{CursorHint, PanelResizeKind, WidgetHostNative};
use op_editor_core::ChatAnchor;
use op_editor_ui::widgets::{
rotation_corner_at_point, selection_handle_at_point, LayoutCx, LocalePicker, ShapePicker,
Toolbar, TopBar, Widget, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_HEIGHT,
AI_CHAT_WIDTH, LOCALE_PICKER_WIDTH, SHAPE_PICKER_WIDTH, TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
rotation_corner_at_point, selection_handle_at_point, GitPanel, LayoutCx, LocalePicker,
ShapePicker, Toolbar, TopBar, Widget, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH,
AI_CHAT_HEIGHT, AI_CHAT_WIDTH, GIT_PANEL_INSET, LOCALE_PICKER_WIDTH,
SHAPE_PICKER_WIDTH, TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
};
use op_editor_ui::{Point2D, Rect};
@ -173,7 +174,15 @@ impl WidgetHostNative {
{
return CursorHint::Default;
}
if self.panel_resize_hover(x, y, viewport_w).is_some() || self.is_resizing_panel() {
// The floating Git panel paints on top of the right-rail
// resize gutter (and in diff mode is wide enough to cover
// it), so don't show the resize cursor over the panel.
let over_git_panel = self
.git_panel_rect(viewport_w, viewport_h)
.is_some_and(|r| rect_contains(r, Point2D::new(x, y)));
if self.is_resizing_panel()
|| (!over_git_panel && self.panel_resize_hover(x, y, viewport_w).is_some())
{
return CursorHint::ResizeEw;
}
if self.is_dragging_node() {
@ -266,6 +275,24 @@ impl WidgetHostNative {
(canvas_left, TOP_BAR_HEIGHT, canvas_w, canvas_h)
}
/// Floating Git-panel rect — `None` when the panel is closed.
/// Mirrors the placement in `widget_host/paint.rs`.
pub(in crate::widget_host) fn git_panel_rect(
&self,
viewport_w: f32,
viewport_h: f32,
) -> Option<Rect> {
let panel = GitPanel::for_editor(&self.editor_state)?;
let (canvas_left, _cy, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
Some(Rect {
origin: Point2D::new(
canvas_left + GIT_PANEL_INSET,
TOP_BAR_HEIGHT + GIT_PANEL_INSET,
),
size: Point2D::new(panel.panel_width(), panel.height()),
})
}
pub(in crate::widget_host) fn shape_picker_rect(
&self,
viewport_w: f32,

View file

@ -0,0 +1,122 @@
//! Git-panel press dispatch — extracted from `press.rs` to keep
//! that file under the repo's 800-line cap.
//!
//! The floating Git panel sits above the right-rail panels in paint
//! order; `apply_press` calls [`WidgetHostNative::dispatch_git_panel_press`]
//! after the rail blocks (which already skip a click that lands
//! inside the Git-panel rect) and before the canvas overlays.
use op_editor_core::{GitDiffTarget, GitPanelAction};
use op_editor_ui::widgets::{GitPanel, GitPanelHit};
use op_editor_ui::Point2D;
use super::WidgetHostNative;
impl WidgetHostNative {
/// Dispatch a press inside the floating Git panel.
///
/// Returns `true` when the click was consumed by the panel — a
/// focus change or a queued `GitPanelState::pending_action`.
/// Returns `false` when the click is outside the panel (the
/// commit input is defocused as a side effect) so the caller
/// keeps hit-testing.
pub(in crate::widget_host) fn dispatch_git_panel_press(
&mut self,
x: f32,
y: f32,
viewport_width: f32,
viewport_height: f32,
) -> bool {
let Some(panel_rect) = self.git_panel_rect(viewport_width, viewport_height) else {
return false;
};
// Compute everything that needs an immutable `GitPanel`
// borrow up front — the hit, and the diff scroll bound —
// then drop the borrow before mutating `editor_state`.
let hit = GitPanel::for_editor(&self.editor_state)
.and_then(|p| p.hit_test(panel_rect, Point2D::new(x, y)));
let diff_max_scroll = GitPanel::for_editor(&self.editor_state)
.map(|p| p.diff_max_scroll())
.unwrap_or(0);
let panel = &mut self.editor_state.editor_ui.git_panel;
match hit {
Some(GitPanelHit::CommitInput) => {
panel.commit_focused = true;
}
Some(GitPanelHit::Commit) => {
if !panel.commit_message.trim().is_empty() {
panel.pending_action = Some(GitPanelAction::Commit);
}
}
Some(GitPanelHit::Refresh) => {
panel.pending_action = Some(GitPanelAction::Refresh);
}
Some(GitPanelHit::Pull) => {
panel.pending_action = Some(GitPanelAction::Pull);
}
Some(GitPanelHit::AbortMerge) => {
panel.pending_action = Some(GitPanelAction::AbortMerge);
}
Some(GitPanelHit::CompleteMerge) => {
panel.pending_action = Some(GitPanelAction::CompleteMerge);
}
Some(GitPanelHit::SwitchBranch(index)) => {
if let Some(name) = panel.branches.get(index).cloned() {
panel.pending_action = Some(GitPanelAction::SwitchBranch(name));
}
}
Some(GitPanelHit::MergeBranch(index)) => {
if let Some(name) = panel.branches.get(index).cloned() {
panel.pending_action = Some(GitPanelAction::MergeBranch(name));
}
}
Some(GitPanelHit::ShowWorkingDiff) => {
panel.pending_action =
Some(GitPanelAction::ShowDiff(GitDiffTarget::WorkingTree));
}
Some(GitPanelHit::ShowCommitDiff(index)) => {
if let Some(rev) = panel.recent_commits.get(index).map(|c| c.short_hash.clone()) {
panel.pending_action =
Some(GitPanelAction::ShowDiff(GitDiffTarget::Commit(rev)));
}
}
Some(GitPanelHit::ShowFileDiff(index)) => {
if let Some(path) = panel.conflicted_files.get(index).cloned() {
panel.pending_action =
Some(GitPanelAction::ShowDiff(GitDiffTarget::Path(path)));
}
}
Some(GitPanelHit::CloseDiff) => {
// Pure UI state — no git op needed.
panel.diff = None;
}
Some(GitPanelHit::DiffScrollUp) => {
let step = GitPanel::diff_page_step();
if let Some(diff) = &mut panel.diff {
diff.scroll = diff.scroll.saturating_sub(step);
}
}
Some(GitPanelHit::DiffScrollDown) => {
let step = GitPanel::diff_page_step();
if let Some(diff) = &mut panel.diff {
diff.scroll = (diff.scroll + step).min(diff_max_scroll);
}
}
Some(GitPanelHit::Inside) => {
// Panel chrome — swallow the click + defocus.
panel.commit_focused = false;
}
None => {
// Outside the panel — release the commit input's
// focus, then let the click fall through.
if panel.commit_focused {
panel.commit_focused = false;
self.mark_dirty();
}
return false;
}
}
self.mark_dirty();
true
}
}

View file

@ -20,12 +20,27 @@ impl WidgetHostNative {
|| self.editor_state.editor_ui.variable_row_focus.is_some()
|| self.editor_state.editor_ui.agent_settings.focus.is_some()
|| self.editor_state.chat.focused
|| self.git_commit_focus_active()
}
pub fn settings_focus_active(&self) -> bool {
self.editor_state.editor_ui.agent_settings.focus.is_some()
}
/// Whether the Git panel's commit-message input owns the
/// keyboard. Like [`Self::settings_focus_active`], the desktop
/// runner gates editor shortcuts on this so typing a commit
/// message never mutates the document.
///
/// Never active while the panel is `loading`: the commit input
/// is hidden then, so a stale `commit_focused` must not let
/// keystrokes — or Enter — reach (and commit through) a control
/// the user cannot see.
pub fn git_commit_focus_active(&self) -> bool {
let panel = &self.editor_state.editor_ui.git_panel;
panel.open && panel.commit_focused && !panel.loading
}
/// Wheel event — zoom centered at (x, y) over the canvas.
pub fn apply_wheel(
&mut self,

View file

@ -23,6 +23,15 @@ impl WidgetHostNative {
}
return false;
}
// Git panel's commit-message input owns the keyboard next.
if self.git_commit_focus_active() {
if !c.is_control() {
self.editor_state.editor_ui.git_panel.commit_message.push(c);
self.mark_dirty();
return true;
}
return false;
}
if self.editor_state.ui.layer_rename.is_some() && !c.is_control() {
let mut s = [0u8; 4];
let _ = self.editor_state.rename_append(c.encode_utf8(&mut s));
@ -103,6 +112,11 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
if self.git_commit_focus_active() {
self.editor_state.editor_ui.git_panel.commit_message.pop();
self.mark_dirty();
return true;
}
if self.editor_state.ui.layer_rename.is_some() {
let ok = self.editor_state.rename_backspace();
if ok {
@ -238,6 +252,15 @@ impl WidgetHostNative {
self.commit_settings_focus_if_any();
return true;
}
// Enter in the Git commit input requests a commit.
if self.git_commit_focus_active() {
let panel = &mut self.editor_state.editor_ui.git_panel;
if !panel.commit_message.trim().is_empty() {
panel.pending_action = Some(op_editor_core::GitPanelAction::Commit);
}
self.mark_dirty();
return true;
}
if self.editor_state.ui.layer_rename.is_some() {
let ok = self.editor_state.rename_commit();
if ok {
@ -293,6 +316,12 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
// Escape defocuses the Git commit input (the panel stays open).
if self.git_commit_focus_active() {
self.editor_state.editor_ui.git_panel.commit_focused = false;
self.mark_dirty();
return true;
}
if self.editor_state.editor_ui.agent_settings_open {
self.editor_state.editor_ui.agent_settings_open = false;
self.editor_state.editor_ui.agent_settings_drag = None;

View file

@ -6,9 +6,10 @@ use super::frame_backend::NativeFrameBackend;
use super::helpers::{STATUS_INSET, TOOLBAR_INSET_X, TOOLBAR_INSET_Y};
use super::WidgetHostNative;
use op_editor_ui::widgets::{
variables_panel::VariablesPanel, AIChatPlaceholder, AlignToolbar, CanvasViewport, LayerPanel,
LayoutCx, LocalePicker, PaintCx, PropertyPanel, ShapePicker, StatusBar, Toolbar, TopBar,
Widget, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
variables_panel::VariablesPanel, AIChatPlaceholder, AlignToolbar, CanvasViewport, GitPanel,
LayerPanel, LayoutCx, LocalePicker, PaintCx, PropertyPanel, ShapePicker, StatusBar, Toolbar,
TopBar, Widget, GIT_PANEL_INSET, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH,
TOOLBAR_WIDTH, TOP_BAR_HEIGHT,
};
use op_editor_ui::{Point2D, Rect, RenderBackend};
@ -228,6 +229,23 @@ impl WidgetHostNative {
status.paint(&mut cx, status_rect);
}
// 8.2. Floating Git panel — read-only repository status,
// toggled from the View menu. Floats at the canvas's
// top-left, below the TopBar.
if let Some(panel) = GitPanel::for_editor(&self.editor_state) {
let panel_rect = Rect {
origin: Point2D::new(
canvas_left + GIT_PANEL_INSET,
TOP_BAR_HEIGHT + GIT_PANEL_INSET,
),
size: Point2D::new(panel.panel_width(), panel.height()),
};
let mut cx = PaintCx {
backend: &mut *frame,
};
panel.paint(&mut cx, panel_rect);
}
// 8.4. Floating align/distribute toolbar — visible whenever
// 2+ nodes are selected. Sits above the canvas but
// below status / modal overlays.

View file

@ -230,8 +230,19 @@ impl WidgetHostNative {
}
}
// The floating Git panel paints on top of the right-rail
// panels (see `paint.rs` §8.2) — and in diff mode it widens
// to 620 px, which overlaps the property / variables rail
// (and its resize gutter) on a narrow window. Hit-test order
// must mirror paint Z-order: a click inside the Git-panel
// rect belongs to the Git panel (block 0.9 below), so every
// rail block in between skips a click it would otherwise own.
let in_git_panel = self
.git_panel_rect(viewport_width, viewport_height)
.is_some_and(|r| rect_contains(r, Point2D::new(x, y)));
// 0z. Panel-resize gutter — ±4 px from rail edges.
if y >= TOP_BAR_HEIGHT {
if y >= TOP_BAR_HEIGHT && !in_git_panel {
if let Some(kind) = self.panel_resize_hover(x, y, viewport_width) {
let start_width = match kind {
PanelResizeKind::LayerRight => self.editor_state.editor_ui.layer_panel_width,
@ -358,7 +369,7 @@ impl WidgetHostNative {
}
// 0c0. Fill-type picker — outside-click dismiss.
if self.editor_state.editor_ui.fill_type_picker_open {
if self.editor_state.editor_ui.fill_type_picker_open && !in_git_panel {
self.refresh_layout_scene();
if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) {
let property_rect = Rect {
@ -388,13 +399,17 @@ impl WidgetHostNative {
}
// 0b1. VariablesPanel — tested before PropertyPanel.
if self.dispatch_variables_panel_press(x, y, viewport_width, viewport_height) {
if !in_git_panel
&& self.dispatch_variables_panel_press(x, y, viewport_width, viewport_height)
{
return true;
}
// 0c. PropertyPanel input row.
self.refresh_layout_scene();
if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) {
if let Some(panel) = PropertyPanel::for_selection(&self.editor_state)
.filter(|_| !in_git_panel)
{
let property_rect = Rect {
origin: Point2D::new(
viewport_width - self.editor_state.editor_ui.property_panel_width,
@ -433,6 +448,12 @@ impl WidgetHostNative {
}
}
// 0.9. Floating Git panel — status + interactive actions
// (dispatch in `git_press.rs`).
if self.dispatch_git_panel_press(x, y, viewport_width, viewport_height) {
return true;
}
// 1. AI chat panel — sits on top of the toolbar in paint
// order. DragHandle starts a chat drag; other AI hits
// defer to apply_click.