Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247060de68 |
@@ -572,9 +572,27 @@ impl App {
|
|||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put the spawn form up with a fresh draft behind it.
|
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
||||||
|
/// project of the session the operator was last on — so `n` then Enter
|
||||||
|
/// spawns another session there, the way "spawn similar" would. A
|
||||||
|
/// worktree session seeds its *base repository*, not its checkout: the
|
||||||
|
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
|
||||||
|
/// rule as web/src/spawnSimilar.ts.
|
||||||
fn open_spawn_modal(&mut self) {
|
fn open_spawn_modal(&mut self) {
|
||||||
self.draft = Some(DraftPane::default());
|
let seed_cwd = self.state.active_session().and_then(|sid| {
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == sid)
|
||||||
|
.map(|s| match &s.worktree {
|
||||||
|
Some(wt) => wt.base_repo.clone(),
|
||||||
|
None => s.cwd.clone(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
draft.seed_cwd = seed_cwd;
|
||||||
|
self.draft = Some(draft);
|
||||||
self.state.open_spawn_modal();
|
self.state.open_spawn_modal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +849,9 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
draft.home_dir = home_dir;
|
draft.home_dir = home_dir;
|
||||||
|
// The fast path resolved its own project; its pick wins over the
|
||||||
|
// focused session's, so the seed never lands on top of it.
|
||||||
|
draft.seed_cwd = None;
|
||||||
if !presets.is_empty() {
|
if !presets.is_empty() {
|
||||||
draft.set_presets(presets);
|
draft.set_presets(presets);
|
||||||
}
|
}
|
||||||
@@ -1640,6 +1661,41 @@ mod tests {
|
|||||||
assert_eq!(draft.command(), "claude");
|
assert_eq!(draft.command(), "claude");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opening the form preselects the project of the session in the pane, so
|
||||||
|
/// `n` then Enter spawns another session there.
|
||||||
|
#[test]
|
||||||
|
fn opening_the_spawn_modal_seeds_the_focused_sessions_project() {
|
||||||
|
use maestro_protocol::{CatalogEvent, SessionWorktree};
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(21);
|
||||||
|
let mut app = make_app();
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
|
sessions: vec![test_session(sid, "/repos/maestro")],
|
||||||
|
});
|
||||||
|
app.state.attach_to_focused(sid);
|
||||||
|
app.dispatch(Action::OpenSpawnModal);
|
||||||
|
assert_eq!(
|
||||||
|
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
||||||
|
Some(std::path::Path::new("/repos/maestro"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// A worktree session seeds its base repository: the daemon never
|
||||||
|
// adopts an existing worktree (docs/WORKTREES.md).
|
||||||
|
let mut wt_session = test_session(sid, "/repos/maestro/worktrees/flugelhorn");
|
||||||
|
wt_session.worktree = Some(SessionWorktree {
|
||||||
|
base_repo: "/repos/maestro".into(),
|
||||||
|
path: "/repos/maestro/worktrees/flugelhorn".into(),
|
||||||
|
});
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
|
sessions: vec![wt_session],
|
||||||
|
});
|
||||||
|
app.dispatch(Action::OpenSpawnModal);
|
||||||
|
assert_eq!(
|
||||||
|
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
||||||
|
Some(std::path::Path::new("/repos/maestro"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A valid form flows through: pick a project (set directly — the picker
|
/// A valid form flows through: pick a project (set directly — the picker
|
||||||
/// itself is covered in `draft.rs`), select the custom tile, type a
|
/// itself is covered in `draft.rs`), select the custom tile, type a
|
||||||
/// command, then submit from the Spawn row.
|
/// command, then submit from the Spawn row.
|
||||||
@@ -1653,11 +1709,11 @@ mod tests {
|
|||||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
app.handle_draft_input(&InputKey::Tab); // dir picker → preset
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → custom command
|
||||||
for c in "claude".chars() {
|
for c in "claude".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
}
|
}
|
||||||
app.handle_draft_input(&InputKey::Tab); // → dir picker
|
|
||||||
app.handle_draft_input(&InputKey::Tab); // → name
|
app.handle_draft_input(&InputKey::Tab); // → name
|
||||||
for c in "crimson-otter".chars() {
|
for c in "crimson-otter".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
|
|||||||
+131
-49
@@ -221,6 +221,11 @@ pub struct DraftPane {
|
|||||||
pub cwd_raw_visible: bool,
|
pub cwd_raw_visible: bool,
|
||||||
pub cwd_raw: crate::text_input::TextInput,
|
pub cwd_raw: crate::text_input::TextInput,
|
||||||
pub picked_cwd: Option<PathBuf>,
|
pub picked_cwd: Option<PathBuf>,
|
||||||
|
/// Directory to preselect once the project scan lands — the project of
|
||||||
|
/// the session the operator was last looking at, so `n` then Enter
|
||||||
|
/// respawns there without touching the picker. Consumed by the first
|
||||||
|
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||||
|
pub seed_cwd: Option<PathBuf>,
|
||||||
/// The repository picked in step one ([`group_key`] of its main box),
|
/// The repository picked in step one ([`group_key`] of its main box),
|
||||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||||
/// cached, so a project-list refresh can't leave it stale.
|
/// cached, so a project-list refresh can't leave it stale.
|
||||||
@@ -293,6 +298,7 @@ impl Default for DraftPane {
|
|||||||
cwd_raw_visible: false,
|
cwd_raw_visible: false,
|
||||||
cwd_raw: crate::text_input::TextInput::default(),
|
cwd_raw: crate::text_input::TextInput::default(),
|
||||||
picked_cwd: None,
|
picked_cwd: None,
|
||||||
|
seed_cwd: None,
|
||||||
selected_group_key: None,
|
selected_group_key: None,
|
||||||
worktree_choice: WorktreeChoice::New,
|
worktree_choice: WorktreeChoice::New,
|
||||||
name: crate::text_input::TextInput::default(),
|
name: crate::text_input::TextInput::default(),
|
||||||
@@ -315,7 +321,9 @@ impl Default for DraftPane {
|
|||||||
branches_for: None,
|
branches_for: None,
|
||||||
needs_branches: false,
|
needs_branches: false,
|
||||||
error: None,
|
error: None,
|
||||||
focused_field: DraftField::Preset,
|
// The picker owns the keyboard on open: typing filters projects
|
||||||
|
// straight away, and Tab from here reaches the command tiles.
|
||||||
|
focused_field: DraftField::DirPicker,
|
||||||
home_dir: String::new(),
|
home_dir: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +366,26 @@ impl DraftPane {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
|
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
|
||||||
|
self.apply_seed_cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preselect the seeded directory now that the scan has landed: highlight
|
||||||
|
/// its repository box and pick it, so Enter from the untouched picker
|
||||||
|
/// spawns there. A seed no box matches is dropped — the operator picks by
|
||||||
|
/// hand, exactly as before.
|
||||||
|
fn apply_seed_cwd(&mut self) {
|
||||||
|
let Some(seed) = self.seed_cwd.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| {
|
||||||
|
let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed);
|
||||||
|
matches.then(|| (i, (*g).clone()))
|
||||||
|
});
|
||||||
|
let Some((index, group)) = hit else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.highlighted = index;
|
||||||
|
self.select_group(&group);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fill the local name batch from `GET /api/names`, seeding the field
|
/// Fill the local name batch from `GET /api/names`, seeding the field
|
||||||
@@ -705,14 +733,14 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn field_order(&self) -> Vec<DraftField> {
|
fn field_order(&self) -> Vec<DraftField> {
|
||||||
let mut order = vec![DraftField::Preset];
|
let mut order = vec![DraftField::DirPicker];
|
||||||
if self.is_custom() {
|
|
||||||
order.push(DraftField::CustomCommand);
|
|
||||||
}
|
|
||||||
order.push(DraftField::DirPicker);
|
|
||||||
if self.cwd_raw_visible {
|
if self.cwd_raw_visible {
|
||||||
order.push(DraftField::CwdRaw);
|
order.push(DraftField::CwdRaw);
|
||||||
}
|
}
|
||||||
|
order.push(DraftField::Preset);
|
||||||
|
if self.is_custom() {
|
||||||
|
order.push(DraftField::CustomCommand);
|
||||||
|
}
|
||||||
order.push(DraftField::Name);
|
order.push(DraftField::Name);
|
||||||
order.push(DraftField::Advanced);
|
order.push(DraftField::Advanced);
|
||||||
if !self.advanced_collapsed {
|
if !self.advanced_collapsed {
|
||||||
@@ -1116,15 +1144,17 @@ impl DraftPane {
|
|||||||
+ u16::from(self.error.is_some());
|
+ u16::from(self.error.is_some());
|
||||||
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
|
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
|
||||||
|
|
||||||
let mut constraints = vec![Constraint::Length(1)];
|
// The picker leads the form: it is where focus lands on open, and Tab
|
||||||
if show_custom {
|
// walks down from it into the command tiles.
|
||||||
constraints.push(Constraint::Length(1));
|
let mut constraints = vec![Constraint::Length(1)]; // dir filter
|
||||||
}
|
|
||||||
constraints.push(Constraint::Length(1));
|
|
||||||
constraints.push(Constraint::Length(list_capacity as u16));
|
constraints.push(Constraint::Length(list_capacity as u16));
|
||||||
if show_raw {
|
if show_raw {
|
||||||
constraints.push(Constraint::Length(1));
|
constraints.push(Constraint::Length(1));
|
||||||
}
|
}
|
||||||
|
constraints.push(Constraint::Length(1)); // command tiles
|
||||||
|
if show_custom {
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
}
|
||||||
constraints.push(Constraint::Length(1)); // name
|
constraints.push(Constraint::Length(1)); // name
|
||||||
constraints.push(Constraint::Length(1)); // advanced header
|
constraints.push(Constraint::Length(1)); // advanced header
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
@@ -1163,36 +1193,6 @@ impl DraftPane {
|
|||||||
// Tracks where the terminal cursor belongs for the focused text field.
|
// Tracks where the terminal cursor belongs for the focused text field.
|
||||||
let mut cursor: Option<(u16, u16)> = None;
|
let mut cursor: Option<(u16, u16)> = None;
|
||||||
|
|
||||||
// -- command presets ---------------------------------------------------
|
|
||||||
let tiles_row = next_row();
|
|
||||||
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
|
|
||||||
for (i, preset) in self.presets.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
tiles.push(Span::raw(" "));
|
|
||||||
}
|
|
||||||
let mut style = Style::default();
|
|
||||||
if i == self.selected_preset {
|
|
||||||
style = style.add_modifier(Modifier::REVERSED);
|
|
||||||
}
|
|
||||||
tiles.push(Span::styled(format!("[{}]", preset.label), style));
|
|
||||||
}
|
|
||||||
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
|
|
||||||
|
|
||||||
// -- custom command (only while a custom tile is selected) -------------
|
|
||||||
if show_custom {
|
|
||||||
let row = next_row();
|
|
||||||
draw_gutter(f, row, "");
|
|
||||||
let col = draw_text_field(
|
|
||||||
f,
|
|
||||||
&self.custom_command,
|
|
||||||
row,
|
|
||||||
field_style(DraftField::CustomCommand),
|
|
||||||
);
|
|
||||||
if self.focused_field == DraftField::CustomCommand {
|
|
||||||
cursor = Some((col, row.y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- directory picker ----------------------------------------------------
|
// -- directory picker ----------------------------------------------------
|
||||||
let filter_row = next_row();
|
let filter_row = next_row();
|
||||||
draw_gutter(f, filter_row, "dir:");
|
draw_gutter(f, filter_row, "dir:");
|
||||||
@@ -1280,6 +1280,36 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- command presets ---------------------------------------------------
|
||||||
|
let tiles_row = next_row();
|
||||||
|
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
|
||||||
|
for (i, preset) in self.presets.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
tiles.push(Span::raw(" "));
|
||||||
|
}
|
||||||
|
let mut style = Style::default();
|
||||||
|
if i == self.selected_preset {
|
||||||
|
style = style.add_modifier(Modifier::REVERSED);
|
||||||
|
}
|
||||||
|
tiles.push(Span::styled(format!("[{}]", preset.label), style));
|
||||||
|
}
|
||||||
|
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
|
||||||
|
|
||||||
|
// -- custom command (only while a custom tile is selected) -------------
|
||||||
|
if show_custom {
|
||||||
|
let row = next_row();
|
||||||
|
draw_gutter(f, row, "");
|
||||||
|
let col = draw_text_field(
|
||||||
|
f,
|
||||||
|
&self.custom_command,
|
||||||
|
row,
|
||||||
|
field_style(DraftField::CustomCommand),
|
||||||
|
);
|
||||||
|
if self.focused_field == DraftField::CustomCommand {
|
||||||
|
cursor = Some((col, row.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- name + reroll ---------------------------------------------------------
|
// -- name + reroll ---------------------------------------------------------
|
||||||
let name_row = next_row();
|
let name_row = next_row();
|
||||||
draw_gutter(f, name_row, "name:");
|
draw_gutter(f, name_row, "name:");
|
||||||
@@ -1681,7 +1711,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane {
|
||||||
|
focused_field: DraftField::Preset,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
draft.on_input(&InputKey::Right);
|
draft.on_input(&InputKey::Right);
|
||||||
assert_eq!(draft.command(), "opencode");
|
assert_eq!(draft.command(), "opencode");
|
||||||
draft.on_input(&InputKey::Left);
|
draft.on_input(&InputKey::Left);
|
||||||
@@ -1704,17 +1737,22 @@ mod tests {
|
|||||||
draft.selected_preset = draft.presets.len() - 1;
|
draft.selected_preset = draft.presets.len() - 1;
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.field_order()[..2],
|
draft.field_order()[..3],
|
||||||
[DraftField::Preset, DraftField::CustomCommand],
|
[
|
||||||
"custom command sits between presets and the dir picker"
|
DraftField::DirPicker,
|
||||||
|
DraftField::Preset,
|
||||||
|
DraftField::CustomCommand
|
||||||
|
],
|
||||||
|
"the picker leads; custom command sits right after the tiles"
|
||||||
);
|
);
|
||||||
|
draft.next_field(); // DirPicker → Preset
|
||||||
draft.next_field();
|
draft.next_field();
|
||||||
assert_eq!(draft.focused_field, DraftField::CustomCommand);
|
assert_eq!(draft.focused_field, DraftField::CustomCommand);
|
||||||
typed(&mut draft, "bash -l");
|
typed(&mut draft, "bash -l");
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
// Tab walks past the hidden fields in order.
|
// Tab walks past the hidden fields in order.
|
||||||
draft.next_field();
|
draft.next_field();
|
||||||
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
assert_eq!(draft.focused_field, DraftField::Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1777,8 +1815,11 @@ mod tests {
|
|||||||
],
|
],
|
||||||
Utc::now(),
|
Utc::now(),
|
||||||
);
|
);
|
||||||
draft.next_field(); // Preset → DirPicker
|
assert_eq!(
|
||||||
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
draft.focused_field,
|
||||||
|
DraftField::DirPicker,
|
||||||
|
"the form opens with the project filter focused"
|
||||||
|
);
|
||||||
typed(&mut draft, "maes");
|
typed(&mut draft, "maes");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.visible_groups().len(),
|
draft.visible_groups().len(),
|
||||||
@@ -1890,6 +1931,47 @@ mod tests {
|
|||||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_seeded_directory_is_preselected_so_one_enter_spawns_there() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_cwd: Some(PathBuf::from("/repos/other")),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_projects(
|
||||||
|
vec![
|
||||||
|
project("maestro", "/repos/maestro", 1, Some("claude")),
|
||||||
|
project("other", "/repos/other", 2, Some("zsh")),
|
||||||
|
],
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
draft.picked_cwd.as_deref(),
|
||||||
|
Some(Path::new("/repos/other")),
|
||||||
|
"the seeded project is picked before a key is pressed"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.selection(), 1, "and its row is the highlighted one");
|
||||||
|
assert_eq!(
|
||||||
|
draft.on_input(&InputKey::Enter),
|
||||||
|
DraftInputResult::Submit,
|
||||||
|
"one Enter spawns in the seeded project"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_seed_no_project_matches_leaves_the_picker_untouched() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_cwd: Some(PathBuf::from("/gone")),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_projects(
|
||||||
|
vec![project("maestro", "/repos/maestro", 1, None)],
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
assert_eq!(draft.picked_cwd, None);
|
||||||
|
assert_eq!(draft.selection(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
|
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane::default();
|
||||||
@@ -2140,7 +2222,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).unwrap();
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
||||||
let pos = terminal.get_cursor_position().unwrap();
|
let pos = terminal.get_cursor_position().unwrap();
|
||||||
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
|
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+9
-2
@@ -22,7 +22,7 @@ import {
|
|||||||
} from "./fastForward.js";
|
} from "./fastForward.js";
|
||||||
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
||||||
import { openSpawnModal } from "./spawnModal.js";
|
import { openSpawnModal } from "./spawnModal.js";
|
||||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
|
||||||
import { mountHud, type Hud } from "./hud.js";
|
import { mountHud, type Hud } from "./hud.js";
|
||||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
@@ -243,7 +243,14 @@ function requestOpenSpawn(): void {
|
|||||||
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
|
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
|
||||||
// the old behaviour: both act on the session in front of you, so a split
|
// the old behaviour: both act on the session in front of you, so a split
|
||||||
// beside it is the point.
|
// beside it is the point.
|
||||||
openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) });
|
// The session you were last on preselects its project: `n` then Enter spawns
|
||||||
|
// another session there without touching the picker.
|
||||||
|
const focused = mv?.focusedSessionId() ?? null;
|
||||||
|
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
|
||||||
|
openSpawnModal({
|
||||||
|
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
||||||
|
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||||
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
||||||
|
|||||||
+14
-8
@@ -122,12 +122,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
form.className = "spawn-form";
|
form.className = "spawn-form";
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">Command</span>
|
|
||||||
<div class="tile-row cmd-tiles"></div>
|
|
||||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
|
||||||
placeholder="bash -l" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Project</span>
|
<span class="field-label">Project</span>
|
||||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
@@ -139,6 +133,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Command</span>
|
||||||
|
<div class="tile-row cmd-tiles"></div>
|
||||||
|
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||||
|
placeholder="bash -l" autocomplete="off" />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Name</span>
|
<span class="field-label">Name</span>
|
||||||
<div class="name-row">
|
<div class="name-row">
|
||||||
@@ -896,8 +896,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (target === filterInput) {
|
if (target === filterInput) {
|
||||||
const top = visibleGroups(filterInput.value)[0];
|
// Resolve the filter to a project only when the current pick isn't one
|
||||||
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
|
// of the matches: a filter typed past it means the operator moved on,
|
||||||
|
// while a pick that still matches (including the seeded one the form
|
||||||
|
// opens with) keeps its worktree choice.
|
||||||
|
const visible = visibleGroups(filterInput.value);
|
||||||
|
const stillMatches =
|
||||||
|
selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path);
|
||||||
|
if (!stillMatches && visible[0]) selectProject(visible[0]);
|
||||||
}
|
}
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,9 +68,12 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
|||||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
async function mount(
|
||||||
|
projects: Project[],
|
||||||
|
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
|
||||||
|
): Promise<HTMLFormElement> {
|
||||||
stubFetch(projects);
|
stubFetch(projects);
|
||||||
handle = createSpawnForm({ onSuccess() {} });
|
handle = createSpawnForm({ seed, onSuccess() {} });
|
||||||
document.body.appendChild(handle.el);
|
document.body.appendChild(handle.el);
|
||||||
await drain();
|
await drain();
|
||||||
return handle.el;
|
return handle.el;
|
||||||
@@ -232,6 +235,24 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
|||||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submits in the seeded project rather than the freshest one", async () => {
|
||||||
|
// What `n` then Enter must do: the modal opens parked on the project of
|
||||||
|
// the session you were last on (OTHER here), even though MAESTRO's
|
||||||
|
// worktree is the freshest row in the list.
|
||||||
|
const form = await mount([MAESTRO, WT_A, OTHER], {
|
||||||
|
cwd: OTHER.path,
|
||||||
|
cwdRawVisible: false,
|
||||||
|
});
|
||||||
|
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||||
|
const spy = submitSpy();
|
||||||
|
form.addEventListener("submit", spy);
|
||||||
|
|
||||||
|
pressEnter(form.querySelector<HTMLInputElement>(".project-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 () => {
|
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
||||||
const form = await mount([MAESTRO, WT_A]);
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
box(form, MAESTRO.path).click();
|
box(form, MAESTRO.path).click();
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ describe("spawn modal", () => {
|
|||||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
||||||
|
openSpawnModal({ onSuccess: () => {} });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const form = modal()!.querySelector("form.spawn-form")!;
|
||||||
|
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||||
|
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||||
|
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("titles a seeded open as a clone", async () => {
|
it("titles a seeded open as a clone", async () => {
|
||||||
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
|
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
|
||||||
await flush();
|
await flush();
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export function openSpawnModal(opts: SpawnModalOptions): void {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
dialog.appendChild(form.el);
|
dialog.appendChild(form.el);
|
||||||
|
// The project filter owns the keyboard on open: typing narrows the list and
|
||||||
|
// Tab from here reaches the command tiles, matching the TUI's draft pane.
|
||||||
|
form.el.querySelector<HTMLInputElement>(".project-filter")?.focus();
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
if (closeCurrent === close) closeCurrent = null;
|
if (closeCurrent === close) closeCurrent = null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
function session(overrides: Partial<Session> = {}): Session {
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
@@ -43,3 +43,23 @@ describe("spawnSimilarSeed (#28)", () => {
|
|||||||
expect(seed.name).toBeUndefined();
|
expect(seed.name).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("spawnSimilarProjectSeed", () => {
|
||||||
|
it("seeds the project alone, leaving command and worktree at their defaults", () => {
|
||||||
|
const seed = spawnSimilarProjectSeed(session());
|
||||||
|
expect(seed.cwd).toBe("/repos/app");
|
||||||
|
expect(seed.cwdRawVisible).toBe(false);
|
||||||
|
expect(seed.command).toBeUndefined();
|
||||||
|
expect(seed.worktreeEnabled).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds a worktree session's base repository", () => {
|
||||||
|
const seed = spawnSimilarProjectSeed(
|
||||||
|
session({
|
||||||
|
cwd: "/repos/app-worktrees/feature",
|
||||||
|
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(seed.cwd).toBe("/repos/app");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,3 +28,17 @@ export function spawnSimilarSeed(s: Session): SpawnFormSeed {
|
|||||||
cwdRawVisible: false,
|
cwdRawVisible: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The project half of a clone: the session's repository, nothing else.
|
||||||
|
*
|
||||||
|
* The plain spawn modal opens on this so the picker is already parked on the
|
||||||
|
* project you were last looking at — one Enter spawns there — while the
|
||||||
|
* command, worktree decision and name stay at the form's own defaults. Same
|
||||||
|
* base-repository rule as `spawnSimilarSeed`. */
|
||||||
|
export function spawnSimilarProjectSeed(s: Session): SpawnFormSeed {
|
||||||
|
const wt = s.worktree ?? null;
|
||||||
|
return {
|
||||||
|
cwd: wt ? wt.base_repo : s.cwd,
|
||||||
|
cwdRawVisible: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user