feat(panels): git-panel commit-detail diff, overflow menu, signature form

Bring the Rust git panel to TS parity across four surfaces:

- Commit rows expand inline into a semantic diff card (ported
  diffDocuments / indexNodesById / engineDiff over serde_json::Value)
  instead of navigating to a separate diff page; the card shows the
  "~modify N nodes" summary plus a per-node patch list. blob_at_commit
  reads the {rev} and {rev}^ blobs to compute base-vs-next.
- Overflow menu (...) with the 5 TS actions — switch tracked file, clear
  commit author, remote settings, SSH keys, close repository — backed by
  GitOverflowView subviews (tracked-file picker + SSH-keys list) and
  remote-settings rows (origin URL + ahead/behind + fetch; no HTTPS token
  field, matching TS).
- Commit signature form: empty commits are refused, and committing with no
  configured author opens an inline name/email form (Enter saves, Escape
  cancels) that writes the repo-local identity before retrying the commit.
- Polish: taller commit-detail card, removed panel shadow, lengthened the
  ready panel, and unified the commit-textarea caret blink to the app's
  500 ms cadence.

New overflow/SSH/author/remote labels resolve via Document::t with an
English fallback for keys not yet in the locale tables.
This commit is contained in:
Kayshen-X 2026-06-02 09:42:03 +08:00
parent ae492e7aa2
commit 82d481547c
28 changed files with 2299 additions and 178 deletions

View file

@ -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<String>,
}
/// One node-level change in a commit's semantic diff (TS `NodePatch`,
/// rendered as `<op> <nodeId>` 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<CommitDiffPatch>,
}
/// 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<String>,
/// 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<usize>,
/// 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<CommitDiffView>,
/// Candidate `.op` files for the tracked-file picker subview, host-filled
/// when the picker opens (TS `RepoMeta.candidateFiles`).
pub candidate_files: Vec<GitCandidateFile>,
/// The picker's currently-selected candidate index, if any.
pub tracked_picker_selected: Option<usize>,
/// SSH key names for the SSH-keys subview (host-filled on open).
pub ssh_keys: Vec<String>,
/// 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).

View file

@ -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,

View file

@ -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

View file

@ -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 {

View file

@ -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 `<Separator>`).
divider_after: bool,
}
/// Height of a divider band between overflow-menu groups (TS
/// `<Separator className="my-1">` ≈ 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<OverflowItem> {
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<Rect> {
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 `<Separator className="my-1">`).
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 {

View file

@ -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<Rect> {
// +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<GitPanelHit> {
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.

View file

@ -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}|")

View file

@ -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<GitPanelHit> {
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
}
}

View file

@ -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)
);
}

View file

@ -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<Rect> {
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<GitPanelHit> {
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
}
}

View file

@ -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,

View file

@ -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 <line> → "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.

View file

@ -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;

View file

@ -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<i64>,
/// First line of that commit's message, if any.
pub last_commit_message: Option<String>,
}
/// 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<Vec<CandidateOpFile>, 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<String, CandidateOpFile> = 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<String, CandidateOpFile>) {
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,
},
);
}
}
}

View file

@ -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.

View file

@ -232,6 +232,23 @@ impl GitRepo {
Ok(())
}
/// Read `relpath`'s file content at `rev` (a hash, tag, branch, or a
/// revspec like `<hash>^`). 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<Option<String>, 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.

View file

@ -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);
}
}
}

View file

@ -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<String>,
parent_id: Option<String>,
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(&current) {
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<String> = 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<Indexed> {
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<String>, &Vec<Value>)> {
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<String>,
parent_id: Option<String>,
out: &mut Vec<Indexed>,
) {
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);
}
}

View file

@ -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();

View file

@ -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<String>,
/// 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 {

View file

@ -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<GitCandidateFile> = 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)
}

View file

@ -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.

View file

@ -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"),
}
}
}

View file

@ -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;

View file

@ -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,
));
}
}

View file

@ -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<String> {
panel
.tracked_picker_selected
.and_then(|i| panel.candidate_files.get(i))
.map(|c| c.path.clone())
}

View file

@ -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

View file

@ -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;