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:
parent
0730230ddb
commit
b8f030b3bb
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
703
crates/op-editor-ui/src/widgets/git_panel.rs
Normal file
703
crates/op-editor-ui/src/widgets/git_panel.rs
Normal 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))
|
||||
}
|
||||
163
crates/op-editor-ui/src/widgets/git_panel_diff.rs
Normal file
163
crates/op-editor-ui/src/widgets/git_panel_diff.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
342
crates/op-editor-ui/src/widgets/git_panel_tests.rs
Normal file
342
crates/op-editor-ui/src/widgets/git_panel_tests.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
122
crates/op-host-native/src/widget_host/git_press.rs
Normal file
122
crates/op-host-native/src/widget_host/git_press.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue