Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d06827b69 | ||
|
|
8a6cb81d3a | ||
|
|
5b0a0c2001 | ||
|
|
df9013ffdf | ||
|
|
39341b278c | ||
|
|
cd66dbf7d8 | ||
|
|
6bb812f6bd | ||
|
|
5de04af36a |
@@ -1349,9 +1349,7 @@ mod tests {
|
||||
for c in "crimson-otter".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
||||
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
||||
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
||||
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
|
||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||
|
||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||
|
||||
+339
-49
@@ -80,6 +80,9 @@ pub enum DraftField {
|
||||
/// apply immediately, mirroring the web form's per-click choice.
|
||||
WorktreeChoice,
|
||||
Name,
|
||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
||||
/// Closed by default; Enter / Space toggles.
|
||||
Advanced,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
/// New-vs-existing branch source; Left / Right switches.
|
||||
@@ -196,6 +199,12 @@ pub struct DraftPane {
|
||||
/// Highlighted entry within the visible step-one list; the implicit
|
||||
/// `Other directory…` entry sits at index `visible_groups().len()`.
|
||||
pub highlighted: usize,
|
||||
/// Set by an explicit Up/Down move, cleared by typing (#97): typing
|
||||
/// mirrors the web form's plain filter input, where Enter always
|
||||
/// resolves and submits; an explicit arrow move mirrors Tab landing on
|
||||
/// a not-yet-active picker button there, where a first Enter only
|
||||
/// chooses. See `dir_picker_choice_active` at the Enter match arm.
|
||||
dir_picker_navigated: bool,
|
||||
pub cwd_raw_visible: bool,
|
||||
pub cwd_raw: crate::text_input::TextInput,
|
||||
pub picked_cwd: Option<PathBuf>,
|
||||
@@ -215,6 +224,10 @@ pub struct DraftPane {
|
||||
/// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe
|
||||
/// unticks it, like the web form.
|
||||
pub worktree_enabled: bool,
|
||||
/// Whether the worktree/branch controls are collapsed behind the
|
||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||
/// take the default target.
|
||||
pub advanced_collapsed: bool,
|
||||
/// Enclosing repository of the chosen target — from the project scan for
|
||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||
pub base_repo: Option<PathBuf>,
|
||||
@@ -263,6 +276,7 @@ impl Default for DraftPane {
|
||||
groups: Vec::new(),
|
||||
filter: crate::text_input::TextInput::default(),
|
||||
highlighted: 0,
|
||||
dir_picker_navigated: false,
|
||||
cwd_raw_visible: false,
|
||||
cwd_raw: crate::text_input::TextInput::default(),
|
||||
picked_cwd: None,
|
||||
@@ -273,6 +287,7 @@ impl Default for DraftPane {
|
||||
name_touched: false,
|
||||
needs_name_refill: false,
|
||||
worktree_enabled: true,
|
||||
advanced_collapsed: true,
|
||||
base_repo: None,
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
@@ -511,6 +526,21 @@ impl DraftPane {
|
||||
self.set_branch_mode(next);
|
||||
}
|
||||
|
||||
/// Enter / Space on the "Advanced options" header (#93).
|
||||
pub fn toggle_advanced(&mut self) {
|
||||
self.advanced_collapsed = !self.advanced_collapsed;
|
||||
}
|
||||
|
||||
/// What the collapsed header says about this spawn's default, so it stays
|
||||
/// visible without expanding (#93).
|
||||
fn advanced_summary(&self) -> &'static str {
|
||||
if self.worktree_enabled {
|
||||
"creates a worktree"
|
||||
} else {
|
||||
"no worktree"
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter / Space on the toggle. Re-enabling reuses a still-valid repo
|
||||
/// answer instead of re-probing.
|
||||
pub fn toggle_worktree(&mut self) {
|
||||
@@ -673,13 +703,16 @@ impl DraftPane {
|
||||
order.push(DraftField::WorktreeChoice);
|
||||
}
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::WorktreeToggle);
|
||||
if self.worktree_body_visible() {
|
||||
order.push(DraftField::BranchMode);
|
||||
order.push(match self.branch_mode {
|
||||
WorktreeBranchMode::New => DraftField::BranchNew,
|
||||
WorktreeBranchMode::Existing => DraftField::BranchExisting,
|
||||
});
|
||||
order.push(DraftField::Advanced);
|
||||
if !self.advanced_collapsed {
|
||||
order.push(DraftField::WorktreeToggle);
|
||||
if self.worktree_body_visible() {
|
||||
order.push(DraftField::BranchMode);
|
||||
order.push(match self.branch_mode {
|
||||
WorktreeBranchMode::New => DraftField::BranchNew,
|
||||
WorktreeBranchMode::Existing => DraftField::BranchExisting,
|
||||
});
|
||||
}
|
||||
}
|
||||
order.extend([DraftField::Submit]);
|
||||
order
|
||||
@@ -743,6 +776,7 @@ impl DraftPane {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
self.dir_picker_navigated = true;
|
||||
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
|
||||
}
|
||||
|
||||
@@ -778,6 +812,17 @@ impl DraftPane {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Whether the highlighted step-one row is already the picked group
|
||||
/// (#97): once true, a further Enter submits instead of re-picking it.
|
||||
/// Always false on the trailing "Other directory…" row — picking it is
|
||||
/// never a no-op, so it always takes the Enter to reveal the raw field.
|
||||
fn dir_picker_choice_active(&self) -> bool {
|
||||
let visible = self.visible_groups();
|
||||
let sel = self.selection();
|
||||
sel < visible.len()
|
||||
&& self.selected_group_key.as_ref() == Some(&group_key(&visible[sel].main))
|
||||
}
|
||||
|
||||
/// Act on the highlighted step-one box: reveals step two, defaulting per
|
||||
/// rule 4. The trailing entry reveals the free-text path input and
|
||||
/// clears both steps (rule 3).
|
||||
@@ -907,23 +952,41 @@ impl DraftPane {
|
||||
self.prev_field();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => match self.focused_field {
|
||||
DraftField::Submit => DraftInputResult::Submit,
|
||||
DraftField::DirPicker => {
|
||||
self.select_entry();
|
||||
// Enter submits from any field (#97), mirroring web/src/spawnForm.ts.
|
||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||
// highlighted row isn't the picked one until a first Enter chooses
|
||||
// it (the next Enter, now that it's active, falls through to
|
||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
||||
// than submitting, same as the web form's own `<button>` controls.
|
||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||
let choose_only =
|
||||
is_other_row || (self.dir_picker_navigated && !self.dir_picker_choice_active());
|
||||
self.select_entry();
|
||||
if choose_only {
|
||||
DraftInputResult::Consumed
|
||||
} else {
|
||||
DraftInputResult::Submit
|
||||
}
|
||||
DraftField::WorktreeToggle => {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
// The existing-branch selection is live; Enter just confirms.
|
||||
DraftField::BranchExisting => DraftInputResult::Consumed,
|
||||
_ => {
|
||||
self.next_field();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
},
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::Advanced => {
|
||||
self.toggle_advanced();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::WorktreeToggle => {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => DraftInputResult::Submit,
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
ctrl: false,
|
||||
..
|
||||
} if self.focused_field == DraftField::Advanced => {
|
||||
self.toggle_advanced();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
ctrl: false,
|
||||
@@ -987,6 +1050,7 @@ impl DraftPane {
|
||||
DraftField::DirPicker => {
|
||||
self.filter.handle_key(other);
|
||||
self.highlighted = 0;
|
||||
self.dir_picker_navigated = false;
|
||||
}
|
||||
DraftField::CwdRaw => {
|
||||
self.cwd_raw.handle_key(other);
|
||||
@@ -1020,6 +1084,13 @@ impl DraftPane {
|
||||
|
||||
let show_custom = self.is_custom();
|
||||
let show_raw = self.cwd_raw_visible;
|
||||
// The worktree toggle and its body stay behind the "Advanced options"
|
||||
// disclosure (#93): collapsed hides both, regardless of what they'd
|
||||
// otherwise show. The worktree *choice* step is not part of that —
|
||||
// it belongs to the directory picker (#95 rule 3), so it stays
|
||||
// visible once a project is picked.
|
||||
let show_wt_toggle = !self.advanced_collapsed;
|
||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||
let show_wt_choice = self.selected_group_key.is_some() && !show_raw;
|
||||
let wt_choice_rows_data = if show_wt_choice {
|
||||
self.worktree_choice_rows()
|
||||
@@ -1029,13 +1100,13 @@ impl DraftPane {
|
||||
// Up to four choices (new + worktrees) stay visible around the
|
||||
// selection.
|
||||
let wt_choice_capacity = wt_choice_rows_data.len().clamp(1, 4) as u16;
|
||||
let show_wt_body = self.worktree_body_visible();
|
||||
// Up to three branch rows stay visible around the selection; held
|
||||
// branches need their reason on screen, so they aren't collapsed.
|
||||
let branch_rows = self.branches.len().clamp(1, 3) as u16;
|
||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */
|
||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||
+ u16::from(show_custom)
|
||||
+ u16::from(show_raw)
|
||||
+ u16::from(show_wt_toggle)
|
||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||
+ u16::from(self.error.is_some());
|
||||
@@ -1053,7 +1124,10 @@ impl DraftPane {
|
||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // worktree toggle
|
||||
constraints.push(Constraint::Length(1)); // advanced header
|
||||
if show_wt_toggle {
|
||||
constraints.push(Constraint::Length(1)); // worktree toggle
|
||||
}
|
||||
if show_wt_body {
|
||||
constraints.push(Constraint::Length(1)); // branch mode
|
||||
match self.branch_mode {
|
||||
@@ -1268,33 +1342,55 @@ impl DraftPane {
|
||||
},
|
||||
);
|
||||
|
||||
// -- advanced disclosure (#93) ------------------------------------------
|
||||
let advanced_row = next_row();
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(format!("{:<GUTTER$}", "adv:")),
|
||||
Span::styled(
|
||||
format!(
|
||||
"[{}] Advanced options",
|
||||
if self.advanced_collapsed { "+" } else { "-" }
|
||||
),
|
||||
field_style(DraftField::Advanced),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" — {}", self.advanced_summary()),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
),
|
||||
])),
|
||||
advanced_row,
|
||||
);
|
||||
|
||||
// -- worktree (#65) -----------------------------------------------------
|
||||
let wt_row = next_row();
|
||||
let hint = if self.cwd().is_none() {
|
||||
Some("choose a directory first")
|
||||
} else if self.repo_missing {
|
||||
Some("not inside a git repository")
|
||||
} else if !self.worktree_enabled {
|
||||
Some("off — spawns in the checkout itself")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "wt:"))];
|
||||
tiles.push(Span::styled(
|
||||
if self.worktree_enabled {
|
||||
"[x] worktree"
|
||||
if show_wt_toggle {
|
||||
let wt_row = next_row();
|
||||
let hint = if self.cwd().is_none() {
|
||||
Some("choose a directory first")
|
||||
} else if self.repo_missing {
|
||||
Some("not inside a git repository")
|
||||
} else if !self.worktree_enabled {
|
||||
Some("off — spawns in the checkout itself")
|
||||
} else {
|
||||
"[ ] worktree"
|
||||
},
|
||||
field_style(DraftField::WorktreeToggle),
|
||||
));
|
||||
if let Some(hint) = hint {
|
||||
None
|
||||
};
|
||||
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "wt:"))];
|
||||
tiles.push(Span::styled(
|
||||
format!(" {hint}"),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
if self.worktree_enabled {
|
||||
"[x] worktree"
|
||||
} else {
|
||||
"[ ] worktree"
|
||||
},
|
||||
field_style(DraftField::WorktreeToggle),
|
||||
));
|
||||
if let Some(hint) = hint {
|
||||
tiles.push(Span::styled(
|
||||
format!(" {hint}"),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(tiles)), wt_row);
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(tiles)), wt_row);
|
||||
|
||||
if show_wt_body {
|
||||
// branch source: new (created) vs existing (checked out as-is)
|
||||
@@ -1677,7 +1773,11 @@ mod tests {
|
||||
1,
|
||||
"type-to-filter narrows the list"
|
||||
);
|
||||
draft.on_input(&InputKey::Enter);
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Submit,
|
||||
"#97: the project filter submits with the highlighted project in one Enter"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/maestro"))
|
||||
@@ -1689,6 +1789,113 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_submits_immediately_from_ordinary_fields_97() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/repos/maestro")),
|
||||
base_repo: Some(PathBuf::from("/repos/maestro")),
|
||||
probed_cwd: Some(PathBuf::from("/repos/maestro")),
|
||||
..DraftPane::default()
|
||||
};
|
||||
|
||||
for field in [
|
||||
DraftField::Preset,
|
||||
DraftField::Name,
|
||||
DraftField::BranchNew,
|
||||
DraftField::BranchMode,
|
||||
] {
|
||||
draft.focused_field = field;
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Submit,
|
||||
"{field:?} should submit on Enter"
|
||||
);
|
||||
}
|
||||
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||
draft.focused_field = DraftField::CustomCommand;
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_submits_immediately_from_eager_apply_pickers_97() {
|
||||
// WorktreeChoice and BranchExisting apply their highlight the moment
|
||||
// Up/Down moves it (#96 rule 3/#54) — there is no separate "chosen
|
||||
// vs highlighted" state left to confirm, so Enter always submits.
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![repo_project(
|
||||
"maestro",
|
||||
"/repos/maestro",
|
||||
"/repos/maestro",
|
||||
1,
|
||||
None,
|
||||
)],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter); // picks "maestro", reveals step two
|
||||
|
||||
draft.focused_field = DraftField::WorktreeChoice;
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
|
||||
draft.focused_field = DraftField::BranchExisting;
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_picker_enter_chooses_then_submits_once_the_highlight_is_the_active_pick_97() {
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![
|
||||
project("maestro", "/repos/maestro", 1, Some("claude")),
|
||||
project("other", "/repos/other", 2, Some("zsh")),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter); // picks "maestro" (nothing chosen yet, submits)
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/maestro"))
|
||||
);
|
||||
|
||||
// Arrow onto the other row: an explicit navigation away from the
|
||||
// active pick is "mid-selection" — Enter here only chooses it.
|
||||
draft.move_selection(1);
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Consumed,
|
||||
"navigating to a different row must not submit the old pick"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/other")),
|
||||
"the new row is chosen"
|
||||
);
|
||||
|
||||
// The highlight is now the active pick: the next Enter submits.
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
|
||||
let mut draft = DraftPane::default();
|
||||
draft.set_projects(
|
||||
vec![project("maestro", "/repos/maestro", 1, None)],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.move_selection(1); // onto "Other directory…"
|
||||
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Consumed,
|
||||
"reveals the raw field instead of submitting, like the web form's own button"
|
||||
);
|
||||
assert_eq!(draft.focused_field, DraftField::CwdRaw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_down_moves_the_highlight_within_the_list() {
|
||||
let mut draft = DraftPane::default();
|
||||
@@ -2229,7 +2436,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn held_branches_render_unavailable_with_their_holder_as_the_reason() {
|
||||
let mut draft = DraftPane::default();
|
||||
let mut draft = DraftPane {
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![repo_project(
|
||||
"maestro",
|
||||
@@ -2443,6 +2653,7 @@ mod tests {
|
||||
fn render_shows_the_worktree_section() {
|
||||
let mut draft = DraftPane {
|
||||
home_dir: "/home/u".into(),
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
@@ -2481,6 +2692,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_section_is_collapsed_by_default_and_states_the_worktree_default() {
|
||||
let mut draft = DraftPane {
|
||||
home_dir: "/home/u".into(),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
|
||||
Utc::now(),
|
||||
);
|
||||
assert!(draft.advanced_collapsed, "closed by default");
|
||||
assert_eq!(
|
||||
draft
|
||||
.field_order()
|
||||
.iter()
|
||||
.filter(|f| matches!(f, DraftField::WorktreeToggle | DraftField::BranchMode))
|
||||
.count(),
|
||||
0,
|
||||
"collapsed fields drop out of tab order"
|
||||
);
|
||||
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(
|
||||
rendered.contains("Advanced options") && rendered.contains("creates a worktree"),
|
||||
"header states the on-by-default worktree:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("[x] worktree"),
|
||||
"checkbox stays hidden while collapsed:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_header_states_no_worktree_for_a_worktree_row_91() {
|
||||
let mut draft = DraftPane::default();
|
||||
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
|
||||
wt.is_worktree = true;
|
||||
draft.set_projects(vec![wt], Utc::now());
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
draft.on_input(&InputKey::Enter);
|
||||
|
||||
assert!(!draft.worktree_enabled);
|
||||
assert!(
|
||||
draft.advanced_collapsed,
|
||||
"picking a row must not force it open"
|
||||
);
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(
|
||||
rendered.contains("no worktree"),
|
||||
"header reflects #91's off default:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_the_advanced_header_expands_it_without_moving_fields_above() {
|
||||
let mut draft = DraftPane {
|
||||
home_dir: "/home/u".into(),
|
||||
focused_field: DraftField::Advanced,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
|
||||
Utc::now(),
|
||||
);
|
||||
let before = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
let before_head: Vec<&str> = before.lines().take(6).collect();
|
||||
|
||||
draft.on_input(&InputKey::Enter);
|
||||
assert!(!draft.advanced_collapsed);
|
||||
|
||||
let after = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
let after_head: Vec<&str> = after.lines().take(6).collect();
|
||||
assert_eq!(
|
||||
before_head, after_head,
|
||||
"expanding must not move the fields above the disclosure"
|
||||
);
|
||||
assert!(after.contains("[x] worktree"), "body now visible:\n{after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_path_matches_the_web_helpers() {
|
||||
assert_eq!(
|
||||
|
||||
+88
-22
@@ -152,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||
<span class="chevron">▾</span>
|
||||
<span class="folder-name">Advanced options</span>
|
||||
<span class="wt-summary"></span>
|
||||
</button>
|
||||
<div class="wt-collapsible" hidden>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
</legend>
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||
@@ -200,6 +205,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||
const wtSummary = form.querySelector(".wt-summary") as HTMLElement;
|
||||
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||
@@ -526,9 +534,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
markActiveChoice();
|
||||
if (choice === "new") {
|
||||
wtEnabled.checked = true;
|
||||
updateWtSummary();
|
||||
applyProjectRepo(group.main.base_repo ?? group.main.path);
|
||||
} else {
|
||||
wtEnabled.checked = false;
|
||||
updateWtSummary();
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
@@ -539,11 +549,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||
function visibleGroups(query: string): ProjectGroup[] {
|
||||
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
}
|
||||
|
||||
function renderProjects(): void {
|
||||
projectsEl.replaceChildren();
|
||||
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||
const query = filterInput.value;
|
||||
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
const visible = visibleGroups(filterInput.value);
|
||||
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||
if (discovered.length > 0 && visible.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
@@ -661,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
|
||||
let advancedCollapsed = true;
|
||||
function setAdvancedCollapsed(collapsed: boolean): void {
|
||||
advancedCollapsed = collapsed;
|
||||
wtToggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
wtCollapsible.hidden = collapsed;
|
||||
}
|
||||
function updateWtSummary(): void {
|
||||
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
|
||||
}
|
||||
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
|
||||
|
||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||
// The project scan already reports repo membership, so a picked project
|
||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
||||
@@ -741,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// Publish the untick too, or the next render seeds the box checked with
|
||||
// no inferred repo and submitting fails.
|
||||
wtEnabled.checked = false;
|
||||
updateWtSummary();
|
||||
emit();
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
@@ -814,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
branchSelect.addEventListener("change", () => emit());
|
||||
|
||||
wtEnabled.addEventListener("change", () => {
|
||||
updateWtSummary();
|
||||
emit();
|
||||
if (wtEnabled.checked) {
|
||||
resolveBaseRepo();
|
||||
@@ -827,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// The toggle defaults ON (#61): the operator almost always wants a
|
||||
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||
updateWtSummary();
|
||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||
setBranchMode(branchMode);
|
||||
|
||||
@@ -837,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
||||
|
||||
// ---- Enter submits from anywhere (#97) --------------------------------
|
||||
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
|
||||
// choose it instead; the next Enter, now that it's active, submits.
|
||||
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
|
||||
// when it handles the key, which the `e.defaultPrevented` guard defers to.
|
||||
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
|
||||
// does the same) rather than folding it into the blanket-submit rule below.
|
||||
form.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target === wtEnabled) {
|
||||
e.preventDefault();
|
||||
wtEnabled.checked = !wtEnabled.checked;
|
||||
wtEnabled.dispatchEvent(new Event("change"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (target instanceof HTMLButtonElement) {
|
||||
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
|
||||
e.preventDefault();
|
||||
if (!target.classList.contains("is-active")) {
|
||||
target.click();
|
||||
return;
|
||||
}
|
||||
form.requestSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (target === filterInput) {
|
||||
const top = visibleGroups(filterInput.value)[0];
|
||||
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
|
||||
}
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
// ---- submit ----------------------------------------------------------
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Enter submits the draft form from any field, at any point (#97). The two
|
||||
// exceptions: a picker button that isn't yet the active choice takes the
|
||||
// Enter to *choose* it instead (the next Enter submits), and the project
|
||||
// filter must resolve to the highlighted project rather than sending
|
||||
// whatever cwd was already set.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const MAESTRO: Project = {
|
||||
path: "/home/user/tea/maestro",
|
||||
name: "maestro",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
};
|
||||
|
||||
const WT_A: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
||||
name: "bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-22T10:00:00Z",
|
||||
};
|
||||
|
||||
const OTHER: Project = {
|
||||
path: "/home/user/tea/rev",
|
||||
name: "rev",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-19T10:00:00Z",
|
||||
};
|
||||
|
||||
function stubFetch(projects: Project[]): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["amber-fox"] }
|
||||
: url.includes("/api/branches")
|
||||
? []
|
||||
: url.includes("/api/dirs")
|
||||
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
|
||||
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||
stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function submitSpy(): ReturnType<typeof vi.fn> {
|
||||
return vi.fn();
|
||||
}
|
||||
|
||||
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
|
||||
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(evt);
|
||||
return { defaultPrevented: evt.defaultPrevented };
|
||||
}
|
||||
|
||||
const box = (form: HTMLFormElement, path: string) =>
|
||||
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||
|
||||
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||
expect(toggle.checked).toBe(true);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(toggle);
|
||||
expect(toggle.checked).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(toggle);
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("submits immediately from ordinary fields", () => {
|
||||
it("name field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("worktree branch field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("custom command field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
raw.value = "bash -l";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(raw);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raw cwd field once the autocomplete dropdown is closed", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = "/opt/xyz-nothing-here";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(raw);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.focus();
|
||||
raw.value = "/opt/";
|
||||
raw.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
const { defaultPrevented } = pressEnter(raw);
|
||||
|
||||
expect(defaultPrevented).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(raw.value).toBe("/opt/nomatch-zzz/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two-step picker: first Enter chooses, second submits", () => {
|
||||
it("project box", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
const b = box(form, MAESTRO.path);
|
||||
b.focus();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
const first = pressEnter(b);
|
||||
expect(first.defaultPrevented).toBe(true);
|
||||
expect(b.classList.contains("is-active")).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(b);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("worktree choice", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
|
||||
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(choice);
|
||||
expect(choice.classList.contains("is-active")).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(choice);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project filter", () => {
|
||||
it("submits with the highlighted (first matching) project, not the filter text", async () => {
|
||||
const form = await mount([MAESTRO, OTHER]);
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "rev";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(filter);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
|
||||
await drain();
|
||||
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "maestro";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(filter);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
|
||||
"is-active",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,59 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
expect(modes.at(-1)).toBe("existing");
|
||||
});
|
||||
|
||||
it("collapses the advanced (worktree) section by default (#93)", async () => {
|
||||
const form = await mount();
|
||||
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
|
||||
const form = await mount();
|
||||
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
|
||||
});
|
||||
|
||||
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
|
||||
urls = stubFetch({
|
||||
projects: [
|
||||
{
|
||||
path: "/home/user/wt-91",
|
||||
name: "wt-91",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/user/maestro",
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
handle.el
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||
await drain();
|
||||
|
||||
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
|
||||
// Still collapsed — picking a project must not force it open.
|
||||
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
|
||||
const form = await mount();
|
||||
const before = [...form.querySelectorAll(".field")];
|
||||
|
||||
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||
|
||||
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
|
||||
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
|
||||
});
|
||||
|
||||
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
||||
const gate: { release?: () => void } = {};
|
||||
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
||||
|
||||
+21
-6
@@ -1284,18 +1284,33 @@ textarea {
|
||||
.worktree {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-3);
|
||||
padding: 0 var(--sp-3);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
||||
}
|
||||
|
||||
.worktree legend {
|
||||
padding: 0 6px;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-13);
|
||||
.worktree .wt-toggle {
|
||||
margin: 0 calc(var(--sp-3) * -1);
|
||||
padding-left: var(--sp-3);
|
||||
width: calc(100% + var(--sp-3) * 2);
|
||||
}
|
||||
|
||||
.wt-summary {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.wt-collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.wt-collapsible[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
|
||||
Reference in New Issue
Block a user