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:
parent
ae492e7aa2
commit
82d481547c
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}|")
|
||||
|
|
|
|||
167
crates/op-editor-ui/src/widgets/git_panel_ssh_keys.rs
Normal file
167
crates/op-editor-ui/src/widgets/git_panel_ssh_keys.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
323
crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs
Normal file
323
crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
134
crates/op-git/src/candidates.rs
Normal file
134
crates/op-git/src/candidates.rs
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
36
crates/op-host-desktop/src/commit_diff_host.rs
Normal file
36
crates/op-host-desktop/src/commit_diff_host.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
306
crates/op-host-desktop/src/commit_diff_semantic.rs
Normal file
306
crates/op-host-desktop/src/commit_diff_semantic.rs
Normal 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(¤t) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return CommitDiffView::Error(e.to_string()),
|
||||
};
|
||||
let base_doc: Value = match serde_json::from_str(&base) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return CommitDiffView::Error(e.to_string()),
|
||||
};
|
||||
|
||||
let summary = compute_commit_diff(&base_doc, &next_doc);
|
||||
if summary.patches.is_empty() {
|
||||
CommitDiffView::NoChanges
|
||||
} else {
|
||||
CommitDiffView::Ready(summary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff `base` → `next` and aggregate the summary (TS `diffDocuments` +
|
||||
/// `engineDiff`). Public for unit tests.
|
||||
pub fn compute_commit_diff(base: &Value, next: &Value) -> CommitDiffSummary {
|
||||
let base_nodes = index_nodes(base);
|
||||
let next_nodes = index_nodes(next);
|
||||
|
||||
// Lookup maps keyed by id; the ordered id list preserves the TS walk
|
||||
// order (base ids first, then next-only ids) for a stable patch list.
|
||||
let base_map: std::collections::HashMap<&str, &Indexed> =
|
||||
base_nodes.iter().map(|n| (n.id.as_str(), n)).collect();
|
||||
let next_map: std::collections::HashMap<&str, &Indexed> =
|
||||
next_nodes.iter().map(|n| (n.id.as_str(), n)).collect();
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut order: Vec<&str> = Vec::new();
|
||||
for n in base_nodes.iter().chain(next_nodes.iter()) {
|
||||
if seen.insert(n.id.as_str()) {
|
||||
order.push(n.id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let mut summary = CommitDiffSummary::default();
|
||||
let mut frames: HashSet<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
144
crates/op-host-desktop/src/git_overflow_host.rs
Normal file
144
crates/op-host-desktop/src/git_overflow_host.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
71
crates/op-host-desktop/src/git_ssh_host.rs
Normal file
71
crates/op-host-desktop/src/git_ssh_host.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue