refactor(editor): migrate git clone form to shared text state

This commit is contained in:
Kayshen-X 2026-06-14 00:56:29 +08:00
parent 7133ab243b
commit ce3626da2d
10 changed files with 126 additions and 128 deletions

View file

@ -457,12 +457,12 @@ pub enum CloneField {
/// `GitPanelCloneForm`). Reached from the empty-state Clone card. Plain
/// data so the widget layer stays wasm-clean; the desktop host owns the
/// folder picker + the `git clone` job.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CloneFormState {
/// Remote URL draft.
pub url: String,
pub url_input: jian_core::text_input::TextInputState,
/// Local destination-folder draft.
pub dest: String,
pub dest_input: jian_core::text_input::TextInputState,
/// Which field has keyboard focus (`None` = no caret).
pub focus: Option<CloneField>,
/// `true` while the `git clone` worker runs — disables the form.
@ -470,12 +470,6 @@ pub struct CloneFormState {
/// Last clone error (validation or a failed `git clone`), shown
/// under the fields.
pub error: Option<String>,
/// Caret-blink anchor for the focused field — same cadence as the
/// commit input.
pub caret_anchor_ms: u64,
/// True after Cmd/Ctrl+A in the focused clone input. The next edit
/// replaces the whole focused field.
pub input_select_all: bool,
}
/// Git panel state — a plain-data snapshot the desktop host fills
@ -657,7 +651,10 @@ impl GitPanelState {
.clone_form
.as_mut()
.map(|form| {
form.input_select_all = false;
let url_caret = form.url_input.caret();
form.url_input.set_caret(url_caret, 0);
let dest_caret = form.dest_input.caret();
form.dest_input.set_caret(dest_caret, 0);
form.focus.take().is_some()
})
.unwrap_or(false);

View file

@ -91,10 +91,11 @@ impl GitPanel<'_> {
/// `true` when the form carries both a URL + destination and is not
/// already cloning — gates the Clone button.
fn clone_can_submit(&self) -> bool {
self.state
.clone_form
.as_ref()
.is_some_and(|f| !f.cloning && !f.url.trim().is_empty() && !f.dest.trim().is_empty())
self.state.clone_form.as_ref().is_some_and(|f| {
!f.cloning
&& !f.url_input.text().trim().is_empty()
&& !f.dest_input.text().trim().is_empty()
})
}
/// Paint the inline clone wizard.
@ -133,14 +134,16 @@ impl GitPanel<'_> {
11.0,
t.muted_foreground,
);
self.paint_clone_input(
let url_focused = form.focus == Some(CloneField::Url) && !form.cloning;
self.paint_clone_input_frame(cx, layout.url_input, url_focused);
self.paint_text_input_view(
cx,
layout.url_input,
&form.url,
&form.url_input,
self.t("git.wizard.clone.urlPlaceholder"),
form.focus == Some(CloneField::Url) && !form.cloning,
form.input_select_all,
form.caret_anchor_ms,
url_focused,
12.0,
10.0,
);
// Destination field + folder-pick button.
self.text(
@ -151,14 +154,16 @@ impl GitPanel<'_> {
11.0,
t.muted_foreground,
);
self.paint_clone_input(
let dest_focused = form.focus == Some(CloneField::Dest) && !form.cloning;
self.paint_clone_input_frame(cx, layout.dest_input, dest_focused);
self.paint_text_input_view(
cx,
layout.dest_input,
&form.dest,
&form.dest_input,
self.t("git.wizard.clone.destPlaceholder"),
form.focus == Some(CloneField::Dest) && !form.cloning,
form.input_select_all,
form.caret_anchor_ms,
dest_focused,
12.0,
10.0,
);
self.paint_button_with_hit(
cx,
@ -192,19 +197,7 @@ impl GitPanel<'_> {
);
}
/// One bordered text field with a placeholder + blinking caret —
/// same visual language as the ready-view commit box.
#[allow(clippy::too_many_arguments)]
fn paint_clone_input(
&self,
cx: &mut PaintCx<'_>,
rect: Rect,
value: &str,
placeholder: &str,
focused: bool,
select_all: bool,
anchor_ms: u64,
) {
fn paint_clone_input_frame(&self, cx: &mut PaintCx<'_>, rect: Rect, focused: bool) {
let t = self.theme;
cx.backend.fill_round_rect(rect, 6.0, t.card);
let border = if focused {
@ -213,46 +206,6 @@ impl GitPanel<'_> {
alpha(t.border, 0.7)
};
cx.backend.stroke_round_rect(rect, 6.0, border, 1.0);
let text_x = rect.origin.x + 10.0;
let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0;
if value.is_empty() && !focused {
self.text(
cx,
placeholder,
text_x,
baseline,
12.0,
alpha(t.muted_foreground, 0.7),
);
return;
}
// A long URL / path can exceed the field — clip so it never
// bleeds past the border.
cx.backend.save();
cx.backend.clip_rect(rect);
if focused && select_all && !value.is_empty() {
crate::widgets::text_selection::paint_single_line_selection(
cx,
&t,
value,
text_x,
baseline,
12.0,
rect.origin.x + rect.size.x - 10.0,
);
}
self.text(cx, value, text_x, baseline, 12.0, t.foreground);
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 {
origin: Point2D::new(caret_x, rect.origin.y + 8.0),
size: Point2D::new(1.5, rect.size.y - 16.0),
},
t.foreground,
);
}
cx.backend.restore();
}
/// Map a press inside the clone view onto a [`GitPanelHit`].

View file

@ -785,8 +785,10 @@ fn clone_form_takes_over_and_maps_each_target() {
open: true,
in_repo: false,
clone_form: Some(CloneFormState {
url: "https://github.com/owner/repo.git".into(),
dest: "/tmp/repo".into(),
url_input: jian_core::text_input::TextInputState::with_text(
"https://github.com/owner/repo.git",
),
dest_input: jian_core::text_input::TextInputState::with_text("/tmp/repo"),
focus: Some(CloneField::Url),
..Default::default()
}),
@ -833,8 +835,10 @@ fn clone_view_locks_to_cancel_only_while_cloning() {
open: true,
in_repo: false,
clone_form: Some(CloneFormState {
url: "https://github.com/owner/repo.git".into(),
dest: "/tmp/repo".into(),
url_input: jian_core::text_input::TextInputState::with_text(
"https://github.com/owner/repo.git",
),
dest_input: jian_core::text_input::TextInputState::with_text("/tmp/repo"),
cloning: true,
..Default::default()
}),

View file

@ -771,7 +771,7 @@ impl DesktopApp {
}
/// Clone wizard "浏览…" — a native folder picker for the clone
/// destination, written back into the form's `dest` field.
/// destination, written back into the form's destination input.
fn pick_clone_dest(&mut self) {
let Some(folder) = rfd::FileDialog::new().pick_folder() else {
return;
@ -785,7 +785,7 @@ impl DesktopApp {
.clone_form
.as_mut()
{
form.dest = path;
form.dest_input.set_text(path);
form.focus = Some(op_editor_core::CloneField::Dest);
form.error = None;
}
@ -810,7 +810,10 @@ impl DesktopApp {
else {
return;
};
(form.url.trim().to_string(), form.dest.trim().to_string())
(
form.url_input.text().trim().to_string(),
form.dest_input.text().trim().to_string(),
)
};
let validation = if url.is_empty() {
Some("git.wizard.clone.validationUrl")

View file

@ -954,12 +954,17 @@ impl WidgetHostNative {
// the loop ticking so the caret blinks and `poll_git_clone_job`
// drains the worker's result on a later frame.
if let Some(form) = &self.editor_state.editor_ui.git_panel.clone_form {
if form.focus.is_some() || form.cloning {
return Some(jian_core::anim::next_blink_flip_ms(
self.now_ms,
form.caret_anchor_ms,
500,
));
match form.focus {
Some(op_editor_core::CloneField::Url) => {
return Some(form.url_input.next_blink_flip_ms(self.now_ms));
}
Some(op_editor_core::CloneField::Dest) => {
return Some(form.dest_input.next_blink_flip_ms(self.now_ms));
}
None if form.cloning => {
return Some(self.now_ms + jian_core::text_input::CARET_BLINK_PERIOD_MS);
}
None => {}
}
}
None

View file

@ -61,12 +61,15 @@ fn canvas_press_defocuses_every_git_input() {
git.author_name_focused = true;
git.author_email_focused = true;
git.branch_create_focused = true;
git.clone_form = Some(CloneFormState {
url: "https://example.com/repo.git".into(),
dest: "/tmp/repo".into(),
let mut clone_form = CloneFormState {
focus: Some(CloneField::Url),
..Default::default()
});
};
clone_form
.url_input
.set_text("https://example.com/repo.git");
clone_form.dest_input.set_text("/tmp/repo");
git.clone_form = Some(clone_form);
}
// Empty canvas — right of the chat panel (which floats bottom-left,

View file

@ -391,7 +391,8 @@ fn clone_wizard_owns_keyboard_and_enter() {
.clone_form
.as_ref()
.unwrap()
.url,
.url_input
.text(),
"http"
);
// Enter on a focused field requests the clone.
@ -435,7 +436,8 @@ fn hidden_clone_form_does_not_capture_keyboard() {
.clone_form
.as_ref()
.unwrap()
.url,
.url_input
.text(),
"",
"a hidden clone form must not capture keystrokes"
);
@ -482,7 +484,35 @@ fn clone_wizard_accepts_pasted_url() {
.clone_form
.as_ref()
.unwrap()
.url,
.url_input
.text(),
"https://github.com/owner/repo.git"
);
}
#[test]
fn clone_wizard_select_all_replaces_only_the_focused_field() {
use op_editor_core::{CloneField, CloneFormState};
let mut host = host_with_git_panel_open();
let mut form = CloneFormState {
focus: Some(CloneField::Dest),
..Default::default()
};
form.url_input.set_text("https://github.com/owner/repo.git");
form.dest_input.set_text("/tmp/repo");
host.editor_state_mut().editor_ui.git_panel.clone_form = Some(form);
assert!(host.apply_select_all());
assert!(host.apply_text('x'));
let form = host
.editor_state()
.editor_ui
.git_panel
.clone_form
.as_ref()
.unwrap();
assert_eq!(form.url_input.text(), "https://github.com/owner/repo.git");
assert_eq!(form.dest_input.text(), "x");
assert_eq!(form.dest_input.caret(), 1);
}

View file

@ -149,14 +149,14 @@ impl WidgetHostNative {
Some(GitPanelHit::CloneUrlInput) => {
if let Some(form) = panel.clone_form.as_mut() {
form.focus = Some(CloneField::Url);
form.caret_anchor_ms = now;
form.url_input.touch(now);
form.error = None;
}
}
Some(GitPanelHit::CloneDestInput) => {
if let Some(form) = panel.clone_form.as_mut() {
form.focus = Some(CloneField::Dest);
form.caret_anchor_ms = now;
form.dest_input.touch(now);
form.error = None;
}
}

View file

@ -54,20 +54,16 @@ impl WidgetHostNative {
let now = self.now_ms;
if let Some(form) = self.editor_state.editor_ui.git_panel.clone_form.as_mut() {
if !form.cloning {
if form.input_select_all {
match form.focus {
Some(op_editor_core::CloneField::Url) => form.url.clear(),
Some(op_editor_core::CloneField::Dest) => form.dest.clear(),
None => {}
}
form.input_select_all = false;
}
let mut s = [0u8; 4];
match form.focus {
Some(op_editor_core::CloneField::Url) => form.url.push(c),
Some(op_editor_core::CloneField::Dest) => form.dest.push(c),
Some(op_editor_core::CloneField::Url) => {
form.url_input.insert_str(c.encode_utf8(&mut s), now)
}
Some(op_editor_core::CloneField::Dest) => {
form.dest_input.insert_str(c.encode_utf8(&mut s), now)
}
None => {}
}
form.caret_anchor_ms = now;
form.error = None;
}
}
@ -452,19 +448,14 @@ impl WidgetHostNative {
// focused field that isn't mid-clone.
if let Some(form) = self.editor_state.editor_ui.git_panel.clone_form.as_mut() {
if !form.cloning {
if form.input_select_all {
match form.focus {
Some(op_editor_core::CloneField::Url) => form.url.clear(),
Some(op_editor_core::CloneField::Dest) => form.dest.clear(),
None => {}
match form.focus {
Some(op_editor_core::CloneField::Url) => {
form.url_input.backspace(self.now_ms)
}
form.input_select_all = false;
} else {
match form.focus {
Some(op_editor_core::CloneField::Url) => form.url.pop(),
Some(op_editor_core::CloneField::Dest) => form.dest.pop(),
None => None,
};
Some(op_editor_core::CloneField::Dest) => {
form.dest_input.backspace(self.now_ms)
}
None => {}
}
form.error = None;
}
@ -1358,7 +1349,10 @@ impl WidgetHostNative {
.clone_form
.as_mut()
.unwrap();
form.input_select_all = false;
let url_caret = form.url_input.caret();
form.url_input.set_caret(url_caret, self.now_ms);
let dest_caret = form.dest_input.caret();
form.dest_input.set_caret(dest_caret, self.now_ms);
form.focus.take().is_some()
};
if !defocused {

View file

@ -96,11 +96,20 @@ impl WidgetHostNative {
}
if self.git_clone_input_active() {
if let Some(form) = self.editor_state.editor_ui.git_panel.clone_form.as_mut() {
if form.focus.is_some() {
form.input_select_all = true;
form.caret_anchor_ms = self.now_ms;
self.mark_dirty();
return true;
match form.focus {
Some(op_editor_core::CloneField::Url) => {
form.url_input.select_all();
form.url_input.touch(self.now_ms);
self.mark_dirty();
return true;
}
Some(op_editor_core::CloneField::Dest) => {
form.dest_input.select_all();
form.dest_input.touch(self.now_ms);
self.mark_dirty();
return true;
}
None => {}
}
}
return false;