refactor(editor): migrate git clone form to shared text state
This commit is contained in:
parent
7133ab243b
commit
ce3626da2d
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue