diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index c55917b08..f03f8858f 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -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(String), + /// The full patch a commit introduced (`git show `). + 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, + /// 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, + /// All local branch names, sorted — the panel lists them for + /// one-click switching. + pub branches: Vec, + /// 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, + /// Most-recent commits, newest first. + pub recent_commits: Vec, + /// 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, + /// 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, +} + /// 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(), } } } diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 592f5f4fb..f67b2010f 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -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}; diff --git a/crates/op-editor-ui/src/widgets/git_panel.rs b/crates/op-editor-ui/src/widgets/git_panel.rs new file mode 100644 index 000000000..6ceddaf48 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel.rs @@ -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> { + 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) { + 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 { + 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 { + 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)) +} diff --git a/crates/op-editor-ui/src/widgets/git_panel_diff.rs b/crates/op-editor-ui/src/widgets/git_panel_diff.rs new file mode 100644 index 000000000..2b503549e --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel_diff.rs @@ -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, + ); + } +} diff --git a/crates/op-editor-ui/src/widgets/git_panel_tests.rs b/crates/op-editor-ui/src/widgets/git_panel_tests.rs new file mode 100644 index 000000000..1553b3a5e --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel_tests.rs @@ -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); +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 9e26c1c2a..ce7ed57dd 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -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 diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index e6010ac8c..9ad5696f9 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -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)] diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index 9906fc052..61a901748 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -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 { + 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, diff --git a/crates/op-host-native/src/widget_host/git_press.rs b/crates/op-host-native/src/widget_host/git_press.rs new file mode 100644 index 000000000..3e06e0e5d --- /dev/null +++ b/crates/op-host-native/src/widget_host/git_press.rs @@ -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 + } +} diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index e1a0d0dc7..253de0b0a 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -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, diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 509ac43d0..851dedec9 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -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; diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 19480b0f0..a7fe63f0c 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -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. diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index a13e1377f..e9c88b43a 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -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.