openpencil/crates/op-editor-ui/src/widgets/git_panel_remotes.rs
Kayshen-X 9b77f170c8 refactor(git): render masked HTTPS credential via unified TextInputView
Route the Remotes-section HTTPS credential field through the unified
TextInputView with mask: Some(&mask_credential) (new
paint_text_input_view_masked helper), deleting the ~90-line hand-rolled
masked renderer + masked_byte_for_source_byte caret remap in
git_panel_remotes. The token still shows as user:•••• with a correct,
family-aware caret. Other TextInputView call sites pass mask: None.

Bumps vendor/jian to c451e16 (TextInputView::mask).
2026-06-20 22:26:28 +08:00

229 lines
7.8 KiB
Rust

//! Remotes-section layout + rendering for [`GitPanel`].
//!
//! Split out of `git_panel.rs` to keep that file under the repo's
//! 800-line cap. The Remotes section sits below the Branches list:
//! a one-line remote summary, a URL input + "Set" / "SSH" buttons,
//! and an HTTPS-credential (`username:token`) input + "Login".
use crate::widgets::git_panel::{truncate, GitPanel, GitPanelHit, INPUT_H, PAD, SECTION_GAP};
use crate::widgets::PaintCx;
use crate::{Point2D, Rect};
use jian_core::text_input::TextInputState;
/// Label baseline offset within the Remotes block.
const LABEL_OFF: f32 = 8.0;
/// Summary-line baseline offset within the block.
const SUMMARY_OFF: f32 = 22.0;
/// URL-input-row top offset within the block.
const INPUT_OFF: f32 = 30.0;
/// Gap between the URL row and the HTTPS-credential row.
const CRED_GAP: f32 = 8.0;
/// Width of each button at an input row's right edge.
const SET_BTN_W: f32 = 52.0;
/// Gap between an input and the buttons.
const SET_GAP: f32 = 8.0;
/// The interactive sub-rects of the Remotes section.
pub(super) struct RemotesLayout {
/// The remote-URL input box.
pub(super) input: Rect,
/// The "Set" (origin) button.
pub(super) set_button: Rect,
/// The "SSH" (set up SSH auth) button.
pub(super) ssh_button: Rect,
/// The HTTPS-credential (`username:token`) input box.
pub(super) https_input: Rect,
/// The "Login" (store HTTPS credential) button.
pub(super) login_button: Rect,
}
impl GitPanel<'_> {
/// Total height of the Remotes section — a fixed block (label +
/// summary + URL row + HTTPS-credential row).
pub(super) fn remotes_block_height(&self) -> f32 {
SECTION_GAP + INPUT_OFF + INPUT_H + CRED_GAP + INPUT_H
}
/// The Remotes section's interactive sub-rects.
pub(super) fn remotes_layout(&self, panel: Rect) -> RemotesLayout {
let top = panel.origin.y + self.remotes_section_top();
let left = panel.origin.x + PAD;
let inner_w = panel.size.x - PAD * 2.0;
let url_top = top + SECTION_GAP + INPUT_OFF;
let cred_top = url_top + INPUT_H + CRED_GAP;
// URL row: field + the "Set" and "SSH" buttons.
let input = Rect {
origin: Point2D::new(left, url_top),
size: Point2D::new(inner_w - 2.0 * (SET_BTN_W + SET_GAP), INPUT_H),
};
let url_button = |from_right: f32| Rect {
origin: Point2D::new(
left + inner_w - (from_right + 1.0) * SET_BTN_W - from_right * SET_GAP,
url_top,
),
size: Point2D::new(SET_BTN_W, INPUT_H),
};
// Credential row: field + the "Login" button.
let https_input = Rect {
origin: Point2D::new(left, cred_top),
size: Point2D::new(inner_w - SET_BTN_W - SET_GAP, INPUT_H),
};
let login_button = Rect {
origin: Point2D::new(left + inner_w - SET_BTN_W, cred_top),
size: Point2D::new(SET_BTN_W, INPUT_H),
};
RemotesLayout {
input,
set_button: url_button(1.0),
ssh_button: url_button(0.0),
https_input,
login_button,
}
}
/// Paint the Remotes section into the panel.
pub(super) fn paint_remotes(&self, cx: &mut PaintCx<'_>, panel: Rect) {
let top = panel.origin.y + self.remotes_section_top();
let left = panel.origin.x + PAD;
self.text(
cx,
self.t("git.panel.remotes"),
left,
top + SECTION_GAP + LABEL_OFF,
12.0,
self.theme.muted_foreground,
);
// One-line summary of the configured remote(s).
let (summary, summary_color) = match self.state.remotes.first() {
Some(first) if self.state.remotes.len() > 1 => (
self.t("git.panel.remotesMore")
.replace("{{name}}", &truncate(first, 38))
.replace("{{count}}", &(self.state.remotes.len() - 1).to_string()),
self.theme.foreground,
),
Some(first) => (truncate(first, 52), self.theme.foreground),
None => (
self.t("git.panel.noRemote").to_string(),
self.theme.muted_foreground,
),
};
self.text(
cx,
&summary,
left,
top + SECTION_GAP + SUMMARY_OFF,
11.0,
summary_color,
);
let layout = self.remotes_layout(panel);
// URL input + "Set" / "SSH" buttons.
self.paint_section_input(
cx,
layout.input,
&self.state.remote_input,
self.state.remote_focused,
self.t("git.panel.remotePlaceholder"),
);
self.paint_button_with_hit(
cx,
layout.set_button,
self.t("git.panel.set"),
true,
false,
Some(GitPanelHit::SetRemote),
);
// "SSH" is a protocol name — the same in every locale.
self.paint_button_with_hit(
cx,
layout.ssh_button,
"SSH",
true,
false,
Some(GitPanelHit::SetupSshAuth),
);
// HTTPS-credential input (token masked) + "Login" button.
self.paint_masked_section_input(
cx,
layout.https_input,
&self.state.https_input,
self.state.https_focused,
self.t("git.panel.httpsPlaceholder"),
);
self.paint_button_with_hit(
cx,
layout.login_button,
self.t("git.panel.login"),
true,
false,
Some(GitPanelHit::SetHttpsAuth),
);
}
/// Paint one Remotes-section text input — fill, focus border,
/// `shown` text (or `placeholder` when empty + unfocused) and a
/// caret while focused.
fn paint_section_input(
&self,
cx: &mut PaintCx<'_>,
rect: Rect,
input: &TextInputState,
focused: bool,
placeholder: &str,
) {
cx.backend.fill_round_rect(rect, 6.0, self.theme.muted);
let border = if focused {
self.theme.primary
} else {
self.theme.border
};
cx.backend.stroke_round_rect(rect, 6.0, border, 1.0);
self.paint_text_input_view(cx, rect, input, placeholder, focused, 11.0, 8.0);
}
/// Same chrome as [`Self::paint_section_input`], but the glyphs render
/// through `mask_credential` so the token shows as bullets. The caret /
/// selection geometry is handled by the unified `TextInputView` (which
/// remaps source↔masked by char index), so there is no bespoke caret math.
fn paint_masked_section_input(
&self,
cx: &mut PaintCx<'_>,
rect: Rect,
input: &TextInputState,
focused: bool,
placeholder: &str,
) {
cx.backend.fill_round_rect(rect, 6.0, self.theme.muted);
let border = if focused {
self.theme.primary
} else {
self.theme.border
};
cx.backend.stroke_round_rect(rect, 6.0, border, 1.0);
self.paint_text_input_view_masked(
cx,
rect,
input,
placeholder,
focused,
11.0,
8.0,
Some(&mask_credential),
);
}
}
/// Mask the token half of a `username:token` draft — the username
/// stays visible, the token renders as bullets so a shoulder-surfer
/// cannot read it. A draft with no `:` yet (still typing the name)
/// is shown verbatim. Char-count-preserving so `TextInputView` can remap
/// the caret by char index.
fn mask_credential(draft: &str) -> String {
match draft.split_once(':') {
Some((user, token)) => format!("{user}:{}", "•".repeat(token.chars().count())),
None => draft.to_string(),
}
}