diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index fdba4c9d0..77886cbf6 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -136,6 +136,60 @@ pub struct GitCommitSummary { pub is_initial: bool, } +/// One `.op` candidate in the tracked-file picker (TS `GitCandidateFileInfo`). +/// Plain data the host enumerates from the repo; the widget only paints it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitCandidateFile { + /// Absolute path — the bind argument. + pub path: String, + /// Repo-relative path — the row title. + pub relative_path: String, + /// Number of commits that touched this file (the "N milestones" label). + pub milestone_count: u32, + /// Pre-formatted relative time of the last commit touching it, or empty. + pub last_commit_time: String, + /// First line of the last commit's message, if any. + pub last_commit_message: Option, +} + +/// One node-level change in a commit's semantic diff (TS `NodePatch`, +/// rendered as ` ` in the inline detail card). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitDiffPatch { + /// `add` / `remove` / `modify` / `move`. + pub op: String, + /// The affected node's id. + pub node_id: String, +} + +/// Aggregated semantic diff of one commit against its parent — the TS +/// `engineDiff` result that drives `GitPanelHistoryDiff`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CommitDiffSummary { + /// Distinct parent ids touched by any patch. + pub frames_changed: u32, + pub nodes_added: u32, + pub nodes_removed: u32, + pub nodes_modified: u32, + /// Per-node patch list (newest-first walk order). + pub patches: Vec, +} + +/// Lazy state of the expanded commit's inline diff (TS `DiffState`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommitDiffView { + /// The host is computing the diff on a worker / this frame. + Loading, + /// The root commit — no parent to diff against. + Initial, + /// Diff computed, but no node changed (rare; e.g. metadata-only). + NoChanges, + /// The diff could not be computed (parse / git error). Carries the message. + Error(String), + /// Computed diff ready to render. + Ready(CommitDiffSummary), +} + /// One changed file in the Git panel's staging list — plain data /// snapshotted by the desktop host from `git status`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -258,6 +312,10 @@ pub enum GitOverflowView { Menu, /// The remote-settings subview — origin URL + HTTPS credential. RemoteSettings, + /// The tracked-file picker subview — pick which `.op` the panel tracks. + TrackedPicker, + /// The SSH-keys subview — list keys + import / generate. + SshKeys, } /// Which sub-mode the branch-picker dropdown is showing (mirrors the @@ -341,6 +399,29 @@ pub enum GitPanelAction { RestoreCommit(String), /// Copy the given commit hash to the OS clipboard (TS copy-hash). CopyHash(String), + /// Compute the semantic diff of `recent_commits[index]` against its + /// parent and store it in `expanded_commit_diff` (TS `computeDiff`, + /// triggered when a commit row's detail card is expanded). + LoadCommitDiff(usize), + /// Overflow "切换跟踪文件" — enumerate the repo's `.op` candidates into + /// `candidate_files` and open the tracked-file picker subview. + EnterTrackedPicker, + /// Bind the panel to the given `.op` path (TS `bindTrackedFile`). The + /// `bool` is "also load it into the editor" (TS "track and open"). + BindTrackedFile(String, bool), + /// Overflow "清除提交作者" — clear the stored commit-author identity. + ClearAuthor, + /// Overflow "关闭仓库" — unbind the repository and reset to empty state. + CloseRepo, + /// Overflow "SSH 密钥" — enumerate stored SSH keys + open the subview. + EnterSshKeys, + /// SSH subview "导入现有密钥" — pick a private key file and import it. + ImportSshKey, + /// Remote-settings "获取" — run `git fetch` on the origin remote. + FetchRemote, + /// Commit-signature form "保存" — write the name/email drafts into the + /// repo identity, then re-fire the pending milestone commit. + SaveAuthor, } /// Which clone-form text field has keyboard focus. @@ -426,6 +507,14 @@ pub struct GitPanelState { /// Commits the current branch is ahead of its upstream — gates the /// Push button (TS disables Push when `ahead === 0`). pub ahead: u32, + /// Commits the local branch is behind its upstream (remote-settings row). + pub behind: u32, + /// The `origin` remote's host (e.g. `github.com`), parsed host-side. + /// Drives the remote-settings credentials row; `None` = no host detected. + pub remote_host: Option, + /// Stored-credential kind for `remote_host`: `"token"` / `"ssh"` / + /// `"none"` (empty when there's no host). Host-filled. + pub stored_auth: String, /// Number of files with unresolved merge conflicts. pub conflicted_count: usize, /// Whether a merge is in progress — drives the panel's conflict @@ -453,10 +542,36 @@ pub struct GitPanelState { /// the commit list changes so it can't point at a stale commit /// (TS keys the card by hash; the widget layer keys by index). pub expanded_commit: Option, + /// Lazy semantic diff for the expanded commit (TS `GitPanelHistoryDiff`). + /// `None` when no card is open; otherwise loading / initial / ready / + /// error. The host fills it after a `LoadCommitDiff` action. + pub expanded_commit_diff: Option, + /// Candidate `.op` files for the tracked-file picker subview, host-filled + /// when the picker opens (TS `RepoMeta.candidateFiles`). + pub candidate_files: Vec, + /// The picker's currently-selected candidate index, if any. + pub tracked_picker_selected: Option, + /// SSH key names for the SSH-keys subview (host-filled on open). + pub ssh_keys: 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, + /// Set when a milestone "save" was skipped because the saved design + /// matched the last commit — the ready view shows a "未检测到变更" hint + /// under the commit box. Cleared when the user re-engages the input. + pub commit_no_changes: bool, + /// Whether the commit-signature form (`提交署名`) is showing in place of + /// the commit box — raised when a commit is attempted with no committer + /// identity (TS `authorPromptVisible`). The pending message stays in + /// `commit_message` and the commit re-fires after a successful save. + pub author_prompt: bool, + /// Name / email drafts typed into the commit-signature form. + pub author_name_draft: String, + pub author_email_draft: String, + /// Which signature-form field holds keyboard focus. + pub author_name_focused: bool, + pub author_email_focused: bool, /// Caret-blink anchor (ms) for the commit input — reset on focus + /// each keystroke so the caret stays solid while typing, then /// blinks (same cadence as the chat / property inputs). diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 5f890172e..9a5651a61 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -110,12 +110,13 @@ pub use command::{ pub use components::{Component, ComponentLibrary}; pub use design_md::{extract_design_md_from_document, generate_design_md, parse_design_md}; pub use editor_ui_state::{ - BooleanOp, CloneField, CloneFormState, DesignMdRequest, EditorUiState, ExportFormat, - FileAction, FileMenuChoice, FillType, FlexLayout, GitBranchPickerMode, GitCommitSummary, - GitDiffTarget, GitDiffView, GitFileEntry, GitOverflowView, GitPanelAction, GitPanelState, - ImageAdjustmentField, ImageFillMode, LayerContextMenuState, Locale, MergeConflictRow, - MergeResolveFile, MergeResolveState, PaddingEditMode, PageRenameState, PropertyTab, RecentFile, - ShapeChoice, ThemeMode, UpdateStatus, VariableRowFocus, + BooleanOp, CloneField, CloneFormState, CommitDiffPatch, CommitDiffSummary, CommitDiffView, + DesignMdRequest, EditorUiState, ExportFormat, FileAction, FileMenuChoice, FillType, FlexLayout, + GitBranchPickerMode, GitCandidateFile, GitCommitSummary, GitDiffTarget, GitDiffView, + GitFileEntry, GitOverflowView, GitPanelAction, GitPanelState, ImageAdjustmentField, + ImageFillMode, LayerContextMenuState, Locale, MergeConflictRow, MergeResolveFile, + MergeResolveState, PaddingEditMode, PageRenameState, PropertyTab, RecentFile, ShapeChoice, + ThemeMode, UpdateStatus, VariableRowFocus, }; pub use fills::{ first_fill_type, first_image_fill_summary, first_solid_fill_hex, first_solid_fill_opacity, diff --git a/crates/op-editor-ui/src/widgets/git_panel.rs b/crates/op-editor-ui/src/widgets/git_panel.rs index b1e555b61..fdbf17a0d 100644 --- a/crates/op-editor-ui/src/widgets/git_panel.rs +++ b/crates/op-editor-ui/src/widgets/git_panel.rs @@ -94,6 +94,35 @@ pub enum GitPanelHit { OverflowRemoteSettings, /// The overflow menu's "SSH keys" entry — set up SSH auth. OverflowSshKeys, + /// The overflow menu's "Switch tracked file" entry — open the + /// tracked-file picker subview. + OverflowSwitchTracked, + /// The overflow menu's "Clear commit author" entry. + OverflowClearAuthor, + /// The overflow menu's "Close repository" entry — unbind the repo. + OverflowCloseRepo, + /// A tracked-file-picker candidate row — select `candidate_files[index]`. + TrackedPickerRow(usize), + /// The tracked-file picker's "Track this file" button. + TrackedPickerBind, + /// The tracked-file picker's "Track and open" button. + TrackedPickerBindOpen, + /// The tracked-file picker's Back / Cancel button. + TrackedPickerBack, + /// Remote-settings "获取" — run `git fetch` on origin. + FetchRemote, + /// Commit-signature form — focus the 姓名 (name) input. + AuthorNameInput, + /// Commit-signature form — focus the 邮箱 (email) input. + AuthorEmailInput, + /// Commit-signature form "保存" — save identity + re-fire the commit. + AuthorSave, + /// Commit-signature form "取消" — dismiss without committing. + AuthorCancel, + /// SSH subview "生成新密钥" — generate a key for the origin host. + SshGenerateKey, + /// SSH subview "导入现有密钥" — import an existing private key. + SshImportKey, /// A subview's `‹ Back` row — return to the overflow menu. OverflowBack, /// A click outside an open header popover (but inside the panel) — @@ -422,31 +451,6 @@ impl<'a> GitPanel<'a> { /// Paint the panel into `rect`. pub fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) { - // Drop shadow (TS PopoverContent `shadow-md`) — two soft black rects - // offset down so the floating panel lifts off the canvas instead of - // reading flat / stuck-on against it. - cx.backend.fill_round_rect( - Rect { - origin: Point2D::new(rect.origin.x, rect.origin.y + 10.0), - size: rect.size, - }, - 6.0, - Color { - a: 0.10, - ..Color::BLACK - }, - ); - cx.backend.fill_round_rect( - Rect { - origin: Point2D::new(rect.origin.x, rect.origin.y + 4.0), - size: rect.size, - }, - 6.0, - Color { - a: 0.14, - ..Color::BLACK - }, - ); // TS popover radius is `rounded-md` = 6px (Rust was 10px, too round). cx.backend.fill_round_rect(rect, 6.0, self.theme.popover); cx.backend diff --git a/crates/op-editor-ui/src/widgets/git_panel_clone.rs b/crates/op-editor-ui/src/widgets/git_panel_clone.rs index 5d665f868..b3ac2365a 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_clone.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_clone.rs @@ -224,7 +224,7 @@ impl GitPanel<'_> { cx.backend.save(); cx.backend.clip_rect(rect); self.text(cx, value, text_x, baseline, 12.0, t.foreground); - if focused && jian_core::anim::blink_visible(self.now_ms, anchor_ms, 530) { + if focused && jian_core::anim::blink_visible(self.now_ms, anchor_ms, 500) { let caret_x = text_x + cx.backend.measure_text(value, 12.0) + 1.0; cx.backend.fill_rect( Rect { diff --git a/crates/op-editor-ui/src/widgets/git_panel_menus.rs b/crates/op-editor-ui/src/widgets/git_panel_menus.rs index 31a1601e3..a24020eda 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_menus.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_menus.rs @@ -38,7 +38,7 @@ const PICKER_FOOTER_H: f32 = 30.0; /// Create-mode body height — name input (30) + gap (8) + submit (24) + pad. const PICKER_CREATE_H: f32 = 70.0; /// Overflow-menu width (TS `w-56` ≈ 224 px, clamped to the panel). -const OVERFLOW_W: f32 = 208.0; +const OVERFLOW_W: f32 = 224.0; /// Remote-settings subview width (TS `w-[300px]`, clamped). const RS_W: f32 = 280.0; /// Inner padding inside the remote-settings subview. @@ -51,6 +51,10 @@ const RS_BACK_H: f32 = 24.0; const RS_BTN_W: f32 = 52.0; /// Gap between an input and its trailing button. const RS_GAP: f32 = 8.0; +/// Height of the ahead/behind + credentials section appended below the +/// remote-URL input (divider + ahead/behind row + divider + credentials row, +/// plus bottom padding). +const RS_SECTION: f32 = 70.0; /// One overflow-menu entry — an icon, a label key, and the /// [`GitPanelHit`] it dispatches. @@ -60,29 +64,67 @@ struct OverflowItem { hit: GitPanelHit, /// A `›` submenu affordance (the entry opens a subview). submenu: bool, + /// A divider band painted below this row (TS ``). + divider_after: bool, } +/// Height of a divider band between overflow-menu groups (TS +/// `` ≈ 1px line + 8px margins). +const OVERFLOW_DIVIDER_H: f32 = 9.0; + impl GitPanel<'_> { /// The overflow menu's entries, top to bottom — a port of the TS - /// header popover. Only the remote-settings / SSH-keys subviews are - /// wired today; the entries map to existing git actions. - fn overflow_items(&self) -> [OverflowItem; 2] { - [ + /// header popover (`git-panel-header.tsx`): switch-tracked-file / + /// clear-author / —— / remote-settings › / ssh-keys › / —— / close-repo. + fn overflow_items(&self) -> Vec { + vec![ + OverflowItem { + icon: Icon::FileSearch, + label_key: "git.header.overflowSwitchTracked", + hit: GitPanelHit::OverflowSwitchTracked, + submenu: false, + divider_after: false, + }, + OverflowItem { + icon: Icon::UserX, + label_key: "git.header.overflowClearAuthor", + hit: GitPanelHit::OverflowClearAuthor, + submenu: false, + divider_after: true, + }, OverflowItem { icon: Icon::Settings2, label_key: "git.header.overflowRemoteSettings", hit: GitPanelHit::OverflowRemoteSettings, submenu: true, + divider_after: false, }, OverflowItem { - icon: Icon::Lock, + icon: Icon::Key, label_key: "git.header.overflowSshKeys", hit: GitPanelHit::OverflowSshKeys, + submenu: true, + divider_after: true, + }, + OverflowItem { + icon: Icon::LogOut, + label_key: "git.header.overflowCloseRepo", + hit: GitPanelHit::OverflowCloseRepo, submenu: false, + divider_after: false, }, ] } + /// Total extra height contributed by divider bands in the menu. + fn overflow_dividers_height(&self) -> f32 { + self.overflow_items() + .iter() + .filter(|it| it.divider_after) + .count() as f32 + * OVERFLOW_DIVIDER_H + } + // ── Branch picker ──────────────────────────────────────────────── /// The branch-picker dropdown rect, anchored below the branch @@ -435,7 +477,7 @@ impl GitPanel<'_> { let (_, _, overflow_btn) = self.ready_header_buttons(panel_rect); let items = self.overflow_items().len(); let w = OVERFLOW_W.min(panel_rect.size.x - PAD * 2.0); - let h = MENU_PAD * 2.0 + items as f32 * MENU_ROW_H; + let h = MENU_PAD * 2.0 + items as f32 * MENU_ROW_H + self.overflow_dividers_height(); let right = overflow_btn.origin.x + overflow_btn.size.x; Rect { origin: Point2D::new(right - w, overflow_btn.origin.y + overflow_btn.size.y + 4.0), @@ -443,21 +485,27 @@ impl GitPanel<'_> { } } - /// One clickable rect per overflow-menu entry. + /// One clickable rect per overflow-menu entry. The y-walk inserts a + /// [`OVERFLOW_DIVIDER_H`] gap after any `divider_after` row so paint + + /// hit-test agree on where each row lands. pub(super) fn overflow_row_rects(&self, panel_rect: Rect) -> Vec { let panel = self.overflow_panel(panel_rect); - (0..self.overflow_items().len()) - .map(|i| Rect { - origin: Point2D::new( - panel.origin.x + MENU_PAD, - panel.origin.y + MENU_PAD + i as f32 * MENU_ROW_H, - ), + let mut y = panel.origin.y + MENU_PAD; + let mut rects = Vec::new(); + for item in self.overflow_items() { + rects.push(Rect { + origin: Point2D::new(panel.origin.x + MENU_PAD, y), size: Point2D::new(panel.size.x - MENU_PAD * 2.0, MENU_ROW_H), - }) - .collect() + }); + y += MENU_ROW_H; + if item.divider_after { + y += OVERFLOW_DIVIDER_H; + } + } + rects } - /// Paint the overflow `…` menu. + /// Paint the overflow `…` menu (TS `git-panel-header.tsx` popover). pub(super) fn paint_overflow_menu(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { let t = self.theme; let panel = self.overflow_panel(panel_rect); @@ -465,13 +513,14 @@ impl GitPanel<'_> { cx.backend.stroke_round_rect(panel, 8.0, t.border, 1.0); let rows = self.overflow_row_rects(panel_rect); for (item, row) in self.overflow_items().iter().zip(rows.iter()) { + // Leaf icon (TS size=13 strokeWidth=1.75, muted). draw_icon( cx.backend, item.icon, - Point2D::new(row.origin.x + 8.0, row.origin.y + (row.size.y - 14.0) / 2.0), - 14.0, + Point2D::new(row.origin.x + 8.0, row.origin.y + (row.size.y - 13.0) / 2.0), + 13.0, t.muted_foreground, - 1.5, + 1.75, ); self.text( cx, @@ -494,6 +543,17 @@ impl GitPanel<'_> { 1.5, ); } + // Divider band below the row (TS ``). + if item.divider_after { + let dy = row.origin.y + row.size.y + OVERFLOW_DIVIDER_H / 2.0; + cx.backend.fill_rect( + Rect { + origin: Point2D::new(panel.origin.x + MENU_PAD, dy), + size: Point2D::new(panel.size.x - MENU_PAD * 2.0, 1.0), + }, + alpha(t.border, 0.50), + ); + } } } @@ -521,6 +581,8 @@ impl GitPanel<'_> { match self.state.overflow_view { GitOverflowView::Menu => self.paint_overflow_menu(cx, panel_rect), GitOverflowView::RemoteSettings => self.paint_remote_settings(cx, panel_rect), + GitOverflowView::TrackedPicker => self.paint_tracked_picker(cx, panel_rect), + GitOverflowView::SshKeys => self.paint_ssh_keys(cx, panel_rect), } } @@ -533,6 +595,8 @@ impl GitPanel<'_> { match self.state.overflow_view { GitOverflowView::Menu => self.overflow_hit(panel_rect, point), GitOverflowView::RemoteSettings => self.remote_settings_hit(panel_rect, point), + GitOverflowView::TrackedPicker => self.tracked_picker_hit(panel_rect, point), + GitOverflowView::SshKeys => self.ssh_keys_hit(panel_rect, point), } } @@ -542,17 +606,48 @@ impl GitPanel<'_> { pub(super) fn remote_settings_panel(&self, panel_rect: Rect) -> Rect { let (_, _, overflow_btn) = self.ready_header_buttons(panel_rect); let w = RS_W.min(panel_rect.size.x - PAD * 2.0); - let h = RS_PAD * 2.0 + RS_BACK_H + 8.0 + RS_ROW_H + 8.0 + RS_ROW_H; + let top = overflow_btn.origin.y + overflow_btn.size.y + 4.0; + // back + 远端 heading + optional empty hint + Origin-地址 label + URL + // input row + the ahead/behind & credentials section. + let h = (self.remote_url_top(top) - top) + RS_ROW_H + 8.0 + RS_SECTION; let right = overflow_btn.origin.x + overflow_btn.size.x; Rect { - origin: Point2D::new(right - w, overflow_btn.origin.y + overflow_btn.size.y + 4.0), + origin: Point2D::new(right - w, top), size: Point2D::new(w, h), } } - /// The subview's interactive sub-rects: `(back, url_input, set, - /// https_input, login)`. Shared by paint + hit-test (+ tests). - pub(super) fn remote_settings_rects(&self, panel_rect: Rect) -> (Rect, Rect, Rect, Rect, Rect) { + /// Absolute y of the Origin-URL input — below the back row, the 远端 + /// heading, the optional "尚未配置远端仓库" hint, and the Origin-地址 label. + fn remote_url_top(&self, panel_top: f32) -> f32 { + let mut y = panel_top + RS_PAD + RS_BACK_H + 6.0; // 远端 heading row + y += 18.0; + if self.state.remotes.is_empty() { + y += 18.0; // empty hint + } + y += 18.0; // Origin 地址 label + y + } + + /// Y of the first divider below the URL input. + fn remote_settings_section_top(&self, p: Rect) -> f32 { + self.remote_url_top(p.origin.y) + RS_ROW_H + 8.0 + } + + /// The "获取" (Fetch) button rect on the ahead/behind row. + pub(super) fn remote_settings_fetch_rect(&self, panel_rect: Rect) -> Rect { + let p = self.remote_settings_panel(panel_rect); + let top = self.remote_settings_section_top(p); + Rect { + origin: Point2D::new(p.origin.x + p.size.x - RS_PAD - 44.0, top + 6.0), + size: Point2D::new(44.0, 22.0), + } + } + + /// The subview's interactive sub-rects: `(back, url_input, set)` — the + /// TS layout has no HTTPS-credential input here. Shared by paint + + /// hit-test (+ tests). + pub(super) fn remote_settings_rects(&self, panel_rect: Rect) -> (Rect, Rect, Rect) { let p = self.remote_settings_panel(panel_rect); let left = p.origin.x + RS_PAD; let inner_w = p.size.x - RS_PAD * 2.0; @@ -560,7 +655,7 @@ impl GitPanel<'_> { origin: Point2D::new(left, p.origin.y + RS_PAD), size: Point2D::new(inner_w, RS_BACK_H), }; - let url_top = back.origin.y + RS_BACK_H + 8.0; + let url_top = self.remote_url_top(p.origin.y); let field_w = inner_w - RS_BTN_W - RS_GAP; let url_input = Rect { origin: Point2D::new(left, url_top), @@ -570,16 +665,7 @@ impl GitPanel<'_> { origin: Point2D::new(left + field_w + RS_GAP, url_top), size: Point2D::new(RS_BTN_W, RS_ROW_H), }; - let cred_top = url_top + RS_ROW_H + 8.0; - let https_input = Rect { - origin: Point2D::new(left, cred_top), - size: Point2D::new(field_w, RS_ROW_H), - }; - let login = Rect { - origin: Point2D::new(left + field_w + RS_GAP, cred_top), - size: Point2D::new(RS_BTN_W, RS_ROW_H), - }; - (back, url_input, set, https_input, login) + (back, url_input, set) } /// Paint the remote-settings subview. @@ -588,25 +674,57 @@ impl GitPanel<'_> { let p = self.remote_settings_panel(panel_rect); cx.backend.fill_round_rect(p, 8.0, t.popover); cx.backend.stroke_round_rect(p, 8.0, t.border, 1.0); - let (back, url_input, set, https_input, login) = self.remote_settings_rects(panel_rect); - // ‹ Back header row. + let (back, url_input, set) = self.remote_settings_rects(panel_rect); + let left = back.origin.x; + // ← Back row. draw_icon( cx.backend, Icon::ChevronLeft, - Point2D::new(back.origin.x, back.origin.y + (back.size.y - 14.0) / 2.0), + Point2D::new(left, back.origin.y + (back.size.y - 14.0) / 2.0), 14.0, t.muted_foreground, 1.5, ); self.text( cx, - self.t("git.remote.settingsHeading"), - back.origin.x + 20.0, + self.t("git.remote.back"), + left + 20.0, back.origin.y + back.size.y / 2.0 + 4.0, 12.0, t.foreground, ); - // Remote-URL field + "Save". + // 远端 section heading (uppercase muted). + let heading_y = back.origin.y + RS_BACK_H + 6.0; + self.text( + cx, + self.t("git.remote.settingsHeading"), + left, + heading_y + 12.0, + 10.0, + t.muted_foreground, + ); + // Empty hint + Origin-地址 label above the URL input. + let mut label_y = heading_y + 18.0; + if self.state.remotes.is_empty() { + self.text( + cx, + self.t("git.remote.emptyNoOrigin"), + left, + label_y + 12.0, + 11.0, + t.muted_foreground, + ); + label_y += 18.0; + } + self.text( + cx, + self.t("git.remote.urlLabel"), + left, + label_y + 12.0, + 11.0, + t.muted_foreground, + ); + // Origin-URL field + "Save". self.paint_menu_input( cx, url_input, @@ -621,21 +739,64 @@ impl GitPanel<'_> { !self.state.remote_draft.trim().is_empty(), true, ); - // HTTPS-credential field + "Login". - self.paint_menu_input( - cx, - https_input, - &self.state.https_draft, - self.t("git.panel.httpsPlaceholder"), - self.state.https_focused, - ); + + // ── Ahead/behind + Fetch + stored-credentials section (TS rows) ── + let p = self.remote_settings_panel(panel_rect); + let inner_w = p.size.x - RS_PAD * 2.0; + let top = self.remote_settings_section_top(p); + let divider = |cx: &mut PaintCx<'_>, y: f32| { + cx.backend.fill_rect( + Rect { + origin: Point2D::new(left, y), + size: Point2D::new(inner_w, 1.0), + }, + alpha(t.border, 0.50), + ); + }; + divider(cx, top); + // 领先 N · 落后 N + let ab = self + .t("git.remote.aheadBehind") + .replace("{{ahead}}", &self.state.ahead.to_string()) + .replace("{{behind}}", &self.state.behind.to_string()); + self.text(cx, &ab, left, top + 21.0, 11.0, t.muted_foreground); + // 获取 button (enabled when a remote exists). self.paint_button( cx, - login, - self.t("git.panel.login"), - !self.state.https_draft.trim().is_empty(), + self.remote_settings_fetch_rect(panel_rect), + self.t("git.remote.fetchButton"), + !self.state.remotes.is_empty(), false, ); + // Credentials row. + let d2 = top + 8.0 + 30.0; + divider(cx, d2); + self.text( + cx, + self.t("git.remote.storedAuthLabel"), + left, + d2 + 21.0, + 11.0, + t.muted_foreground, + ); + let status = if self.state.remote_host.is_none() { + self.t("git.remote.storedAuth.noHost") + } else { + match self.state.stored_auth.as_str() { + "ssh" => self.t("git.remote.storedAuth.ssh"), + "token" => self.t("git.remote.storedAuth.token"), + _ => self.t("git.remote.storedAuth.none"), + } + }; + let sw = cx.backend.measure_text(status, 11.0); + self.text( + cx, + status, + p.origin.x + p.size.x - RS_PAD - sw, + d2 + 21.0, + 11.0, + t.foreground, + ); } /// Hit-test the remote-settings subview. @@ -648,7 +809,7 @@ impl GitPanel<'_> { if !contains(p, point) { return None; } - let (back, url_input, set, https_input, login) = self.remote_settings_rects(panel_rect); + let (back, url_input, set) = self.remote_settings_rects(panel_rect); if contains(back, point) { return Some(GitPanelHit::OverflowBack); } @@ -658,18 +819,17 @@ impl GitPanel<'_> { if contains(set, point) { return Some(GitPanelHit::SetRemote); } - if contains(https_input, point) { - return Some(GitPanelHit::HttpsInput); - } - if contains(login, point) { - return Some(GitPanelHit::SetHttpsAuth); + if !self.state.remotes.is_empty() + && contains(self.remote_settings_fetch_rect(panel_rect), point) + { + return Some(GitPanelHit::FetchRemote); } Some(GitPanelHit::Inside) } /// A simple popover input box — rounded field + draft / placeholder /// text + a blink-free caret bar when focused. - fn paint_menu_input( + pub(super) fn paint_menu_input( &self, cx: &mut PaintCx<'_>, rect: Rect, @@ -700,7 +860,7 @@ impl GitPanel<'_> { // Blink the caret on the shared commit-caret cadence (the host // wakes the loop for this input's focus), instead of a static `|`. let blink = - jian_core::anim::blink_visible(self.now_ms, self.state.commit_caret_anchor_ms, 530); + jian_core::anim::blink_visible(self.now_ms, self.state.commit_caret_anchor_ms, 500); let shown = if focused && blink { format!("{shown}|") } else { diff --git a/crates/op-editor-ui/src/widgets/git_panel_ready.rs b/crates/op-editor-ui/src/widgets/git_panel_ready.rs index 051e0ea85..4c039ded7 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_ready.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_ready.rs @@ -15,6 +15,7 @@ use crate::widgets::git_panel::{contains, truncate, GitPanel, GitPanelHit, PAD}; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; +use op_editor_core::{CommitDiffSummary, CommitDiffView}; /// Header bar height (TS `px-2.5 py-1.5` around a 28 px `icon-sm` /// button = 6 + 28 + 6 = 40 px row). @@ -31,19 +32,28 @@ const READY_PAD: f32 = 10.0; /// (`h-6` 24 px + `pb-1.5` 6 px = 30 px) ≈ 83 px. const COMMIT_TOP: f32 = HEADER_H + 12.0; const COMMIT_H: f32 = 83.0; -/// History section. The commit box owns the only divider below it. The -/// TS `git-panel-history-list` has NO section header — the timeline (or -/// the empty `git.history.empty` line) sits directly under that divider -/// with the list's `py-1.5` rhythm. -const HISTORY_FIRST: f32 = COMMIT_TOP + COMMIT_H + 24.0; +/// Height of the commit-signature form (`提交署名`) when it replaces the +/// commit box — heading + subheading + name + email inputs + button row. +const AUTHOR_FORM_H: f32 = 168.0; const ROW_H: f32 = 26.0; const MAX_COMMITS: usize = 8; +/// Extra empty body height appended below the recent-commit history so the +/// ready panel reads roomy (TS fixed-height feel) instead of hugging a short +/// history. Constant, so the panel still grows with more commits. +const READY_FILL: f32 = 200.0; const SUMMARY_MAX: usize = 34; -/// Height of the inline commit-detail card (里程碑详情 title + a diff -/// status line + a restore/copy-hash button row) inserted under an -/// expanded commit row (TS `HistoryMilestoneRow` detail block). Pushes -/// later rows down. -const CARD_H: f32 = 84.0; +/// Base height of the inline commit-detail card (里程碑详情 title + one +/// status/summary line + a restore/copy-hash button row) under an expanded +/// commit row (TS `HistoryMilestoneRow` detail block). Grows by one +/// [`PATCH_ROW_H`] per rendered patch line. The extra height over the button +/// row (anchored at `height - 52`) is bottom padding so the next commit row +/// stays well clear of the buttons. +const CARD_BASE_H: f32 = 104.0; +/// Per-patch-line height in the expanded diff list. +const PATCH_ROW_H: f32 = 14.0; +/// Cap on patch lines drawn in the card (TS scrolls a `max-h-24` list; +/// the summary counts still report the full totals above the list). +const MAX_PATCH_ROWS: usize = 6; /// Per-char advance heuristic for the branch label (keeps paint + /// hit-test aligned without measuring text). const BRANCH_CHAR_W: f32 = 7.5; @@ -65,21 +75,47 @@ impl GitPanel<'_> { && self.state.merge_resolve.is_none() } - /// The panel height for the ready view — header + commit box + the - /// recent-commit rows (at least one placeholder row). + /// Y of the first history row — below the commit box, or below the taller + /// commit-signature form when it has replaced the box. + fn history_first(&self) -> f32 { + let box_h = if self.state.author_prompt { + AUTHOR_FORM_H + } else { + COMMIT_H + }; + COMMIT_TOP + box_h + 24.0 + } + + /// The panel height for the ready view — header + commit box (or signature + /// form) + the recent-commit rows, plus [`READY_FILL`] body space. pub(super) fn ready_height(&self) -> f32 { let rows = self.state.recent_commits.len().clamp(1, MAX_COMMITS); - HISTORY_FIRST + rows as f32 * ROW_H + PAD + self.expanded_card_extra() + self.history_first() + rows as f32 * ROW_H + PAD + self.expanded_card_extra() + READY_FILL + } + + /// Number of patch lines the expanded card will draw (0 unless the diff + /// is `Ready`, capped at [`MAX_PATCH_ROWS`]). + fn card_patch_rows(&self) -> usize { + match &self.state.expanded_commit_diff { + Some(CommitDiffView::Ready(s)) => s.patches.len().min(MAX_PATCH_ROWS), + _ => 0, + } + } + + /// Total height of the open card — base chrome plus one row per drawn + /// patch line. Shared by paint + the hit-test walk so they agree. + fn expanded_card_height(&self) -> f32 { + CARD_BASE_H + self.card_patch_rows() as f32 * PATCH_ROW_H } /// Extra height contributed by an open inline commit-detail card — - /// `CARD_H` when a valid row is expanded, else 0. Shared by - /// [`GitPanel::ready_height`] + the history paint / hit-test walk so - /// they stay in lockstep. + /// [`Self::expanded_card_height`] when a valid row is expanded, else 0. + /// Shared by [`GitPanel::ready_height`] + the history paint / hit-test + /// walk so they stay in lockstep. fn expanded_card_extra(&self) -> f32 { let n = self.state.recent_commits.len().min(MAX_COMMITS); match self.state.expanded_commit { - Some(e) if e < n => CARD_H, + Some(e) if e < n => self.expanded_card_height(), _ => 0.0, } } @@ -89,16 +125,20 @@ impl GitPanel<'_> { fn expand_offset_before(&self, i: usize) -> f32 { let n = self.state.recent_commits.len().min(MAX_COMMITS); match self.state.expanded_commit { - Some(e) if e < i && e < n => CARD_H, + Some(e) if e < i && e < n => self.expanded_card_height(), _ => 0.0, } } /// `(恢复, 复制哈希)` button rects for the inline card whose top edge /// is `card_top`. Backend-free fixed widths keep paint + hit aligned. + /// The button row is bottom-anchored so a growing patch list pushes it + /// down in lockstep with paint. fn commit_card_button_rects(&self, rect: Rect, card_top: f32) -> (Rect, Rect) { - // Title at +18, diff line at +40, button row at +52 (h24) → 84. - let btn_y = card_top + 52.0; + // Button row sits at content position `52 + patches`; the rest of the + // card height (CARD_BASE_H - 52 = 52px) is bottom padding so the next + // commit row keeps well clear of the buttons. + let btn_y = card_top + self.expanded_card_height() - 52.0; let h = 24.0; let x = rect.origin.x + 40.0; // align with the message column (`pl-10`) let restore = Rect { @@ -120,7 +160,7 @@ impl GitPanel<'_> { let e = self.state.expanded_commit.filter(|&e| e < n)?; // Row `e`'s text baseline (no prior card offsets — only one card // can be open) then `+ ROW_H` to the card top, matching paint. - let card_top = rect.origin.y + HISTORY_FIRST + (e as f32 + 1.0) * ROW_H - 6.0; + let card_top = rect.origin.y + self.history_first() + (e as f32 + 1.0) * ROW_H - 6.0; Some(self.commit_card_button_rects(rect, card_top)) } @@ -210,6 +250,70 @@ impl GitPanel<'_> { } /// Paint the ready view. + /// `(name_input, email_input, save, cancel)` rects of the commit-signature + /// form, shared by paint + hit-test. + pub(super) fn author_form_rects(&self, rect: Rect) -> (Rect, Rect, Rect, Rect) { + let left = rect.origin.x + READY_PAD; + let inner_w = rect.size.x - READY_PAD * 2.0; + let top = rect.origin.y + COMMIT_TOP; + let name_input = Rect { + origin: Point2D::new(left, top + 58.0), + size: Point2D::new(inner_w, 28.0), + }; + let email_input = Rect { + origin: Point2D::new(left, top + 102.0), + size: Point2D::new(inner_w, 28.0), + }; + let btn_y = top + 138.0; + let w = 56.0; + let save = Rect { + origin: Point2D::new(rect.origin.x + rect.size.x - READY_PAD - w, btn_y), + size: Point2D::new(w, 26.0), + }; + let cancel = Rect { + origin: Point2D::new(save.origin.x - 8.0 - w, btn_y), + size: Point2D::new(w, 26.0), + }; + (name_input, email_input, save, cancel) + } + + /// Paint the commit-signature form (`提交署名`) in the commit-box area. + fn paint_author_form(&self, cx: &mut PaintCx<'_>, rect: Rect) { + let t = self.theme; + let left = rect.origin.x + READY_PAD; + let top = rect.origin.y + COMMIT_TOP; + self.text(cx, self.t("git.author.heading"), left, top + 16.0, 13.0, t.foreground); + self.text( + cx, + self.t("git.author.subheading"), + left, + top + 36.0, + 11.0, + t.muted_foreground, + ); + let (name_input, email_input, save, cancel) = self.author_form_rects(rect); + self.text(cx, self.t("git.author.nameLabel"), left, top + 54.0, 11.0, t.muted_foreground); + self.paint_menu_input( + cx, + name_input, + &self.state.author_name_draft, + self.t("git.author.namePlaceholder"), + self.state.author_name_focused, + ); + self.text(cx, self.t("git.author.emailLabel"), left, top + 98.0, 11.0, t.muted_foreground); + self.paint_menu_input( + cx, + email_input, + &self.state.author_email_draft, + self.t("git.author.emailPlaceholder"), + self.state.author_email_focused, + ); + let can_save = !self.state.author_name_draft.trim().is_empty() + && self.state.author_email_draft.contains('@'); + self.paint_button(cx, cancel, self.t("git.author.cancel"), true, false); + self.paint_button(cx, save, self.t("git.author.submit"), can_save, true); + } + pub(super) fn paint_ready(&self, cx: &mut PaintCx<'_>, rect: Rect) { let t = self.theme; let top = rect.origin.y; @@ -286,7 +390,10 @@ impl GitPanel<'_> { 1.5, ); - // ── Commit box (TS textarea + milestone button) ── + // ── Commit box / signature form ── + if self.state.author_prompt { + self.paint_author_form(cx, rect); + } else { let box_r = self.ready_commit_box(rect); cx.backend.fill_round_rect(box_r, 8.0, t.card); let border = if self.state.commit_focused { @@ -313,7 +420,7 @@ impl GitPanel<'_> { let baseline = box_r.origin.y + 22.0; self.text(cx, msg, text_x, baseline, 12.0, t.foreground); let blink = - jian_core::anim::blink_visible(self.now_ms, self.state.commit_caret_anchor_ms, 530); + jian_core::anim::blink_visible(self.now_ms, self.state.commit_caret_anchor_ms, 500); if self.state.commit_focused && blink { let caret_x = text_x + cx.backend.measure_text(msg, 12.0) + 1.0; cx.backend.fill_rect( @@ -325,10 +432,25 @@ impl GitPanel<'_> { ); } } - self.paint_milestone_button(cx, self.ready_commit_btn(rect), self.ready_can_commit()); + let btn = self.ready_commit_btn(rect); + self.paint_milestone_button(cx, btn, self.ready_can_commit()); + // "未检测到变更" hint — shown to the left of the button after a + // milestone save was skipped for having no changes (TS-style guard). + if self.state.commit_no_changes { + self.text( + cx, + self.t("git.history.diff.noChanges"), + box_r.origin.x + 4.0, + btn.origin.y + btn.size.y / 2.0 + 4.0, + 11.0, + alpha(t.destructive, 0.90), + ); + } + } + // Divider below the commit box / form, just above the history. cx.backend.fill_rect( Rect { - origin: Point2D::new(rect.origin.x, box_r.origin.y + box_r.size.y + 6.0), + origin: Point2D::new(rect.origin.x, rect.origin.y + self.history_first() - 18.0), size: Point2D::new(width, 1.0), }, alpha(t.border, 0.60), @@ -337,7 +459,7 @@ impl GitPanel<'_> { // ── Recent-commit history (TS `git-panel-history-list`) ── // No section header — the timeline / empty line sits directly // under the commit-box divider. - let mut y = top + HISTORY_FIRST; + let mut y = top + self.history_first(); if self.state.recent_commits.is_empty() { // Empty log → a single centered `git.history.empty` line // (TS `flex items-center justify-center p-6 text-xs @@ -418,48 +540,91 @@ impl GitPanel<'_> { // `ready_commit_card_buttons` lands on the same geometry. if self.state.expanded_commit == Some(i) { let card_top = y - 6.0; - self.paint_commit_card(cx, rect, card_top, commit.is_initial); - y += CARD_H; + self.paint_commit_card(cx, rect, card_top); + y += self.expanded_card_height(); } } } } - /// Paint the inline commit-detail card (里程碑详情) — a muted band - /// with the detail title, a diff status line, and a `恢复` / `复制哈希` - /// button row. TS `HistoryMilestoneRow` detail block. The root commit - /// shows the "no parent to diff against" line; the semantic node-diff - /// summary for later commits is deferred (a separate subsystem). - fn paint_commit_card(&self, cx: &mut PaintCx<'_>, rect: Rect, card_top: f32, is_initial: bool) { + /// Paint the inline commit-detail card (里程碑详情) — a muted band with + /// the detail title, the semantic diff (TS `GitPanelHistoryDiff`: a + /// summary row + an `op nodeId` patch list, or a loading / initial / + /// no-changes / error line), and a `恢复` / `复制哈希` button row. + fn paint_commit_card(&self, cx: &mut PaintCx<'_>, rect: Rect, card_top: f32) { let t = self.theme; + let h = self.expanded_card_height(); cx.backend.fill_rect( Rect { origin: Point2D::new(rect.origin.x, card_top), - size: Point2D::new(rect.size.x, CARD_H), + size: Point2D::new(rect.size.x, h), }, alpha(t.muted, 0.30), ); + let body_x = rect.origin.x + 40.0; // Title — `text-[11px] font-medium`. self.text( cx, self.t("git.history.milestoneDetailTitle"), - rect.origin.x + 40.0, + body_x, card_top + 18.0, 11.0, t.foreground, ); - // Diff status line. The root commit has no parent to diff against - // (TS `git.history.diff.initialCommit`); non-root commits leave it - // blank until the semantic node-diff summary lands. - if is_initial { - self.text( - cx, - self.t("git.history.diff.initialCommit"), - rect.origin.x + 40.0, - card_top + 40.0, - 11.0, - alpha(t.muted_foreground, 0.85), - ); + // Diff body (TS `GitPanelHistoryDiff` states). + let status_y = card_top + 38.0; + match &self.state.expanded_commit_diff { + None | Some(CommitDiffView::Loading) => { + self.text( + cx, + self.t("git.history.diff.loading"), + body_x, + status_y, + 10.0, + alpha(t.muted_foreground, 0.85), + ); + } + Some(CommitDiffView::Initial) => { + self.text( + cx, + self.t("git.history.diff.initialCommit"), + body_x, + status_y, + 10.0, + alpha(t.muted_foreground, 0.85), + ); + } + Some(CommitDiffView::NoChanges) => { + self.text( + cx, + self.t("git.history.diff.noChanges"), + body_x, + status_y, + 10.0, + alpha(t.muted_foreground, 0.85), + ); + } + Some(CommitDiffView::Error(msg)) => { + let label = self.t("git.history.diff.error").replace("{{message}}", msg); + self.text(cx, &label, body_x, status_y, 10.0, t.destructive); + } + Some(CommitDiffView::Ready(summary)) => { + self.paint_diff_summary(cx, summary, body_x, status_y); + // Patch list — one `op nodeId` line each (TS font-mono). + for (k, p) in summary.patches.iter().take(MAX_PATCH_ROWS).enumerate() { + let py = card_top + 54.0 + k as f32 * PATCH_ROW_H; + self.text(cx, &p.op, body_x, py, 10.0, t.foreground); + let opw = cx.backend.measure_text(&p.op, 10.0); + self.text( + cx, + &p.node_id, + body_x + opw + 6.0, + py, + 10.0, + alpha(t.muted_foreground, 0.70), + ); + } + } } let (restore, copy) = self.commit_card_button_rects(rect, card_top); // 恢复 — outline button. @@ -492,6 +657,78 @@ impl GitPanel<'_> { ); } + /// Paint the diff summary row — coloured `framesChanged` / `+added` / + /// `-removed` / `~modified` segments left-to-right (TS `GitPanelHistoryDiff` + /// summary spans). Only non-zero counts render. + fn paint_diff_summary(&self, cx: &mut PaintCx<'_>, s: &CommitDiffSummary, x: f32, y: f32) { + let t = self.theme; + // Build the (label, colour) segments first so the draw loop borrows + // `self` only through `self.text` / the backend measure. + let mut segments: Vec<(String, Color)> = Vec::new(); + if s.frames_changed > 0 { + segments.push(( + self.plural( + "git.history.diff.framesChanged_one", + "git.history.diff.framesChanged_other", + s.frames_changed, + ), + t.muted_foreground, + )); + } + if s.nodes_added > 0 { + segments.push(( + format!( + "+{}", + self.plural( + "git.history.diff.nodesAdded_one", + "git.history.diff.nodesAdded_other", + s.nodes_added, + ) + ), + t.primary, + )); + } + if s.nodes_removed > 0 { + segments.push(( + format!( + "-{}", + self.plural( + "git.history.diff.nodesRemoved_one", + "git.history.diff.nodesRemoved_other", + s.nodes_removed, + ) + ), + t.destructive, + )); + } + if s.nodes_modified > 0 { + segments.push(( + format!( + "~{}", + self.plural( + "git.history.diff.nodesModified_one", + "git.history.diff.nodesModified_other", + s.nodes_modified, + ) + ), + t.muted_foreground, + )); + } + let mut cur = x; + for (label, color) in &segments { + self.text(cx, label, cur, y, 10.0, *color); + cur += cx.backend.measure_text(label, 10.0) + 10.0; + } + } + + /// Pick the `_one` / `_other` plural form (TS i18next English rule: 1 → + /// one) and substitute `{{count}}`. Both keys are `&'static` so they + /// satisfy [`GitPanel::t`]'s static-key contract. + fn plural(&self, one_key: &'static str, other_key: &'static str, count: u32) -> String { + let key = if count == 1 { one_key } else { other_key }; + self.t(key).replace("{{count}}", &count.to_string()) + } + /// One ghost icon button — a faint rounded slot + a centred glyph, /// dimmed when disabled. fn paint_ready_icon(&self, cx: &mut PaintCx<'_>, rect: Rect, icon: Icon, enabled: bool) { @@ -556,8 +793,8 @@ impl GitPanel<'_> { /// hit-test stay aligned. pub(super) fn ready_commit_row_rects(&self, rect: Rect) -> Vec { // +9 centres the 26px click target on the 12px row text whose - // baseline is `HISTORY_FIRST + i*ROW_H` (was +4, bottom-biased). - let first = rect.origin.y + HISTORY_FIRST - ROW_H + 9.0; + // baseline is `history_first() + i*ROW_H` (was +4, bottom-biased). + let first = rect.origin.y + self.history_first() - ROW_H + 9.0; (0..self.state.recent_commits.len().min(MAX_COMMITS)) .map(|i| Rect { origin: Point2D::new( @@ -572,8 +809,24 @@ impl GitPanel<'_> { /// Map a press inside the ready view onto a [`GitPanelHit`]. pub(super) fn ready_hit(&self, rect: Rect, point: Point2D) -> Option { let (pull, push, overflow) = self.ready_header_buttons(rect); - // Save-milestone button first (it sits inside the commit box). - if self.ready_can_commit() && contains(self.ready_commit_btn(rect), point) { + // While the signature form is up, the commit box is replaced by it — + // its fields/buttons own the clicks in that region (header still works). + if self.state.author_prompt { + let (name, email, save, cancel) = self.author_form_rects(rect); + if contains(name, point) { + return Some(GitPanelHit::AuthorNameInput); + } + if contains(email, point) { + return Some(GitPanelHit::AuthorEmailInput); + } + if contains(save, point) { + return Some(GitPanelHit::AuthorSave); + } + if contains(cancel, point) { + return Some(GitPanelHit::AuthorCancel); + } + } else if self.ready_can_commit() && contains(self.ready_commit_btn(rect), point) { + // Save-milestone button (it sits inside the commit box). return Some(GitPanelHit::CommitMilestone); } // Overflow is the right-anchored fixed element — test it before @@ -591,7 +844,7 @@ impl GitPanel<'_> { if self.push_enabled() && contains(push, point) { return Some(GitPanelHit::Push); } - if contains(self.ready_commit_box(rect), point) { + if !self.state.author_prompt && contains(self.ready_commit_box(rect), point) { return Some(GitPanelHit::CommitInput); } // Expanded detail-card buttons win over the rows they sit between. diff --git a/crates/op-editor-ui/src/widgets/git_panel_remotes.rs b/crates/op-editor-ui/src/widgets/git_panel_remotes.rs index 284863418..742f667cd 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_remotes.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_remotes.rs @@ -176,7 +176,7 @@ impl GitPanel<'_> { self.theme.muted_foreground, ); } else { - // Blink the caret on the same 530 ms cadence as the commit + // Blink the caret on the same 500 ms cadence as the commit // box (shared `commit_caret_anchor_ms`), rather than a static // `|`. The host wakes the loop while these inputs are focused // so the toggle actually animates. @@ -184,7 +184,7 @@ impl GitPanel<'_> { && jian_core::anim::blink_visible( self.now_ms, self.state.commit_caret_anchor_ms, - 530, + 500, ); let line = if caret { format!("{shown}|") diff --git a/crates/op-editor-ui/src/widgets/git_panel_ssh_keys.rs b/crates/op-editor-ui/src/widgets/git_panel_ssh_keys.rs new file mode 100644 index 000000000..e2823b591 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel_ssh_keys.rs @@ -0,0 +1,167 @@ +//! SSH-keys subview for [`GitPanel`] — a port of the TS `GitPanelSshKeys` +//! list view (`git-panel-ssh-keys.tsx`). +//! +//! Reached from the overflow `…` menu's "SSH 密钥" entry. Lists the stored +//! SSH keys (host-enumerated into `ssh_keys`) with an empty state, and offers +//! "导入现有密钥" + "生成新密钥". Rendered as an overflow popover subview +//! (`GitOverflowView::SshKeys`), anchored + hit-tested like remote-settings. + +use crate::widgets::git_panel::{contains, truncate, GitPanel, GitPanelHit, PAD}; +use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, Rect}; + +const SSH_W: f32 = 300.0; +const SSH_PAD: f32 = 12.0; +const SSH_BACK_H: f32 = 24.0; +const SSH_HEADING_H: f32 = 16.0; +const SSH_KEY_ROW_H: f32 = 24.0; +const SSH_BTN_H: f32 = 28.0; +const SSH_MAX_KEYS: usize = 5; + +impl GitPanel<'_> { + fn ssh_rows(&self) -> usize { + self.state.ssh_keys.len().min(SSH_MAX_KEYS) + } + + /// The SSH-keys subview popover rect, anchored below the overflow `…`. + pub(super) fn ssh_keys_panel(&self, panel_rect: Rect) -> Rect { + let (_, _, overflow_btn) = self.ready_header_buttons(panel_rect); + let w = SSH_W.min(panel_rect.size.x - PAD * 2.0); + let body = if self.state.ssh_keys.is_empty() { + 22.0 + } else { + self.ssh_rows() as f32 * SSH_KEY_ROW_H + }; + let h = SSH_PAD * 2.0 + SSH_BACK_H + 4.0 + SSH_HEADING_H + 8.0 + body + 10.0 + SSH_BTN_H; + let right = overflow_btn.origin.x + overflow_btn.size.x; + Rect { + origin: Point2D::new(right - w, overflow_btn.origin.y + overflow_btn.size.y + 4.0), + size: Point2D::new(w, h), + } + } + + /// `(import, generate)` footer button rects. + pub(super) fn ssh_keys_footer_rects(&self, panel_rect: Rect) -> (Rect, Rect) { + let p = self.ssh_keys_panel(panel_rect); + let btn_y = p.origin.y + p.size.y - SSH_PAD - SSH_BTN_H; + let gen_w = 100.0; + let imp_w = 108.0; + let generate = Rect { + origin: Point2D::new(p.origin.x + p.size.x - SSH_PAD - gen_w, btn_y), + size: Point2D::new(gen_w, SSH_BTN_H), + }; + let import = Rect { + origin: Point2D::new(generate.origin.x - 8.0 - imp_w, btn_y), + size: Point2D::new(imp_w, SSH_BTN_H), + }; + (import, generate) + } + + /// Paint the SSH-keys subview. + pub(super) fn paint_ssh_keys(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { + let t = self.theme; + let p = self.ssh_keys_panel(panel_rect); + cx.backend.fill_round_rect(p, 8.0, t.popover); + cx.backend.stroke_round_rect(p, 8.0, t.border, 1.0); + let left = p.origin.x + SSH_PAD; + // ‹ Back header. + let back_y = p.origin.y + SSH_PAD; + draw_icon( + cx.backend, + Icon::ChevronLeft, + Point2D::new(left, back_y + (SSH_BACK_H - 13.0) / 2.0), + 13.0, + t.muted_foreground, + 1.75, + ); + self.text( + cx, + self.t("git.ssh.back"), + left + 18.0, + back_y + SSH_BACK_H / 2.0 + 4.0, + 12.0, + t.foreground, + ); + // Heading. + let heading_y = back_y + SSH_BACK_H + 4.0; + self.text( + cx, + self.t("git.ssh.heading"), + left, + heading_y + 12.0, + 11.0, + t.muted_foreground, + ); + // Body — empty hint or key list. + let body_top = heading_y + SSH_HEADING_H + 8.0; + if self.state.ssh_keys.is_empty() { + self.text( + cx, + self.t("git.ssh.emptyList"), + left, + body_top + 12.0, + 11.0, + alpha(t.muted_foreground, 0.85), + ); + } else { + let inner_w = p.size.x - SSH_PAD * 2.0; + for (i, name) in self.state.ssh_keys.iter().take(SSH_MAX_KEYS).enumerate() { + let y = body_top + i as f32 * SSH_KEY_ROW_H; + draw_icon( + cx.backend, + Icon::Key, + Point2D::new(left, y + (SSH_KEY_ROW_H - 13.0) / 2.0), + 13.0, + t.muted_foreground, + 1.5, + ); + let chars = ((inner_w - 22.0) / 7.0) as usize; + self.text( + cx, + &truncate(name, chars.max(6)), + left + 22.0, + y + SSH_KEY_ROW_H / 2.0 + 4.0, + 12.0, + t.foreground, + ); + } + } + // Footer — 导入现有密钥 (outline) + 生成新密钥 (primary). + let (import, generate) = self.ssh_keys_footer_rects(panel_rect); + self.paint_button(cx, import, self.t("git.ssh.importAction"), true, false); + self.paint_button(cx, generate, self.t("git.ssh.generateAction"), true, true); + } + + /// Hit-test the SSH-keys subview. + pub(super) fn ssh_keys_hit(&self, panel_rect: Rect, point: Point2D) -> Option { + let p = self.ssh_keys_panel(panel_rect); + if !contains(p, point) { + return None; + } + // ‹ Back row. + let back = Rect { + origin: Point2D::new(p.origin.x + SSH_PAD, p.origin.y + SSH_PAD), + size: Point2D::new(p.size.x - SSH_PAD * 2.0, SSH_BACK_H), + }; + if contains(back, point) { + return Some(GitPanelHit::OverflowBack); + } + let (import, generate) = self.ssh_keys_footer_rects(panel_rect); + if contains(import, point) { + return Some(GitPanelHit::SshImportKey); + } + if contains(generate, point) { + return Some(GitPanelHit::SshGenerateKey); + } + Some(GitPanelHit::Inside) + } +} + +/// A colour at `factor` of its current alpha (Tailwind `/NN`). +fn alpha(c: Color, factor: f32) -> Color { + Color { + a: c.a * factor, + ..c + } +} diff --git a/crates/op-editor-ui/src/widgets/git_panel_tests.rs b/crates/op-editor-ui/src/widgets/git_panel_tests.rs index 59a46bbb3..c1a190c2d 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_tests.rs @@ -4,9 +4,9 @@ use crate::widgets::git_panel::*; use crate::{Point2D, Rect}; use op_editor_core::{ - CloneField, CloneFormState, EditorState, GitBranchPickerMode, GitCommitSummary, GitDiffView, - GitFileEntry, GitOverflowView, GitPanelState, MergeConflictRow, MergeResolveFile, - MergeResolveState, + CloneField, CloneFormState, EditorState, GitBranchPickerMode, GitCandidateFile, + GitCommitSummary, GitDiffView, GitFileEntry, GitOverflowView, GitPanelState, MergeConflictRow, + MergeResolveFile, MergeResolveState, }; fn state_with(panel: GitPanelState) -> EditorState { @@ -241,8 +241,9 @@ fn expanded_commit_card_maps_restore_and_copy_and_shifts_later_rows() { Some(GitPanelHit::CopyCommitHash(0)) ); // Row 1 shifted down by exactly the card height; the panel grew too. + // (No diff loaded in this state → base card height, no patch rows.) let row1_expanded = panel.ready_commit_row_rects(rect)[1].origin.y; - assert!((row1_expanded - row1_collapsed - 84.0).abs() < 0.5); + assert!((row1_expanded - row1_collapsed - 104.0).abs() < 0.5); assert!(panel.height() > cp.height()); // The expanded card sits below row 0's click target. let row0 = panel.ready_commit_row_rects(rect)[0]; @@ -413,15 +414,78 @@ fn overflow_menu_maps_its_entries() { let panel = GitPanel::for_editor(&s).unwrap(); let rect = panel_rect(&panel); let rows = panel.overflow_row_rects(rect); - assert_eq!(rows.len(), 2); + // TS 5-item menu: switch-tracked / clear-author / remote-settings / + // ssh-keys / close-repo (with two dividers between groups). + assert_eq!(rows.len(), 5); assert_eq!( panel.hit_test(rect, centre(rows[0])), - Some(GitPanelHit::OverflowRemoteSettings) + Some(GitPanelHit::OverflowSwitchTracked) ); assert_eq!( panel.hit_test(rect, centre(rows[1])), + Some(GitPanelHit::OverflowClearAuthor) + ); + assert_eq!( + panel.hit_test(rect, centre(rows[2])), + Some(GitPanelHit::OverflowRemoteSettings) + ); + assert_eq!( + panel.hit_test(rect, centre(rows[3])), Some(GitPanelHit::OverflowSshKeys) ); + assert_eq!( + panel.hit_test(rect, centre(rows[4])), + Some(GitPanelHit::OverflowCloseRepo) + ); +} + +#[test] +fn tracked_picker_maps_rows_and_actions() { + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + overflow_open: true, + overflow_view: GitOverflowView::TrackedPicker, + candidate_files: vec![ + GitCandidateFile { + path: "/r/a.op".into(), + relative_path: "a.op".into(), + milestone_count: 2, + last_commit_time: "1h".into(), + last_commit_message: Some("hi".into()), + }, + GitCandidateFile { + path: "/r/b.op".into(), + relative_path: "b.op".into(), + milestone_count: 0, + last_commit_time: String::new(), + last_commit_message: None, + }, + ], + tracked_picker_selected: Some(0), + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let rows = panel.tracked_picker_row_rects(rect); + assert_eq!(rows.len(), 2); + assert_eq!( + panel.hit_test(rect, centre(rows[1])), + Some(GitPanelHit::TrackedPickerRow(1)) + ); + // With a selection, both bind buttons are live; Back always is. + let (back, bind, open) = panel.tracked_picker_footer_rects(rect); + assert_eq!( + panel.hit_test(rect, centre(back)), + Some(GitPanelHit::TrackedPickerBack) + ); + assert_eq!( + panel.hit_test(rect, centre(bind)), + Some(GitPanelHit::TrackedPickerBind) + ); + assert_eq!( + panel.hit_test(rect, centre(open)), + Some(GitPanelHit::TrackedPickerBindOpen) + ); } #[test] @@ -430,11 +494,12 @@ fn overflow_remote_settings_subview_maps_inputs_and_back() { branch: Some("main".to_string()), overflow_open: true, overflow_view: GitOverflowView::RemoteSettings, + remotes: vec!["origin → https://example.com/r.git".to_string()], ..open_repo() }); let panel = GitPanel::for_editor(&s).unwrap(); let rect = panel_rect(&panel); - let (back, url, set, https, login) = panel.remote_settings_rects(rect); + let (back, url, set) = panel.remote_settings_rects(rect); assert_eq!( panel.hit_test(rect, centre(back)), Some(GitPanelHit::OverflowBack) @@ -447,13 +512,12 @@ fn overflow_remote_settings_subview_maps_inputs_and_back() { panel.hit_test(rect, centre(set)), Some(GitPanelHit::SetRemote) ); + // The TS remote-settings has no HTTPS-credential input — fetch is the + // next interactive element (a remote is configured in this state). + let fetch = panel.remote_settings_fetch_rect(rect); assert_eq!( - panel.hit_test(rect, centre(https)), - Some(GitPanelHit::HttpsInput) - ); - assert_eq!( - panel.hit_test(rect, centre(login)), - Some(GitPanelHit::SetHttpsAuth) + panel.hit_test(rect, centre(fetch)), + Some(GitPanelHit::FetchRemote) ); } diff --git a/crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs b/crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs new file mode 100644 index 000000000..9296ae14a --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs @@ -0,0 +1,323 @@ +//! Tracked-file picker subview for [`GitPanel`] — a port of the TS +//! `GitPanelTrackedPicker` (`git-panel-tracked-picker.tsx`). +//! +//! Reached from the overflow `…` menu's "切换跟踪文件" entry. Lists the +//! repo's `.op` candidates (host-enumerated into `candidate_files`), lets the +//! user pick one, and binds it as the tracked file (optionally opening it). +//! +//! Rendered as an overflow popover subview (`GitOverflowView::TrackedPicker`), +//! anchored + hit-tested like the remote-settings subview. Geometry is shared +//! between paint + hit-test through the `*_rects` helpers. + +use crate::widgets::git_panel::{contains, truncate, GitPanel, GitPanelHit, PAD}; +use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, Rect}; + +/// Picker popover width (TS body `w-80` ≈ 320, clamped to the panel). +const TP_W: f32 = 300.0; +/// Inner padding (TS `p-4`). +const TP_PAD: f32 = 12.0; +/// Uppercase header row height. +const TP_HEADER_H: f32 = 16.0; +/// One candidate row (TS bordered card: icon box + title + subtitle). +const TP_ROW_H: f32 = 46.0; +/// Gap between candidate rows (TS `gap-1.5`). +const TP_ROW_GAP: f32 = 6.0; +/// Footer action-bar height. +const TP_FOOTER_H: f32 = 28.0; +/// Gap between the row list and the footer. +const TP_SECTION_GAP: f32 = 10.0; +/// Max candidate rows drawn before the list is clamped. +const TP_MAX_ROWS: usize = 4; +/// Empty-state card height (heading + body + close button). +const TP_EMPTY_H: f32 = 132.0; + +impl GitPanel<'_> { + /// Number of candidate rows the picker will draw (clamped). + fn picker_rows(&self) -> usize { + self.state.candidate_files.len().min(TP_MAX_ROWS) + } + + /// The tracked-file picker popover rect, anchored below the overflow `…`. + pub(super) fn tracked_picker_panel(&self, panel_rect: Rect) -> Rect { + let (_, _, overflow_btn) = self.ready_header_buttons(panel_rect); + let w = TP_W.min(panel_rect.size.x - PAD * 2.0); + let h = if self.state.candidate_files.is_empty() { + TP_EMPTY_H + } else { + let n = self.picker_rows() as f32; + TP_PAD * 2.0 + + TP_HEADER_H + + 8.0 + + n * TP_ROW_H + + (n - 1.0).max(0.0) * TP_ROW_GAP + + TP_SECTION_GAP + + TP_FOOTER_H + }; + let right = overflow_btn.origin.x + overflow_btn.size.x; + Rect { + origin: Point2D::new(right - w, overflow_btn.origin.y + overflow_btn.size.y + 4.0), + size: Point2D::new(w, h), + } + } + + /// One clickable rect per candidate row (list mode). + pub(super) fn tracked_picker_row_rects(&self, panel_rect: Rect) -> Vec { + let p = self.tracked_picker_panel(panel_rect); + let left = p.origin.x + TP_PAD; + let inner_w = p.size.x - TP_PAD * 2.0; + let first = p.origin.y + TP_PAD + TP_HEADER_H + 8.0; + (0..self.picker_rows()) + .map(|i| Rect { + origin: Point2D::new(left, first + i as f32 * (TP_ROW_H + TP_ROW_GAP)), + size: Point2D::new(inner_w, TP_ROW_H), + }) + .collect() + } + + /// `(back, bind, bind_open)` footer button rects (list mode). + pub(super) fn tracked_picker_footer_rects(&self, panel_rect: Rect) -> (Rect, Rect, Rect) { + let p = self.tracked_picker_panel(panel_rect); + let left = p.origin.x + TP_PAD; + let btn_y = p.origin.y + p.size.y - TP_PAD - TP_FOOTER_H; + let back = Rect { + origin: Point2D::new(left, btn_y), + size: Point2D::new(48.0, TP_FOOTER_H), + }; + let open_w = 78.0; + let bind_w = 70.0; + let open = Rect { + origin: Point2D::new(p.origin.x + p.size.x - TP_PAD - open_w, btn_y), + size: Point2D::new(open_w, TP_FOOTER_H), + }; + let bind = Rect { + origin: Point2D::new(open.origin.x - 8.0 - bind_w, btn_y), + size: Point2D::new(bind_w, TP_FOOTER_H), + }; + (back, bind, open) + } + + /// Empty-state "Close panel" button rect. + fn tracked_picker_empty_close(&self, panel_rect: Rect) -> Rect { + let p = self.tracked_picker_panel(panel_rect); + Rect { + origin: Point2D::new( + p.origin.x + (p.size.x - 96.0) / 2.0, + p.origin.y + p.size.y - TP_PAD - 26.0, + ), + size: Point2D::new(96.0, 26.0), + } + } + + /// Paint the tracked-file picker subview. + pub(super) fn paint_tracked_picker(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { + let t = self.theme; + let p = self.tracked_picker_panel(panel_rect); + cx.backend.fill_round_rect(p, 8.0, t.popover); + cx.backend.stroke_round_rect(p, 8.0, t.border, 1.0); + + if self.state.candidate_files.is_empty() { + self.paint_picker_empty(cx, panel_rect); + return; + } + + // Header — "{{count}} .op files in this repo:". + let header = self + .t("git.picker.heading") + .replace("{{count}}", &self.state.candidate_files.len().to_string()); + self.text( + cx, + &header, + p.origin.x + TP_PAD, + p.origin.y + TP_PAD + 12.0, + 11.0, + t.muted_foreground, + ); + + let rows = self.tracked_picker_row_rects(panel_rect); + for (i, (c, row)) in self + .state + .candidate_files + .iter() + .zip(rows.iter()) + .enumerate() + { + let selected = self.state.tracked_picker_selected == Some(i); + let border = if selected { + t.primary + } else { + alpha(t.border, 0.70) + }; + let bg = if selected { + alpha(t.primary, 0.06) + } else { + t.card + }; + cx.backend.fill_round_rect(*row, 8.0, bg); + cx.backend.stroke_round_rect(*row, 8.0, border, 1.0); + // Leading icon — Check when selected, else FileText. + let icon = if selected { + Icon::Check + } else { + Icon::FileText + }; + let icon_color = if selected { + t.primary + } else { + t.muted_foreground + }; + draw_icon( + cx.backend, + icon, + Point2D::new( + row.origin.x + 10.0, + row.origin.y + (row.size.y - 14.0) / 2.0, + ), + 14.0, + icon_color, + 1.75, + ); + // Right-aligned milestone label. + let meta = if c.milestone_count == 0 { + self.t("git.picker.noHistory").to_string() + } else { + self.t("git.picker.milestoneCount") + .replace("{{count}}", &c.milestone_count.to_string()) + }; + let meta_w = cx.backend.measure_text(&meta, 10.0); + let meta_x = row.origin.x + row.size.x - 10.0 - meta_w; + self.text( + cx, + &meta, + meta_x, + row.origin.y + 17.0, + 10.0, + t.muted_foreground, + ); + // Title — repo-relative path (truncated to space left of the meta). + let title_x = row.origin.x + 38.0; + let title_space = (meta_x - title_x - 6.0).max(0.0); + let title_chars = (title_space / 7.0) as usize; + self.text( + cx, + &truncate(&c.relative_path, title_chars.max(4)), + title_x, + row.origin.y + 17.0, + 12.0, + t.foreground, + ); + // Subtitle — "{{message}} · {{time}}" when there's a last commit. + if let Some(msg) = &c.last_commit_message { + let sub = self + .t("git.picker.lastCommit") + .replace("{{message}}", msg) + .replace("{{time}}", &c.last_commit_time); + let sub_chars = ((row.size.x - 48.0) / 6.0) as usize; + self.text( + cx, + &truncate(&sub, sub_chars.max(4)), + title_x, + row.origin.y + 34.0, + 10.0, + alpha(t.muted_foreground, 0.80), + ); + } + } + + // Footer — back (ghost) + Track / Track-and-open buttons. + let (back, bind, open) = self.tracked_picker_footer_rects(panel_rect); + self.text( + cx, + self.t("git.picker.back"), + back.origin.x, + back.origin.y + back.size.y / 2.0 + 4.0, + 11.0, + t.foreground, + ); + let enabled = self.state.tracked_picker_selected.is_some(); + self.paint_button(cx, bind, self.t("git.picker.bindButton"), enabled, false); + self.paint_button( + cx, + open, + self.t("git.picker.bindAndOpenButton"), + enabled, + true, + ); + } + + /// Paint the empty-state card (no `.op` candidates). + fn paint_picker_empty(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { + let t = self.theme; + let p = self.tracked_picker_panel(panel_rect); + let mid = p.origin.x + p.size.x / 2.0; + let heading = self.t("git.picker.empty.heading"); + let hw = cx.backend.measure_text(heading, 13.0); + self.text( + cx, + heading, + mid - hw / 2.0, + p.origin.y + TP_PAD + 22.0, + 13.0, + t.foreground, + ); + let body = self.t("git.picker.empty.body"); + let body_chars = ((p.size.x - TP_PAD * 2.0) / 6.0) as usize; + let body = truncate(body, body_chars.max(8)); + let bw = cx.backend.measure_text(&body, 11.0); + self.text( + cx, + &body, + mid - bw / 2.0, + p.origin.y + TP_PAD + 46.0, + 11.0, + t.muted_foreground, + ); + let close = self.tracked_picker_empty_close(panel_rect); + self.paint_button(cx, close, self.t("git.picker.empty.close"), true, false); + } + + /// Hit-test the tracked-file picker subview. + pub(super) fn tracked_picker_hit( + &self, + panel_rect: Rect, + point: Point2D, + ) -> Option { + let p = self.tracked_picker_panel(panel_rect); + if !contains(p, point) { + return None; + } + if self.state.candidate_files.is_empty() { + if contains(self.tracked_picker_empty_close(panel_rect), point) { + return Some(GitPanelHit::TrackedPickerBack); + } + return Some(GitPanelHit::Inside); + } + let (back, bind, open) = self.tracked_picker_footer_rects(panel_rect); + if contains(back, point) { + return Some(GitPanelHit::TrackedPickerBack); + } + if self.state.tracked_picker_selected.is_some() { + if contains(bind, point) { + return Some(GitPanelHit::TrackedPickerBind); + } + if contains(open, point) { + return Some(GitPanelHit::TrackedPickerBindOpen); + } + } + for (i, row) in self.tracked_picker_row_rects(panel_rect).iter().enumerate() { + if contains(*row, point) { + return Some(GitPanelHit::TrackedPickerRow(i)); + } + } + Some(GitPanelHit::Inside) + } +} + +/// A colour at `factor` of its current alpha (Tailwind `/NN`). +fn alpha(c: Color, factor: f32) -> Color { + Color { + a: c.a * factor, + ..c + } +} diff --git a/crates/op-editor-ui/src/widgets/icons.rs b/crates/op-editor-ui/src/widgets/icons.rs index cbd521eeb..1b7c8316e 100644 --- a/crates/op-editor-ui/src/widgets/icons.rs +++ b/crates/op-editor-ui/src/widgets/icons.rs @@ -229,6 +229,14 @@ pub enum Icon { XCircle, /// Lucide `file-text.svg` — recent file rows. FileText, + /// Lucide `file-search.svg` — git overflow "switch tracked file". + FileSearch, + /// Lucide `user-x.svg` — git overflow "clear commit author". + UserX, + /// Lucide `key.svg` — git overflow "SSH keys". + Key, + /// Lucide `log-out.svg` — git overflow "close repository". + LogOut, /// Lucide `align-start-vertical` — align selection's left edges. AlignLeft, /// Lucide `align-center-vertical` — align selection horizontal centers. @@ -319,6 +327,10 @@ impl Icon { Icon::Save => SAVE, Icon::Download => DOWNLOAD, Icon::FileText => FILE_TEXT, + Icon::FileSearch => FILE_SEARCH, + Icon::UserX => USER_X, + Icon::Key => KEY, + Icon::LogOut => LOG_OUT, Icon::Mail => MAIL, Icon::Smartphone => SMARTPHONE, Icon::Chrome => CHROME, @@ -434,6 +446,10 @@ impl Icon { "save" => Icon::Save, "download" => Icon::Download, "file-text" => Icon::FileText, + "file-search" => Icon::FileSearch, + "user-x" => Icon::UserX, + "key" => Icon::Key, + "log-out" | "logout" => Icon::LogOut, "folder-open" | "folder" => Icon::FolderOpen, "git-branch" | "git" => Icon::GitBranch, "history" | "clock-history" => Icon::History, diff --git a/crates/op-editor-ui/src/widgets/icons_data.rs b/crates/op-editor-ui/src/widgets/icons_data.rs index 5044be387..bd27e9551 100644 --- a/crates/op-editor-ui/src/widgets/icons_data.rs +++ b/crates/op-editor-ui/src/widgets/icons_data.rs @@ -636,6 +636,38 @@ pub(super) const SETTINGS2: &[&str] = &[ "M4 7a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", ]; +// === Git overflow-menu icons (lucide@0.545.0) === + +// Lucide `file-search.svg` — circle cx=5 cy=14 r=3 → two-arc path. +pub(super) const FILE_SEARCH: &[&str] = &[ + "M14 2v4a2 2 0 0 0 2 2h4", + "M4.268 21a2 2 0 0 0 1.727 1H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v3", + "m9 18-1.5-1.5", + "M2 14A3 3 0 1 0 8 14A3 3 0 1 0 2 14Z", +]; + +// Lucide `user-x.svg` — circle cx=9 cy=7 r=4 + two → "M…L…". +pub(super) const USER_X: &[&str] = &[ + "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", + "M5 7A4 4 0 1 0 13 7A4 4 0 1 0 5 7Z", + "M17 8L22 13", + "M22 8L17 13", +]; + +// Lucide `key.svg` — circle cx=7.5 cy=15.5 r=5.5 → two-arc path. +pub(super) const KEY: &[&str] = &[ + "m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4", + "m21 2-9.6 9.6", + "M2 15.5A5.5 5.5 0 1 0 13 15.5A5.5 5.5 0 1 0 2 15.5Z", +]; + +// Lucide `log-out.svg`. +pub(super) const LOG_OUT: &[&str] = &[ + "m16 17 5-5-5-5", + "M21 12H9", + "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4", +]; + // === Align toolbar icons (lucide@0.545.0) === // Rounded rects expanded to "M…H…A…V…A…H…A…V…A…Z" paths. diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 614c2c674..a08e5a7c0 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -153,10 +153,12 @@ mod git_panel_menus; mod git_panel_ready; mod git_panel_remotes; mod git_panel_resolve; +mod git_panel_ssh_keys; mod git_panel_status; #[cfg(test)] mod git_panel_tests; mod git_panel_text; +mod git_panel_tracked_picker; pub mod icon_picker_panel; pub mod locale_picker; pub mod shape_picker; diff --git a/crates/op-git/src/candidates.rs b/crates/op-git/src/candidates.rs new file mode 100644 index 000000000..04291014f --- /dev/null +++ b/crates/op-git/src/candidates.rs @@ -0,0 +1,134 @@ +//! `.op` candidate enumeration for the tracked-file picker — lists the +//! repository's `.op` files with their commit history stats (TS +//! `gitClient.listCandidates` / `GitCandidateFileInfo`). + +use std::collections::HashMap; +use std::path::Path; + +use crate::{GitError, GitRepo}; + +/// One `.op` candidate file with its history stats. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CandidateOpFile { + /// Absolute path. + pub path: String, + /// Path relative to the repo work-tree root (POSIX separators). + pub relative_path: String, + /// Number of commits that touched this file. + pub milestone_count: u32, + /// Author timestamp (Unix seconds) of the most recent commit touching + /// it, or `None` when it has no history yet. + pub last_commit_secs: Option, + /// First line of that commit's message, if any. + pub last_commit_message: Option, +} + +/// How far back the per-file history scan walks (keeps the picker snappy on +/// large repos; the picker only needs a count + the latest commit). +const SCAN_LIMIT: usize = 300; + +impl GitRepo { + /// Enumerate the `.op` files in the work tree with their history stats. + /// Walks the working directory for `*.op` files (skipping `.git`), then a + /// single bounded revwalk attributes commits to the files they touched. + pub fn candidate_op_files(&self) -> Result, GitError> { + let repo = self.open()?; + let workdir = self.workdir().to_path_buf(); + + // 1. Find every `.op` file under the work tree. + let mut files: HashMap = HashMap::new(); + collect_op_files(&workdir, &workdir, &mut files); + if files.is_empty() { + return Ok(Vec::new()); + } + + // 2. One bounded revwalk; per commit, diff against its first parent + // and credit each changed `.op` path (newest commit seen first, so + // the first credit per file is its latest commit). + if let Ok(mut walk) = repo.revwalk() { + walk.set_sorting(git2::Sort::TIME).ok(); + if walk.push_head().is_ok() { + for oid in walk.flatten().take(SCAN_LIMIT) { + let Ok(commit) = repo.find_commit(oid) else { + continue; + }; + let new_tree = commit.tree().ok(); + let old_tree = commit.parent(0).ok().and_then(|p| p.tree().ok()); + let Some(new_tree) = new_tree else { continue }; + let Ok(diff) = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) + else { + continue; + }; + let secs = commit.author().when().seconds(); + let summary = commit.summary().map(str::to_string); + for delta in diff.deltas() { + let Some(path) = delta.new_file().path().and_then(Path::to_str) else { + continue; + }; + let rel = path.replace('\\', "/"); + if let Some(f) = files.get_mut(&rel) { + f.milestone_count += 1; + if f.last_commit_secs.is_none() { + f.last_commit_secs = Some(secs); + f.last_commit_message = summary.clone(); + } + } + } + } + } + } + + Ok(files.into_values().collect()) + } + + /// Clear this repository's local commit-author identity by removing the + /// `user.name` / `user.email` keys from the repo-LOCAL config (TS overflow + /// "清除提交作者"). Opens the `Local` config level explicitly so it never + /// touches the user's global `~/.gitconfig` — `Config::remove` on the + /// merged snapshot would delete from whichever level holds the key, which + /// could be the global identity used by every other repo. Best-effort: a + /// key that isn't set locally is not an error. + pub fn unset_local_author(&self) -> Result<(), GitError> { + let repo = self.open()?; + if let Ok(mut local) = repo + .config() + .and_then(|c| c.open_level(git2::ConfigLevel::Local)) + { + let _ = local.remove("user.name"); + let _ = local.remove("user.email"); + } + Ok(()) + } +} + +/// Recursively collect `*.op` files under `dir`, keyed by repo-relative path. +fn collect_op_files(dir: &Path, root: &Path, out: &mut HashMap) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + if name == ".git" { + continue; + } + if path.is_dir() { + collect_op_files(&path, root, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("op") { + let Ok(rel) = path.strip_prefix(root) else { + continue; + }; + let rel = rel.to_string_lossy().replace('\\', "/"); + out.insert( + rel.clone(), + CandidateOpFile { + path: path.to_string_lossy().into_owned(), + relative_path: rel, + milestone_count: 0, + last_commit_secs: None, + last_commit_message: None, + }, + ); + } + } +} diff --git a/crates/op-git/src/lib.rs b/crates/op-git/src/lib.rs index 6a3bf90e2..0a0ee2a75 100644 --- a/crates/op-git/src/lib.rs +++ b/crates/op-git/src/lib.rs @@ -25,6 +25,7 @@ use std::path::{Path, PathBuf}; mod auth; mod branch; +mod candidates; mod history; mod merge; mod remote; @@ -34,6 +35,7 @@ mod worktree; pub use auth::{AuthStore, Credential}; pub use branch::Branch; +pub use candidates::CandidateOpFile; pub use history::Commit; pub use merge::{ ConflictBag, ConflictKind, ConflictStages, ConflictedFile, MergeOutcome, WorktreeMergeReport, @@ -251,6 +253,28 @@ impl GitRepo { } } + /// Whether a committer identity is resolvable — both `user.name` and + /// `user.email` are set (repo → global → system). A commit refuses + /// without one, so the panel prompts for a signature when this is false. + pub fn has_committer_identity(&self) -> bool { + let a = self.author(); + a.name.is_some() && a.email.is_some() + } + + /// Write `user.name` / `user.email` into the repo-LOCAL config (the + /// signature for commits in this repo). The inverse of + /// [`Self::unset_local_author`]; used by the commit-signature form. + pub fn set_local_author(&self, name: &str, email: &str) -> Result<(), GitError> { + let repo = self.open()?; + let mut local = repo + .config()? + .open_level(git2::ConfigLevel::Local) + .or_else(|_| repo.config())?; + local.set_str("user.name", name)?; + local.set_str("user.email", email)?; + Ok(()) + } + /// Read a single git config value (repo config, falling back to the /// global/system config the way `git config --get` does), or `None` /// when it is unset. diff --git a/crates/op-git/src/status.rs b/crates/op-git/src/status.rs index f7fc2b431..f1a98bc69 100644 --- a/crates/op-git/src/status.rs +++ b/crates/op-git/src/status.rs @@ -232,6 +232,23 @@ impl GitRepo { Ok(()) } + /// Read `relpath`'s file content at `rev` (a hash, tag, branch, or a + /// revspec like `^`). Returns `None` when the path does not exist + /// at that revision (e.g. the file was added in that commit, or `rev` + /// has no parent). Used by the commit-detail card's semantic diff. + pub fn blob_at_commit(&self, rev: &str, relpath: &str) -> Result, GitError> { + let repo = self.open()?; + let result = match repo.revparse_single(&format!("{rev}:{relpath}")) { + Ok(object) => { + let blob = object.peel_to_blob()?; + Ok(Some(String::from_utf8_lossy(blob.content()).into_owned())) + } + Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), + Err(e) => Err(e.into()), + }; + result + } + /// A path made relative to the work-tree root — libgit2 index / /// pathspec APIs expect repo-relative paths, but callers pass /// absolute document paths. diff --git a/crates/op-host-desktop/src/commit_diff_host.rs b/crates/op-host-desktop/src/commit_diff_host.rs new file mode 100644 index 000000000..98d549a6b --- /dev/null +++ b/crates/op-host-desktop/src/commit_diff_host.rs @@ -0,0 +1,36 @@ +//! Host glue for the commit-detail card's semantic diff — kept out of the +//! already-large `git_host.rs`. Reads the expanded commit + its tracked file, +//! computes the node diff against the parent (`commit_diff_semantic`), and +//! lands the result on the panel. + +use op_editor_core::CommitDiffView; + +use crate::DesktopApp; + +impl DesktopApp { + /// Compute `recent_commits[index]`'s semantic diff against its parent + /// (TS `computeDiff`) and store it in the panel for the inline card. + pub(crate) fn load_expanded_commit_diff(&mut self, index: usize) { + let commit = self + .host + .editor_state() + .editor_ui + .git_panel + .recent_commits + .get(index) + .cloned(); + let relpath = self.git_session.tracked_relpath(); + let view = match (self.git_session.repo(), relpath, commit) { + (Some(repo), Some(relpath), Some(commit)) => { + crate::commit_diff_semantic::load_commit_diff(repo, &relpath, &commit) + } + _ => CommitDiffView::Error("no tracked file in this repository".to_string()), + }; + // Only land the result if that row is still expanded — a fast + // collapse / re-expand could have moved on. + let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; + if panel.expanded_commit == Some(index) { + panel.expanded_commit_diff = Some(view); + } + } +} diff --git a/crates/op-host-desktop/src/commit_diff_semantic.rs b/crates/op-host-desktop/src/commit_diff_semantic.rs new file mode 100644 index 000000000..5889b1ff1 --- /dev/null +++ b/crates/op-host-desktop/src/commit_diff_semantic.rs @@ -0,0 +1,306 @@ +//! Semantic node-diff for the commit-detail card — a Rust port of the TS +//! `diffDocuments` (`packages/pen-core/src/merge/node-diff.ts`) + +//! `engineDiff` summary aggregation (`apps/desktop/git/git-engine.ts`). +//! +//! Both `.op` blobs are parsed as generic `serde_json::Value`, indexed by +//! node `id`, and compared field-by-field with `children` stripped — exactly +//! mirroring the TS `stripChildren` + `jsonEqual` path. Kept dependency-free +//! of the canonical typed schema so a malformed/legacy file degrades to a +//! best-effort diff instead of failing to parse. + +use std::collections::HashSet; + +use op_editor_core::{CommitDiffPatch, CommitDiffSummary, CommitDiffView, GitCommitSummary}; +use op_git::GitRepo; +use serde_json::Value; + +/// One indexed node: its structural context (page / parent / index) plus its +/// atomic fields (the node JSON with `children` removed). +struct Indexed { + id: String, + page_id: Option, + parent_id: Option, + index: usize, + fields: Value, +} + +/// Read the commit's blob + its parent's blob and compute the semantic diff. +/// Returns the lazy view state the card renders (TS `DiffState`). +pub fn load_commit_diff( + repo: &GitRepo, + relpath: &str, + commit: &GitCommitSummary, +) -> CommitDiffView { + if commit.is_initial { + return CommitDiffView::Initial; + } + let rev = &commit.short_hash; + // The file at this commit. Absent (`None`) means the commit removed it + // (or predates its creation) → diff an empty doc so the removals show. + let current = match repo.blob_at_commit(rev, relpath) { + Ok(Some(s)) => s, + Ok(None) => "{}".to_string(), + Err(e) => return CommitDiffView::Error(e.to_string()), + }; + // The first parent's version of the file. Absent (`None`) means the file + // was added in this commit → diff against an empty document (all adds). A + // real git error (e.g. an unreachable rev) surfaces rather than masking + // as a phantom all-adds diff. + let base = match repo.blob_at_commit(&format!("{rev}^"), relpath) { + Ok(Some(s)) => s, + Ok(None) => "{}".to_string(), + Err(e) => return CommitDiffView::Error(e.to_string()), + }; + + let next_doc: Value = match serde_json::from_str(¤t) { + Ok(v) => v, + Err(e) => return CommitDiffView::Error(e.to_string()), + }; + let base_doc: Value = match serde_json::from_str(&base) { + Ok(v) => v, + Err(e) => return CommitDiffView::Error(e.to_string()), + }; + + let summary = compute_commit_diff(&base_doc, &next_doc); + if summary.patches.is_empty() { + CommitDiffView::NoChanges + } else { + CommitDiffView::Ready(summary) + } +} + +/// Diff `base` → `next` and aggregate the summary (TS `diffDocuments` + +/// `engineDiff`). Public for unit tests. +pub fn compute_commit_diff(base: &Value, next: &Value) -> CommitDiffSummary { + let base_nodes = index_nodes(base); + let next_nodes = index_nodes(next); + + // Lookup maps keyed by id; the ordered id list preserves the TS walk + // order (base ids first, then next-only ids) for a stable patch list. + let base_map: std::collections::HashMap<&str, &Indexed> = + base_nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + let next_map: std::collections::HashMap<&str, &Indexed> = + next_nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + let mut seen: HashSet<&str> = HashSet::new(); + let mut order: Vec<&str> = Vec::new(); + for n in base_nodes.iter().chain(next_nodes.iter()) { + if seen.insert(n.id.as_str()) { + order.push(n.id.as_str()); + } + } + + let mut summary = CommitDiffSummary::default(); + let mut frames: HashSet = HashSet::new(); + for id in order { + match (base_map.get(id), next_map.get(id)) { + (None, Some(n)) => { + summary.nodes_added += 1; + if let Some(p) = &n.parent_id { + frames.insert(p.clone()); + } + summary.patches.push(patch("add", id)); + } + (Some(_), None) => { + summary.nodes_removed += 1; + summary.patches.push(patch("remove", id)); + } + (Some(b), Some(n)) => { + // `move` and `modify` are independent — one id may produce both. + let moved = + b.parent_id != n.parent_id || b.page_id != n.page_id || b.index != n.index; + if moved { + summary.nodes_modified += 1; + if let Some(p) = &n.parent_id { + frames.insert(p.clone()); + } + summary.patches.push(patch("move", id)); + } + if !json_eq(&b.fields, &n.fields) { + summary.nodes_modified += 1; + summary.patches.push(patch("modify", id)); + } + } + (None, None) => {} + } + } + summary.frames_changed = frames.len() as u32; + summary +} + +fn patch(op: &str, node_id: &str) -> CommitDiffPatch { + CommitDiffPatch { + op: op.to_string(), + node_id: node_id.to_string(), + } +} + +/// Walk a document into a flat list of indexed nodes (TS `indexNodesById`). +/// Handles both the `pages` shape and the legacy single-page `children` shape. +fn index_nodes(doc: &Value) -> Vec { + let mut out = Vec::new(); + for (page_id, children) in all_pages(doc) { + walk(children, page_id, None, &mut out); + } + out +} + +/// Normalize a document into `(pageId, children)` pairs. A legacy `children` +/// document yields one synthetic page with `id = None` (TS `getAllPages`). +fn all_pages(doc: &Value) -> Vec<(Option, &Vec)> { + if let Some(pages) = doc.get("pages").and_then(Value::as_array) { + if !pages.is_empty() { + return pages + .iter() + .filter_map(|p| { + let id = p.get("id").and_then(Value::as_str).map(str::to_string); + p.get("children").and_then(Value::as_array).map(|c| (id, c)) + }) + .collect(); + } + } + match doc.get("children").and_then(Value::as_array) { + Some(children) => vec![(None, children)], + None => Vec::new(), + } +} + +fn walk( + nodes: &[Value], + page_id: Option, + parent_id: Option, + out: &mut Vec, +) { + for (index, node) in nodes.iter().enumerate() { + let Some(id) = node.get("id").and_then(Value::as_str) else { + continue; + }; + out.push(Indexed { + id: id.to_string(), + page_id: page_id.clone(), + parent_id: parent_id.clone(), + index, + fields: strip_children(node), + }); + if let Some(children) = node.get("children").and_then(Value::as_array) { + if !children.is_empty() { + walk(children, page_id.clone(), Some(id.to_string()), out); + } + } + } +} + +/// A shallow copy of `node` with the `children` field removed (TS +/// `stripChildren`). +fn strip_children(node: &Value) -> Value { + let mut copy = node.clone(); + if let Some(map) = copy.as_object_mut() { + map.remove("children"); + } + copy +} + +/// Structural equality matching TS `jsonEqual`: object key order is ignored +/// (as `serde_json` already does) AND numbers compare by numeric value, so a +/// field serialized once as `1` and once as `1.0` is NOT a spurious change. +/// `serde_json::Value`'s own `PartialEq` distinguishes integer `1` from float +/// `1.0`, which would otherwise manufacture phantom `modify` patches across +/// mixed Rust/TS-serialized histories. +fn json_eq(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(xf), Some(yf)) => xf == yf, + _ => x == y, + }, + (Value::Array(xs), Value::Array(ys)) => { + xs.len() == ys.len() && xs.iter().zip(ys).all(|(x, y)| json_eq(x, y)) + } + (Value::Object(xo), Value::Object(yo)) => { + xo.len() == yo.len() + && xo + .iter() + .all(|(k, xv)| yo.get(k).is_some_and(|yv| json_eq(xv, yv))) + } + _ => a == b, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn ready(view: CommitDiffView) -> CommitDiffSummary { + match view { + CommitDiffView::Ready(s) => s, + other => panic!("expected Ready, got {other:?}"), + } + } + + #[test] + fn detects_added_removed_modified_and_moved() { + let base = json!({ + "pages": [{ "id": "p1", "children": [ + { "id": "a", "name": "A", "children": [ { "id": "c", "name": "C" } ] }, + { "id": "b", "name": "B" } + ]}] + }); + let next = json!({ + "pages": [{ "id": "p1", "children": [ + { "id": "a", "name": "A2" }, // modified (name) + c removed from it + { "id": "c", "name": "C" }, // moved to top level + { "id": "d", "name": "D" } // added + ]}] + }); + let s = compute_commit_diff(&base, &next); + assert_eq!(s.nodes_added, 1, "d added"); + assert_eq!(s.nodes_removed, 1, "b removed"); + // a: modify (name) ; c: move (reparented). Both count as modified. + assert_eq!(s.nodes_modified, 2); + let ops: Vec<&str> = s.patches.iter().map(|p| p.op.as_str()).collect(); + assert!(ops.contains(&"add")); + assert!(ops.contains(&"remove")); + assert!(ops.contains(&"modify")); + assert!(ops.contains(&"move")); + } + + #[test] + fn identical_documents_yield_no_patches() { + let doc = json!({ "pages": [{ "id": "p1", "children": [ { "id": "a", "name": "A" } ] }] }); + let s = compute_commit_diff(&doc, &doc); + assert!(s.patches.is_empty()); + assert_eq!(s.frames_changed, 0); + } + + #[test] + fn single_field_modify_matches_image_77() { + let base = json!({ "pages": [{ "id": "p1", "children": [ + { "id": "form-login-text", "text": "Sign in" } + ]}]}); + let next = json!({ "pages": [{ "id": "p1", "children": [ + { "id": "form-login-text", "text": "Log in" } + ]}]}); + let s = ready(CommitDiffView::Ready(compute_commit_diff(&base, &next))); + assert_eq!(s.nodes_modified, 1); + assert_eq!(s.patches.len(), 1); + assert_eq!(s.patches[0].op, "modify"); + assert_eq!(s.patches[0].node_id, "form-login-text"); + } + + #[test] + fn integer_vs_float_number_is_not_a_modify() { + // A field serialized once as `1` and once as `1.0` is the same value + // (TS `jsonEqual` via JSON.stringify) — must not manufacture a modify. + let base = json!({ "pages": [{ "id": "p1", "children": [ { "id": "a", "x": 1 } ] }] }); + let next = json!({ "pages": [{ "id": "p1", "children": [ { "id": "a", "x": 1.0 } ] }] }); + let s = compute_commit_diff(&base, &next); + assert!(s.patches.is_empty(), "1 vs 1.0 must not diff"); + } + + #[test] + fn legacy_children_shape_is_supported() { + let base = json!({ "children": [ { "id": "x", "name": "X" } ] }); + let next = json!({ "children": [ { "id": "x", "name": "X" }, { "id": "y" } ] }); + let s = compute_commit_diff(&base, &next); + assert_eq!(s.nodes_added, 1); + } +} diff --git a/crates/op-host-desktop/src/git_host.rs b/crates/op-host-desktop/src/git_host.rs index 82a4a1fc4..a8f2c6aa4 100644 --- a/crates/op-host-desktop/src/git_host.rs +++ b/crates/op-host-desktop/src/git_host.rs @@ -65,6 +65,20 @@ impl DesktopApp { if !self.git_session.is_bound() { return false; } + // Resolve the stored-credential kind for the remote host before the + // `panel` borrow (it reads `git_session`'s auth store). + let stored_auth = match snap.remote_host.as_deref() { + None => String::new(), + Some(host) => match self + .git_session + .auth_stores() + .and_then(|(auth, _)| auth.get(host).ok().flatten()) + { + Some(op_git::Credential::Ssh { .. }) => "ssh".to_string(), + Some(op_git::Credential::Https { .. }) => "token".to_string(), + None => "none".to_string(), + }, + }; let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; let changed = panel.in_repo != snap.in_repo || panel.branch != snap.branch @@ -82,12 +96,16 @@ impl DesktopApp { let commits_changed = panel.recent_commits != snap.recent_commits; if commits_changed { panel.expanded_commit = None; + panel.expanded_commit_diff = None; } panel.in_repo = snap.in_repo; panel.branch = snap.branch; panel.branches = snap.branches; panel.dirty_count = snap.dirty_count; panel.ahead = snap.ahead; + panel.behind = snap.behind; + panel.remote_host = snap.remote_host; + panel.stored_auth = stored_auth; panel.conflicted_count = snap.conflicted_count; panel.merging = snap.merging; panel.conflicted_files = snap.conflicted_files; @@ -247,18 +265,43 @@ impl DesktopApp { // (the TS `commitMilestone` flow). stage_tracked is // explicitly designed to refresh the index blob after a // save, so a milestone captures exactly what's on screen. - if !message.is_empty() { + // No committer identity yet → show the signature form and + // defer the commit (the message stays put; `save_author_identity` + // re-fires this action). TS `authorIdentity === null` path. + let needs_author = !message.is_empty() + && !self + .git_session + .repo() + .map(|r| r.has_committer_identity()) + .unwrap_or(true); + if needs_author { + let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; + panel.author_prompt = true; + panel.author_name_focused = true; + panel.author_email_focused = false; + // Hand keyboard focus to the form, off the (now hidden) + // commit box, so typing lands in the name/email fields. + panel.commit_focused = false; + panel.commit_no_changes = false; + } else if !message.is_empty() { match self.git_session.tracked_file().map(|p| p.to_path_buf()) { Some(path) => { match persistence::save_to_path(self.host.editor_state(), &path) { Ok(()) => { self.mark_document_saved(); - let committed = self - .git_session - .stage_tracked() - .and_then(|()| self.git_session.commit_staged(&message)); + // Stage, then guard against an empty + // milestone: if the saved file matches the + // last commit there is nothing to commit, + // so skip rather than create an empty one. + let committed = match self.git_session.stage_tracked() { + Ok(()) if self.git_session.tracked_has_staged_changes() => { + self.git_session.commit_staged(&message).map(|()| true) + } + Ok(()) => Ok(false), + Err(e) => Err(e), + }; match committed { - Ok(()) => { + Ok(true) => { let panel = &mut self .host .editor_state_mut() @@ -266,6 +309,16 @@ impl DesktopApp { .git_panel; panel.commit_message.clear(); panel.commit_focused = false; + panel.commit_no_changes = false; + } + // Nothing changed — keep the message and + // flag a "no changes" hint under the box. + Ok(false) => { + self.host + .editor_state_mut() + .editor_ui + .git_panel + .commit_no_changes = true; } Err(err) => self.show_git_op_error_dialog("commit", &err), } @@ -385,6 +438,23 @@ impl DesktopApp { // Pure clipboard write — no git op, no reload. crate::clipboard::set_text(&rev); } + GitPanelAction::LoadCommitDiff(index) => self.load_expanded_commit_diff(index), + GitPanelAction::EnterTrackedPicker => self.enter_tracked_picker(), + GitPanelAction::BindTrackedFile(path, open) => self.bind_tracked_file(path, open), + GitPanelAction::ClearAuthor => self.clear_commit_author(), + GitPanelAction::CloseRepo => self.close_repo(), + GitPanelAction::EnterSshKeys => self.enter_ssh_keys(), + GitPanelAction::ImportSshKey => self.import_ssh_key(), + GitPanelAction::SaveAuthor => self.save_author_identity(), + GitPanelAction::FetchRemote => { + // `git fetch` on origin (with stored credentials) — the tail + // refresh re-reads ahead/behind afterward. + if let Some(repo) = self.git_session.authed_repo() { + if let Err(e) = repo.fetch() { + eprintln!("openpencil-desktop: git fetch failed: {e}"); + } + } + } } // Every action ends with a fresh snapshot + a repaint. self.refresh_git_panel(); diff --git a/crates/op-host-desktop/src/git_jobs.rs b/crates/op-host-desktop/src/git_jobs.rs index 93a15c88d..a4cb53317 100644 --- a/crates/op-host-desktop/src/git_jobs.rs +++ b/crates/op-host-desktop/src/git_jobs.rs @@ -24,6 +24,10 @@ pub struct GitSnapshot { pub dirty_count: usize, /// Commits ahead of the upstream — gates the Push button. pub ahead: u32, + /// Commits behind the upstream — remote-settings row. + pub behind: u32, + /// `origin` remote host (e.g. `github.com`), `None` when absent. + pub remote_host: Option, /// Conflicted-file count. pub conflicted_count: usize, /// Whether a merge is in progress. @@ -115,6 +119,8 @@ fn snapshot(repo: &GitRepo) -> GitSnapshot { let status = repo.status().ok(); let dirty_count = status.as_ref().map(|s| s.files.len()).unwrap_or(0); let ahead = status.as_ref().map(|s| s.ahead).unwrap_or(0); + let behind = status.as_ref().map(|s| s.behind).unwrap_or(0); + let remote_host = repo.origin_host(); let conflicted_count = status .as_ref() .map(|s| { @@ -161,6 +167,8 @@ fn snapshot(repo: &GitRepo) -> GitSnapshot { branches, dirty_count, ahead, + behind, + remote_host, conflicted_count, merging, conflicted_files, @@ -384,7 +392,7 @@ fn compute_diff(repo: &GitRepo, target: &GitDiffTarget, locale: Locale) -> GitDi /// the TS `formatCompactTime`: `now` (<1 min), `{n}m` (<1 h), `{n}h` /// (<1 day), `yesterday` (1 day), `{n}d` (<1 week), else `YYYY-MM-DD`. /// `now_secs` is the wall-clock Unix time captured at snapshot. -fn format_compact_time(ts_secs: i64, now_secs: i64) -> String { +pub(crate) fn format_compact_time(ts_secs: i64, now_secs: i64) -> String { let diff = (now_secs - ts_secs).max(0); let min = diff / 60; if min < 1 { diff --git a/crates/op-host-desktop/src/git_overflow_host.rs b/crates/op-host-desktop/src/git_overflow_host.rs new file mode 100644 index 000000000..2e8005a6a --- /dev/null +++ b/crates/op-host-desktop/src/git_overflow_host.rs @@ -0,0 +1,144 @@ +//! Host glue for the git overflow menu's three new actions — kept out of the +//! already-large `git_host.rs`. Ports the TS git-store methods +//! `enterTrackedFilePicker` / `bindTrackedFile` / `clearAuthorIdentity` / +//! `closeRepo`. + +use std::cmp::Ordering; +use std::path::PathBuf; + +use op_editor_core::{GitCandidateFile, GitOverflowView, GitPanelState}; + +use crate::DesktopApp; +use crate::{git_jobs, persistence}; + +impl DesktopApp { + /// Overflow "切换跟踪文件" — enumerate the repo's `.op` candidates (sorted + /// newest-commit first, then by path) into the panel and open the picker. + pub(crate) fn enter_tracked_picker(&mut self) { + let mut candidates = self + .git_session + .repo() + .and_then(|r| r.candidate_op_files().ok()) + .unwrap_or_default(); + // TS sort: lastCommitAt DESC (None last), tiebreak relativePath ASC. + candidates.sort_by(|a, b| match (a.last_commit_secs, b.last_commit_secs) { + (Some(x), Some(y)) => y + .cmp(&x) + .then_with(|| a.relative_path.cmp(&b.relative_path)), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a.relative_path.cmp(&b.relative_path), + }); + let now_secs = now_unix_secs(); + let files: Vec = candidates + .into_iter() + .map(|c| GitCandidateFile { + path: c.path, + relative_path: c.relative_path, + milestone_count: c.milestone_count, + last_commit_time: c + .last_commit_secs + .map(|s| git_jobs::format_compact_time(s, now_secs)) + .unwrap_or_default(), + last_commit_message: c.last_commit_message, + }) + .collect(); + + let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; + panel.candidate_files = files; + panel.tracked_picker_selected = None; + panel.overflow_view = GitOverflowView::TrackedPicker; + panel.overflow_open = true; + } + + /// Bind the panel to `path` as the tracked file (TS `bindTrackedFile`). + /// `open` also loads that `.op` into the editor (TS "track and open"). + pub(crate) fn bind_tracked_file(&mut self, path: String, open: bool) { + let path = PathBuf::from(path); + { + let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; + panel.overflow_open = false; + panel.overflow_view = GitOverflowView::Menu; + panel.tracked_picker_selected = None; + panel.candidate_files.clear(); + } + if open { + // "Track and open" loads the file as the editor document, then + // rebinds the Git session to whatever is now `current_path` — so + // the session and the open document can't diverge (`open_path` + // updates `current_path` on success, leaves it on failure; the + // canonical rebind follows it either way). + if self.confirm_document_reload() + && persistence::open_path( + &mut self.host, + path, + &mut self.current_path, + self.window.as_ref(), + ) + { + self.mark_document_saved(); + } + self.rebind_git_session_for_current_path(); + } else { + // "Track only" — bind the session to `path` without touching the + // editor document (TS `bindTrackedFile` with no open follow-up). + self.git_session.rebind(Some(&path)); + } + } + + /// Overflow "清除提交作者" — clear the repo's local commit-author identity. + pub(crate) fn clear_commit_author(&mut self) { + if let Some(repo) = self.git_session.repo() { + if let Err(e) = repo.unset_local_author() { + eprintln!("openpencil-desktop: git clear-author failed: {e}"); + } + } + } + + /// Commit-signature form "保存" — validate + write the name/email drafts + /// into the repo identity, then re-fire the deferred milestone commit + /// (TS `setAuthorIdentity` + the pending-commit re-run). + pub(crate) fn save_author_identity(&mut self) { + let (name, email) = { + let panel = &self.host.editor_state().editor_ui.git_panel; + ( + panel.author_name_draft.trim().to_string(), + panel.author_email_draft.trim().to_string(), + ) + }; + // Basic validation (TS validationName / validationEmail). Leave the + // form open on failure so the user can correct it. + if name.is_empty() || !email.contains('@') { + return; + } + if let Some(repo) = self.git_session.repo() { + if let Err(e) = repo.set_local_author(&name, &email) { + eprintln!("openpencil-desktop: set commit author failed: {e}"); + return; + } + } + let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; + panel.author_prompt = false; + panel.author_name_focused = false; + panel.author_email_focused = false; + // Re-fire the deferred commit — the message is still in + // `commit_message` and the identity now resolves. + panel.pending_action = Some(op_editor_core::GitPanelAction::CommitMilestone); + } + + /// Overflow "关闭仓库" — unbind the repository and reset the panel to its + /// empty state (TS `closeRepo`). + pub(crate) fn close_repo(&mut self) { + self.git_session.rebind(None); + let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; + *panel = GitPanelState::default(); + } +} + +/// Current wall-clock Unix seconds (0 if the clock is before the epoch). +fn now_unix_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/crates/op-host-desktop/src/git_session.rs b/crates/op-host-desktop/src/git_session.rs index 94e282ea5..f8374db11 100644 --- a/crates/op-host-desktop/src/git_session.rs +++ b/crates/op-host-desktop/src/git_session.rs @@ -159,6 +159,20 @@ impl GitSession { repo.stage(&[file.as_path()]) } + /// Whether the tracked document has staged changes (its index blob + /// differs from `HEAD`). After `stage_tracked`, `false` means the saved + /// file matches the last commit — committing would create an empty + /// milestone, so the caller skips it. + pub fn tracked_has_staged_changes(&self) -> bool { + let Some(repo) = self.repo.as_ref() else { + return false; + }; + match self.tracked_relpath() { + Some(rel) => repo.is_path_staged(&rel).unwrap_or(false), + None => false, + } + } + /// The tracked document's path relative to the repository root, /// `/`-separated — the key form the Git panel's `changed_files` /// uses. `None` when unbound or the path is outside the repo. diff --git a/crates/op-host-desktop/src/git_ssh_host.rs b/crates/op-host-desktop/src/git_ssh_host.rs new file mode 100644 index 000000000..55419daef --- /dev/null +++ b/crates/op-host-desktop/src/git_ssh_host.rs @@ -0,0 +1,71 @@ +//! Host glue for the git overflow menu's SSH-keys subview — kept out of the +//! already-large `git_host.rs`. Enumerates the stored keys and imports a key +//! file chosen via a native picker (TS `git-panel-ssh-keys.tsx` list view). + +use op_editor_core::GitOverflowView; + +use crate::DesktopApp; + +impl DesktopApp { + /// Overflow "SSH 密钥" — list the stored SSH key names into the panel and + /// open the subview. + pub(crate) fn enter_ssh_keys(&mut self) { + let names = self + .git_session + .auth_stores() + .and_then(|(_, ssh)| ssh.list().ok()) + .map(|keys| keys.into_iter().map(|k| k.name).collect()) + .unwrap_or_default(); + let panel = &mut self.host.editor_state_mut().editor_ui.git_panel; + panel.ssh_keys = names; + panel.overflow_view = GitOverflowView::SshKeys; + panel.overflow_open = true; + } + + /// SSH subview "导入现有密钥" — pick a private key file and import it into + /// the key store, then refresh the list. + pub(crate) fn import_ssh_key(&mut self) { + let Some(source) = rfd::FileDialog::new() + .set_title("Import SSH private key") + .pick_file() + else { + return; // user cancelled + }; + // Name the imported key after its file stem so the list shows + // something meaningful (the store keeps its own copy). + let name = source + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("imported") + .to_string(); + // The origin host to bind the key to (if a remote is configured). + let host = self.git_session.repo().and_then(|r| r.origin_host()); + let imported = self.git_session.auth_stores().map(|(auth, ssh)| { + // The store IS `~/.ssh`, so a key the user picks from there is + // already "in the store" — copying it onto itself would fail with + // "already exists". Only copy when the source lives elsewhere. + if source.parent() != Some(ssh.dir()) { + ssh.import(&source, &name)?; + } + // Bind the (now-stored) key as the origin host's SSH credential so + // git operations actually USE it (not just list it) — but ONLY when + // it won't clobber an existing HTTPS token: bind only if the host + // has no credential yet or already uses SSH. Without a remote + // there's no host to bind to; the key is still stored for later. + if let Some(host) = &host { + let existing = auth.get(host).ok().flatten(); + let safe_to_bind = + matches!(existing, None | Some(op_git::Credential::Ssh { .. })); + if safe_to_bind { + auth.set(host, op_git::Credential::Ssh { key_name: name.clone() })?; + } + } + Ok::<(), op_git::GitError>(()) + }); + match imported { + Some(Ok(())) => self.enter_ssh_keys(), // re-enumerate + Some(Err(e)) => eprintln!("openpencil-desktop: ssh import failed: {e}"), + None => eprintln!("openpencil-desktop: ssh import — no key store available"), + } + } +} diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index ca9e97b41..e0baa6937 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -15,6 +15,8 @@ mod chat_runtime; mod chat_session; mod chat_subprocess; mod clipboard; +mod commit_diff_host; +mod commit_diff_semantic; mod cursor_icon; mod design_md_host; mod design_session; @@ -24,7 +26,9 @@ mod figma_import_session; mod frame; mod git_host; mod git_jobs; +mod git_overflow_host; mod git_session; +mod git_ssh_host; mod iconify_host; mod image_search_session; mod keyboard_input; diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 47b8ec4b1..3fdb7305a 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -741,7 +741,7 @@ impl WidgetHostNative { 500, )); } - // Git commit textarea caret — same 530 ms cadence the ready + // Git commit textarea caret — same 500 ms cadence the ready // panel paints at (`git_panel_ready.rs`). Without this wake the // window never repaints while the commit box is focused, so the // caret sits static instead of blinking. @@ -749,7 +749,7 @@ impl WidgetHostNative { return Some(jian_core::anim::next_blink_flip_ms( self.now_ms, self.editor_state.editor_ui.git_panel.commit_caret_anchor_ms, - 530, + 500, )); } // Remote-settings inputs (remote URL + HTTPS username:token) share @@ -762,7 +762,7 @@ impl WidgetHostNative { return Some(jian_core::anim::next_blink_flip_ms( self.now_ms, self.editor_state.editor_ui.git_panel.commit_caret_anchor_ms, - 530, + 500, )); } // Clone-wizard field caret, and while a `git clone` runs — keep @@ -773,7 +773,7 @@ impl WidgetHostNative { return Some(jian_core::anim::next_blink_flip_ms( self.now_ms, form.caret_anchor_ms, - 530, + 500, )); } } diff --git a/crates/op-host-native/src/widget_host/git_press.rs b/crates/op-host-native/src/widget_host/git_press.rs index 9030bd199..dd519014f 100644 --- a/crates/op-host-native/src/widget_host/git_press.rs +++ b/crates/op-host-native/src/widget_host/git_press.rs @@ -7,7 +7,8 @@ //! inside the Git-panel rect) and before the canvas overlays. use op_editor_core::{ - CloneField, GitBranchPickerMode, GitDiffTarget, GitOverflowView, GitPanelAction, + CloneField, CommitDiffView, GitBranchPickerMode, GitDiffTarget, GitOverflowView, + GitPanelAction, GitPanelState, }; use op_editor_ui::widgets::{GitPanel, GitPanelHit}; use op_editor_ui::Point2D; @@ -59,6 +60,8 @@ impl WidgetHostNative { panel.commit_caret_anchor_ms = now; panel.remote_focused = false; panel.https_focused = false; + // Re-engaging the input dismisses the stale "no changes" hint. + panel.commit_no_changes = false; } Some(GitPanelHit::RemoteInput) => { panel.remote_focused = true; @@ -105,6 +108,30 @@ impl WidgetHostNative { panel.pending_action = Some(GitPanelAction::CommitMilestone); } } + Some(GitPanelHit::AuthorNameInput) => { + panel.author_name_focused = true; + panel.author_email_focused = false; + panel.commit_focused = false; + panel.remote_focused = false; + panel.https_focused = false; + panel.commit_caret_anchor_ms = now; + } + Some(GitPanelHit::AuthorEmailInput) => { + panel.author_email_focused = true; + panel.author_name_focused = false; + panel.commit_focused = false; + panel.remote_focused = false; + panel.https_focused = false; + panel.commit_caret_anchor_ms = now; + } + Some(GitPanelHit::AuthorSave) => { + panel.pending_action = Some(GitPanelAction::SaveAuthor); + } + Some(GitPanelHit::AuthorCancel) => { + panel.author_prompt = false; + panel.author_name_focused = false; + panel.author_email_focused = false; + } Some(GitPanelHit::EmptyInit) => { panel.pending_action = Some(GitPanelAction::InitRepo); } @@ -174,13 +201,59 @@ impl WidgetHostNative { panel.overflow_view = GitOverflowView::RemoteSettings; } Some(GitPanelHit::OverflowSshKeys) => { + // Open the SSH-keys subview (host enumerates the stored keys). + panel.pending_action = Some(GitPanelAction::EnterSshKeys); + } + Some(GitPanelHit::SshGenerateKey) => { panel.pending_action = Some(GitPanelAction::SetupSshAuth); panel.overflow_open = false; panel.overflow_view = GitOverflowView::Menu; } + Some(GitPanelHit::SshImportKey) => { + panel.pending_action = Some(GitPanelAction::ImportSshKey); + } + Some(GitPanelHit::FetchRemote) => { + panel.pending_action = Some(GitPanelAction::FetchRemote); + } Some(GitPanelHit::OverflowBack) => { panel.overflow_view = GitOverflowView::Menu; } + Some(GitPanelHit::OverflowSwitchTracked) => { + // Host enumerates the repo's `.op` candidates, then flips the + // subview to the tracked-file picker. + panel.pending_action = Some(GitPanelAction::EnterTrackedPicker); + } + Some(GitPanelHit::OverflowClearAuthor) => { + panel.pending_action = Some(GitPanelAction::ClearAuthor); + panel.overflow_open = false; + panel.overflow_view = GitOverflowView::Menu; + } + Some(GitPanelHit::OverflowCloseRepo) => { + panel.pending_action = Some(GitPanelAction::CloseRepo); + panel.overflow_open = false; + panel.overflow_view = GitOverflowView::Menu; + } + Some(GitPanelHit::TrackedPickerRow(index)) => { + // Pure UI — single-select a candidate. + if index < panel.candidate_files.len() { + panel.tracked_picker_selected = Some(index); + } + } + Some(GitPanelHit::TrackedPickerBind) => { + if let Some(path) = picker_selected_path(panel) { + panel.pending_action = Some(GitPanelAction::BindTrackedFile(path, false)); + } + } + Some(GitPanelHit::TrackedPickerBindOpen) => { + if let Some(path) = picker_selected_path(panel) { + panel.pending_action = Some(GitPanelAction::BindTrackedFile(path, true)); + } + } + Some(GitPanelHit::TrackedPickerBack) => { + // Close the picker subview back to the overflow menu. + panel.overflow_view = GitOverflowView::Menu; + panel.tracked_picker_selected = None; + } Some(GitPanelHit::DismissPopover) => { // Click outside an open popover — close it + swallow. panel.branch_picker_open = false; @@ -256,13 +329,24 @@ impl WidgetHostNative { // Toggle the inline detail card under that row (TS // `HistoryMilestoneRow` expand) — clicking the open row // collapses it, a different row moves the card. - panel.expanded_commit = if panel.expanded_commit == Some(index) { + let next = if panel.expanded_commit == Some(index) { None } else if index < panel.recent_commits.len() { Some(index) } else { panel.expanded_commit }; + panel.expanded_commit = next; + match next { + // Newly expanded → show the loading state and ask the + // host to compute the semantic diff (TS `computeDiff`). + Some(i) => { + panel.expanded_commit_diff = Some(CommitDiffView::Loading); + panel.pending_action = Some(GitPanelAction::LoadCommitDiff(i)); + } + // Collapsed → drop any loaded diff. + None => panel.expanded_commit_diff = None, + } } Some(GitPanelHit::RestoreCommit(index)) => { if let Some(rev) = panel @@ -394,3 +478,11 @@ impl WidgetHostNative { panel.clone_form = None; } } + +/// The absolute path of the tracked-file picker's selected candidate, if any. +fn picker_selected_path(panel: &GitPanelState) -> Option { + panel + .tracked_picker_selected + .and_then(|i| panel.candidate_files.get(i)) + .map(|c| c.path.clone()) +} diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index dbd5c5aac..959fe9858 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -30,6 +30,7 @@ impl WidgetHostNative { || self.git_remote_focus_active() || self.git_https_focus_active() || self.git_branch_create_focus_active() + || self.git_author_focus_active() || self.git_clone_input_active() } @@ -40,10 +41,14 @@ impl WidgetHostNative { /// Whether the visible Git commit-message input owns the keyboard. pub fn git_commit_focus_active(&self) -> bool { let panel = &self.editor_state.editor_ui.git_panel; - // The branch-picker dropdown has no commit input; while it is open a - // stale `commit_focused` must not route keys (text / Enter) to the - // hidden commit box. - panel.open && panel.commit_focused && !panel.loading && !panel.branch_picker_open + // A stale `commit_focused` must not route keys to a HIDDEN commit box — + // the box is gone while the branch-picker dropdown OR the signature + // form (`author_prompt`) has replaced it. + panel.open + && panel.commit_focused + && !panel.loading + && !panel.branch_picker_open + && !panel.author_prompt } /// Whether the visible Git remote-URL input owns the keyboard. @@ -64,6 +69,15 @@ impl WidgetHostNative { panel.open && panel.branch_create_focused && !panel.loading } + /// Whether a commit-signature form input (name / email) owns the keyboard. + pub fn git_author_focus_active(&self) -> bool { + let panel = &self.editor_state.editor_ui.git_panel; + panel.open + && panel.author_prompt + && (panel.author_name_focused || panel.author_email_focused) + && !panel.loading + } + /// Whether a ready-state Git popover (branch picker / overflow menu) is /// actually visible — the panel is open, in the ready view, and a popover /// flag is set. Scopes the Enter swallow so a stale flag while the panel diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index d5e880210..1d926eb81 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -46,6 +46,7 @@ impl WidgetHostNative { let now = self.now_ms; let panel = &mut self.editor_state.editor_ui.git_panel; panel.commit_message.push(c); + panel.commit_no_changes = false; // Keep the caret solid while typing (reset the blink). panel.commit_caret_anchor_ms = now; self.mark_dirty(); @@ -71,6 +72,22 @@ impl WidgetHostNative { } return false; } + // …then the commit-signature form's name / email inputs. + if self.git_author_focus_active() { + if !c.is_control() { + let now = self.now_ms; + let panel = &mut self.editor_state.editor_ui.git_panel; + if panel.author_email_focused { + panel.author_email_draft.push(c); + } else { + panel.author_name_draft.push(c); + } + panel.commit_caret_anchor_ms = now; + self.mark_dirty(); + return true; + } + return false; + } if self.git_branch_create_focus_active() { if !c.is_control() { let now = self.now_ms; @@ -279,6 +296,16 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self.git_author_focus_active() { + let panel = &mut self.editor_state.editor_ui.git_panel; + if panel.author_email_focused { + panel.author_email_draft.pop(); + } else { + panel.author_name_draft.pop(); + } + self.mark_dirty(); + return true; + } if self.git_branch_create_focus_active() { self.editor_state .editor_ui @@ -646,6 +673,18 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + // Enter in the commit-signature form submits it when valid; swallowed + // either way so it never falls through to the global chat send. + if self.git_author_focus_active() { + let panel = &mut self.editor_state.editor_ui.git_panel; + if !panel.author_name_draft.trim().is_empty() + && panel.author_email_draft.contains('@') + { + panel.pending_action = Some(op_editor_core::GitPanelAction::SaveAuthor); + } + self.mark_dirty(); + return true; + } // While a ready-state popover (branch picker / overflow menu) is // actually visible with no focused input, swallow Enter so it can't // fall through to the global chat send below. (Focused inputs already @@ -753,6 +792,17 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + // Escape dismisses the commit-signature form (TS form cancel) without + // committing — checked before the input-focus handlers so a focused + // name/email field doesn't swallow it. + if self.editor_state.editor_ui.git_panel.author_prompt { + let panel = &mut self.editor_state.editor_ui.git_panel; + panel.author_prompt = false; + panel.author_name_focused = false; + panel.author_email_focused = false; + 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;