Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9829bc56f | ||
|
|
9f582263b8 | ||
|
|
b5cbf94fa8 | ||
|
|
8376122dfe | ||
|
|
ec51b0221c | ||
|
|
ca585447ff | ||
|
|
71301861c0 | ||
|
|
2ce1dc43fe | ||
|
|
e99e21260b | ||
|
|
cea06f9204 | ||
|
|
1199596528 | ||
|
|
95f0e42df5 |
@@ -67,6 +67,11 @@ pub struct SpawnArgs {
|
||||
/// Branch name for the worktree (required with --worktree-from).
|
||||
#[arg(long)]
|
||||
pub branch: Option<String>,
|
||||
/// Check out an existing branch instead of creating one. Without it the
|
||||
/// daemon runs `worktree add -b`, which fails if the branch is already
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub existing_branch: bool,
|
||||
/// Remove the worktree when the session is killed. Kept for wire
|
||||
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
||||
/// against unsaved work.
|
||||
@@ -170,6 +175,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
||||
/// so the daemon's existing-branch path was unreachable and spawning onto
|
||||
/// a branch that already existed just failed.
|
||||
#[test]
|
||||
fn parses_spawn_onto_an_existing_branch() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"bash",
|
||||
"--worktree-from",
|
||||
"/repo",
|
||||
"--branch",
|
||||
"feat/existing",
|
||||
"--existing-branch",
|
||||
])
|
||||
.unwrap();
|
||||
let Command::Spawn(a) = cli.command else {
|
||||
panic!("expected spawn")
|
||||
};
|
||||
assert!(a.existing_branch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_worktree_requires_branch() {
|
||||
let err = Cli::try_parse_from([
|
||||
|
||||
@@ -44,7 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
base_repo: repo.clone(),
|
||||
branch: branch.clone(),
|
||||
cleanup_on_kill: args.cleanup_on_kill,
|
||||
create_branch: true,
|
||||
create_branch: !args.existing_branch,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -69,6 +69,9 @@ impl WorktreeManager {
|
||||
create_branch: bool,
|
||||
) -> Result<PathBuf, WorktreeError> {
|
||||
let path = self.target_path(base_repo, branch);
|
||||
// Clear registrations whose directory is gone before testing the
|
||||
// path, so a previously-leaked entry doesn't block this branch.
|
||||
prune(base_repo)?;
|
||||
if path.symlink_metadata().is_ok() {
|
||||
return Err(WorktreeError::PathExists { path });
|
||||
}
|
||||
@@ -97,6 +100,14 @@ impl WorktreeManager {
|
||||
/// which makes this guard the only thing standing between a stray kill
|
||||
/// and destroyed work.
|
||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||
// Gone from disk already (operator `rm -rf`, or a create that failed
|
||||
// half-way). Probing it with git would fail and be misread as unsaved
|
||||
// work, stranding the registration — and since paths are
|
||||
// deterministic, `worktree add` would then refuse that branch forever
|
||||
// with "missing but already registered" (#56 review).
|
||||
if !path.exists() {
|
||||
return prune(base_repo);
|
||||
}
|
||||
if let Some(reason) = unsafe_to_remove(path)? {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
@@ -128,55 +139,35 @@ fn sanitize_branch_for_path(branch: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Some(reason)` when deleting `path` would destroy work: uncommitted
|
||||
/// changes, or commits reachable from no ref other than the worktree's own
|
||||
/// branch (`git worktree remove` keeps the branch, but nothing checks it out
|
||||
/// anymore — treat its commits as at risk anyway, per #56).
|
||||
/// `Some(reason)` when deleting `path` would destroy work.
|
||||
///
|
||||
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
|
||||
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
|
||||
/// not — stays reachable and is never at risk. Guarding on "commits on no
|
||||
/// other ref" instead would refuse every ordinary unpushed feature branch,
|
||||
/// and because the path is deterministic that branch could then never be
|
||||
/// spawned again (#56 review).
|
||||
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
||||
let status = run_git(path, ["status", "--porcelain"])?;
|
||||
// `--ignored=no` keeps build output out of it; untracked sources count,
|
||||
// since removal would delete them.
|
||||
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
|
||||
if !status.stdout.is_empty() {
|
||||
return Ok(Some("has uncommitted changes".into()));
|
||||
}
|
||||
|
||||
let head = String::from_utf8_lossy(&run_git(path, ["rev-parse", "HEAD"])?.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
let branch =
|
||||
String::from_utf8_lossy(&run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Every ref except this worktree's own branch counts as a safe home for
|
||||
// its commits. Detached HEAD excludes everything.
|
||||
let refs =
|
||||
String::from_utf8_lossy(&run_git(path, ["for-each-ref", "--format=%(refname)"])?.stdout)
|
||||
.lines()
|
||||
.filter(|r| match branch.as_str() {
|
||||
"HEAD" => true,
|
||||
b => *r != format!("refs/heads/{b}"),
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(path).args(["rev-list", "--count", &head]);
|
||||
if !refs.is_empty() {
|
||||
cmd.arg("--not").args(&refs);
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
check(out.clone())?;
|
||||
let count: usize = String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
if count > 0 {
|
||||
return Ok(Some(format!(
|
||||
"has {count} commit(s) not present on any other ref"
|
||||
)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
/// no longer exists.
|
||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["worktree", "prune"])
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
|
||||
fn run_git(
|
||||
dir: &Path,
|
||||
args: impl IntoIterator<Item = &'static str>,
|
||||
|
||||
@@ -206,18 +206,50 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
|
||||
.expect_err("dirty worktree must be refused");
|
||||
assert!(path.exists(), "guarded worktree must stay on disk");
|
||||
|
||||
// Committing the change doesn't help while it is the only copy…
|
||||
// Committing it makes removal safe: `worktree remove` keeps
|
||||
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
|
||||
// instead would make every unpushed branch a one-shot, since the path is
|
||||
// deterministic and the leftover tree blocks the next spawn (#56 review).
|
||||
run_git(&path, &["add", "."]);
|
||||
run_git(&path, &["commit", "-q", "-m", "unsaved"]);
|
||||
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||
|
||||
mgr.remove(&repo, &path).expect("committed work can go");
|
||||
assert!(!path.exists());
|
||||
assert!(
|
||||
worktree_branches(&repo).contains(&"main".to_string()),
|
||||
"the branch survives removal"
|
||||
);
|
||||
let branches = String::from_utf8(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(branches.contains("feat/dirty"), "{branches}");
|
||||
}
|
||||
|
||||
/// A worktree whose directory vanished must be pruned, not misread as holding
|
||||
/// unsaved work — otherwise the registration is stranded and, because paths
|
||||
/// are deterministic, that branch can never be spawned again (#56 review).
|
||||
#[test]
|
||||
fn remove_prunes_a_worktree_whose_directory_vanished() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/gone").expect("add");
|
||||
std::fs::remove_dir_all(&path).unwrap();
|
||||
|
||||
mgr.remove(&repo, &path)
|
||||
.expect_err("unique commit must be refused");
|
||||
assert!(path.exists());
|
||||
|
||||
// …but once the commit is reachable from main, removal is safe.
|
||||
run_git(&repo, &["merge", "-q", "--no-edit", "feat/dirty"]);
|
||||
mgr.remove(&repo, &path).expect("merged worktree can go");
|
||||
assert!(!path.exists());
|
||||
.expect("a vanished worktree prunes");
|
||||
// The registration is gone too, so the same branch can be spawned again.
|
||||
mgr.add_existing(&repo, "feat/gone")
|
||||
.expect("branch is spawnable again");
|
||||
}
|
||||
|
||||
fn run_git(dir: &std::path::Path, args: &[&str]) {
|
||||
|
||||
@@ -38,7 +38,7 @@ pub enum Action {
|
||||
// Catalog actions on the highlighted sidebar row
|
||||
AttachHighlighted,
|
||||
ToggleAttachHighlighted,
|
||||
OpenSpawnModal,
|
||||
NewDraftPane,
|
||||
KillHighlighted,
|
||||
|
||||
// Tabs
|
||||
@@ -78,7 +78,7 @@ impl Action {
|
||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"NewTab" => return Ok(Action::NewTab),
|
||||
"CloseTab" => return Ok(Action::CloseTab),
|
||||
@@ -202,6 +202,7 @@ mod tests {
|
||||
for a in [
|
||||
Action::EnterInsert,
|
||||
Action::Quit,
|
||||
Action::NewDraftPane,
|
||||
Action::FocusPane(Direction::Right),
|
||||
Action::GotoTab(7),
|
||||
] {
|
||||
@@ -209,4 +210,14 @@ mod tests {
|
||||
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The spawn modal is gone (issue #63); its old action string must not
|
||||
/// resolve to anything.
|
||||
#[test]
|
||||
fn the_removed_spawn_modal_action_no_longer_parses() {
|
||||
assert_eq!(
|
||||
Action::parse("OpenSpawnModal").unwrap_err(),
|
||||
ActionParseError::Unknown("OpenSpawnModal".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+181
-84
@@ -6,9 +6,9 @@
|
||||
//! changes, and a 30Hz heartbeat.
|
||||
//!
|
||||
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
||||
//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit
|
||||
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
||||
//! `dispatch` / `handle_overlay_input`.
|
||||
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
|
||||
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
||||
//! `dispatch` / the Insert-mode pane routing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Stdout;
|
||||
@@ -24,15 +24,16 @@ use uuid::Uuid;
|
||||
use crate::action::Action;
|
||||
use crate::catalog::CatalogStore;
|
||||
use crate::config::TuiConfig;
|
||||
use crate::draft::DraftInputResult;
|
||||
use crate::emulators::SessionEmulators;
|
||||
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, SpawnModal};
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::pane::{Pane, PaneStatus};
|
||||
use crate::render::{draw, RegionMap, RenderInputs};
|
||||
use crate::session_task::{SessionInput, SessionTask};
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||
use crate::tabs::TabBar;
|
||||
use crate::TuiError;
|
||||
|
||||
@@ -49,7 +50,6 @@ pub struct App {
|
||||
catalog: CatalogStore,
|
||||
emulators: SessionEmulators,
|
||||
state: AppState,
|
||||
spawn: SpawnModal,
|
||||
help: HelpOverlay,
|
||||
chord: ChordState,
|
||||
daemon: DaemonClient,
|
||||
@@ -90,7 +90,6 @@ impl App {
|
||||
catalog,
|
||||
emulators: SessionEmulators::default(),
|
||||
state: AppState::new(),
|
||||
spawn: SpawnModal::default(),
|
||||
help,
|
||||
chord: ChordState::new(chord_timeout),
|
||||
daemon,
|
||||
@@ -152,7 +151,15 @@ impl App {
|
||||
self.perform(effects).await;
|
||||
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
||||
{
|
||||
if let Some(effect) = self.forward_to_session(&key) {
|
||||
// Insert mode over a focused pane: a draft eats the key
|
||||
// into its form, a session gets the raw bytes.
|
||||
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
||||
{
|
||||
self.handle_draft_input(&key)
|
||||
} else {
|
||||
self.forward_to_session(&key)
|
||||
};
|
||||
if let Some(effect) = effect {
|
||||
self.perform(vec![effect]).await;
|
||||
}
|
||||
}
|
||||
@@ -179,27 +186,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
||||
use crate::overlays::SpawnInputResult;
|
||||
match self.state.overlay().cloned() {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
let mut effects = Vec::new();
|
||||
match self.spawn.on_input(key) {
|
||||
SpawnInputResult::Consumed => {}
|
||||
SpawnInputResult::Cancel => {
|
||||
self.state.dismiss_overlay();
|
||||
}
|
||||
SpawnInputResult::Submit => {
|
||||
effects.push(Effect::Spawn(CreateSessionRequest {
|
||||
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
|
||||
command: self.spawn.command.value().to_string(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(OverlayDecision::Consumed, effects)
|
||||
}
|
||||
Some(OverlayKind::KillConfirm(id)) => match key {
|
||||
InputKey::Char { c: 'y', .. } => {
|
||||
self.state.dismiss_overlay();
|
||||
@@ -265,14 +252,7 @@ impl App {
|
||||
};
|
||||
self.state.show_overlay(OverlayKind::Help);
|
||||
}
|
||||
Action::OpenSpawnModal => {
|
||||
self.spawn = SpawnModal {
|
||||
cwd: Default::default(),
|
||||
command: Default::default(),
|
||||
focused_field: Default::default(),
|
||||
};
|
||||
self.state.show_overlay(OverlayKind::Spawn);
|
||||
}
|
||||
Action::NewDraftPane => self.state.open_draft_pane(),
|
||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
effects.push(Effect::Attach(sid));
|
||||
@@ -322,44 +302,38 @@ impl App {
|
||||
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||
/// attached to send it to.
|
||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
let sid = self.state.active_tab().pane?;
|
||||
let sid = self.state.active_session()?;
|
||||
self.emulators.get(sid)?;
|
||||
let bytes = match key {
|
||||
InputKey::Char { c, ctrl, alt, .. } => {
|
||||
let mut out = Vec::with_capacity(8);
|
||||
if *alt {
|
||||
out.push(0x1b);
|
||||
}
|
||||
if *ctrl && c.is_ascii_alphabetic() {
|
||||
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
||||
} else {
|
||||
let mut b = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut b);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
InputKey::Enter => b"\r".to_vec(),
|
||||
InputKey::Tab => b"\t".to_vec(),
|
||||
InputKey::BackTab => b"\x1b[Z".to_vec(),
|
||||
InputKey::Backspace => vec![0x7f],
|
||||
InputKey::Esc => vec![0x1b],
|
||||
InputKey::Up => b"\x1b[A".to_vec(),
|
||||
InputKey::Down => b"\x1b[B".to_vec(),
|
||||
InputKey::Right => b"\x1b[C".to_vec(),
|
||||
InputKey::Left => b"\x1b[D".to_vec(),
|
||||
InputKey::Home => b"\x1b[H".to_vec(),
|
||||
InputKey::End => b"\x1b[F".to_vec(),
|
||||
InputKey::Delete => b"\x1b[3~".to_vec(),
|
||||
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
|
||||
};
|
||||
let bytes = encode_key(key)?;
|
||||
Some(Effect::Forward { id: sid, bytes })
|
||||
}
|
||||
|
||||
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||
/// effect when the user accepted the form.
|
||||
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
|
||||
return None;
|
||||
};
|
||||
if draft.on_input(key) != DraftInputResult::Submit {
|
||||
return None;
|
||||
}
|
||||
Some(Effect::Spawn(CreateSessionRequest {
|
||||
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
|
||||
command: draft.command.value().to_string(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||
match self.daemon.create_session(&req).await {
|
||||
Ok(_) => {
|
||||
self.state.dismiss_overlay();
|
||||
Ok(session) => {
|
||||
// Fill the pane in place with the new session (web parity,
|
||||
// #57) and attach to it right away.
|
||||
let sid = session.id;
|
||||
self.state.complete_spawn(sid);
|
||||
self.attach(sid);
|
||||
}
|
||||
Err(e) => self.state.toast(format!("spawn failed: {e}")),
|
||||
}
|
||||
@@ -377,7 +351,12 @@ impl App {
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
) -> Result<(), TuiError> {
|
||||
let snap = self.catalog.snapshot();
|
||||
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
|
||||
let attached: HashSet<Uuid> = self
|
||||
.state
|
||||
.tabs
|
||||
.iter()
|
||||
.filter_map(crate::state::Tab::session)
|
||||
.collect();
|
||||
let sidebar = Sidebar {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: self.state.sidebar_highlight,
|
||||
@@ -387,7 +366,7 @@ impl App {
|
||||
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
||||
active_idx: self.state.active_tab_idx,
|
||||
};
|
||||
let active_sid = self.state.active_tab().pane;
|
||||
let active_sid = self.state.active_session();
|
||||
let pane = active_sid.and_then(|sid| {
|
||||
self.emulators.get(sid).map(|h| Pane {
|
||||
screen: h.screen.clone(),
|
||||
@@ -395,13 +374,17 @@ impl App {
|
||||
focused: matches!(self.state.focus, Focus::Pane),
|
||||
})
|
||||
});
|
||||
let draft = match self.state.active_tab().pane.as_ref() {
|
||||
Some(PaneRef::Draft(d)) => Some(d),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
pane: pane.as_ref(),
|
||||
state: &self.state,
|
||||
spawn: &self.spawn,
|
||||
draft,
|
||||
help: &self.help,
|
||||
};
|
||||
|
||||
@@ -449,6 +432,52 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
||||
snap.sessions.iter().map(|s| s.id).collect()
|
||||
}
|
||||
|
||||
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||
/// with no byte representation (mouse events). A bracketed paste is forwarded
|
||||
/// verbatim — the remote readline reassembles it, since the pane's PTY also
|
||||
/// has bracketed paste enabled.
|
||||
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||
match key {
|
||||
InputKey::Char { c, ctrl, alt, .. } => {
|
||||
let mut out = Vec::with_capacity(8);
|
||||
if *alt {
|
||||
out.push(0x1b);
|
||||
}
|
||||
if *ctrl && c.is_ascii_alphabetic() {
|
||||
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
||||
} else {
|
||||
let mut b = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut b);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
InputKey::Enter => Some(b"\r".to_vec()),
|
||||
InputKey::Tab => Some(b"\t".to_vec()),
|
||||
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
|
||||
InputKey::Backspace => Some(vec![0x7f]),
|
||||
InputKey::Esc => Some(vec![0x1b]),
|
||||
InputKey::Up => Some(b"\x1b[A".to_vec()),
|
||||
InputKey::Down => Some(b"\x1b[B".to_vec()),
|
||||
InputKey::Right => Some(b"\x1b[C".to_vec()),
|
||||
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||
InputKey::Paste(text) => {
|
||||
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
||||
// to an app that enabled bracketed paste, so a multi-line paste
|
||||
// would execute line by line instead of arriving as one block.
|
||||
let mut out = Vec::with_capacity(text.len() + 12);
|
||||
out.extend_from_slice(b"\x1b[200~");
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.extend_from_slice(b"\x1b[201~");
|
||||
Some(out)
|
||||
}
|
||||
InputKey::Mouse { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the lines shown by the help overlay from the live keymap, so the
|
||||
/// user always sees the bindings that are actually active (defaults + their
|
||||
/// own `[tui.keymap.*]` overrides).
|
||||
@@ -516,10 +545,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_spawn_modal_sets_overlay() {
|
||||
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
assert!(matches!(
|
||||
app.state.active_tab().pane,
|
||||
Some(PaneRef::Draft(_))
|
||||
));
|
||||
assert_eq!(app.state.focus, Focus::Pane);
|
||||
assert_eq!(app.state.mode, Mode::Insert);
|
||||
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||
}
|
||||
|
||||
/// A tab already showing a session gets a new tab for the draft.
|
||||
#[test]
|
||||
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
||||
let sid = Uuid::from_u128(10);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
app.dispatch(Action::AttachHighlighted);
|
||||
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
assert_eq!(app.state.tabs.len(), 2);
|
||||
assert_eq!(app.state.active_tab_idx, 1);
|
||||
assert!(matches!(
|
||||
app.state.active_tab().pane,
|
||||
Some(PaneRef::Draft(_))
|
||||
));
|
||||
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
||||
}
|
||||
|
||||
/// Seed the catalog with one session and highlight it.
|
||||
@@ -554,7 +606,7 @@ mod tests {
|
||||
app.dispatch(Action::AttachHighlighted),
|
||||
vec![Effect::Attach(sid)]
|
||||
);
|
||||
assert_eq!(app.state.active_tab().pane, Some(sid));
|
||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -587,23 +639,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submitting_the_spawn_modal_carries_the_typed_fields() {
|
||||
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
|
||||
app.spawn.command = crate::text_input::TextInput::with_value("claude");
|
||||
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
|
||||
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||
match effects.as_slice() {
|
||||
[Effect::Spawn(req)] => {
|
||||
for c in "/srv/work".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // cwd → command
|
||||
for c in "claude".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // command → submit
|
||||
|
||||
match app.handle_draft_input(&InputKey::Enter) {
|
||||
Some(Effect::Spawn(req)) => {
|
||||
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
||||
assert_eq!(req.command, "claude");
|
||||
}
|
||||
other => panic!("expected one Spawn effect, got {other:?}"),
|
||||
other => panic!("expected a Spawn effect, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys fed while no draft exists must be ignored, not panic.
|
||||
#[test]
|
||||
fn draft_input_is_a_no_op_without_a_draft() {
|
||||
let mut app = make_app();
|
||||
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
|
||||
}
|
||||
|
||||
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
||||
/// failure message stayed on screen until another overlay replaced it.
|
||||
#[test]
|
||||
@@ -632,6 +696,39 @@ mod tests {
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||
}
|
||||
|
||||
/// A draft pane is not a session: forwarding must not try to reach it.
|
||||
#[test]
|
||||
fn forwarding_is_a_no_op_with_a_draft_pane() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||
}
|
||||
|
||||
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||
/// paste into an attached session vanished once bracketed paste was
|
||||
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
||||
/// remote shell reads the payload as typed keys and runs a multi-line
|
||||
/// paste line by line.
|
||||
#[test]
|
||||
fn a_bracketed_paste_reaches_the_session_with_its_markers() {
|
||||
assert_eq!(
|
||||
encode_key(&InputKey::Paste("cd /tmp/foo\nls".into())),
|
||||
Some(b"\x1b[200~cd /tmp/foo\nls\x1b[201~".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_events_have_no_wire_encoding() {
|
||||
use crate::input::{MouseKind, Region};
|
||||
assert!(encode_key(&InputKey::Mouse {
|
||||
kind: MouseKind::Click,
|
||||
region: Region::Pane,
|
||||
col: 3,
|
||||
row: 4,
|
||||
})
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_highlighted_opens_confirm() {
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("up", "SidebarPrev"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "OpenSpawnModal"),
|
||||
("s", "NewDraftPane"),
|
||||
("x", "KillHighlighted"),
|
||||
// Tabs
|
||||
("ctrl-t", "NewTab"),
|
||||
@@ -155,6 +155,10 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
||||
Some(Action::NextTab),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||
Some(Action::NewDraftPane),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Draft pane — the spawn form living in an ordinary pane (issue #63).
|
||||
//!
|
||||
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
|
||||
//! the caller a built request and the pane is replaced with the new session,
|
||||
//! matching the web client's inline draft panes (#57). Field editing goes
|
||||
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
|
||||
//!
|
||||
//! Scope note: presets, the project list, generated names, and the worktree
|
||||
//! section land with issues #64/#65 — this is cwd + command only.
|
||||
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::input::InputKey;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DraftField {
|
||||
#[default]
|
||||
Cwd,
|
||||
Command,
|
||||
Submit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct DraftPane {
|
||||
pub cwd: crate::text_input::TextInput,
|
||||
pub command: crate::text_input::TextInput,
|
||||
pub focused_field: DraftField,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DraftInputResult {
|
||||
Consumed,
|
||||
Submit,
|
||||
}
|
||||
|
||||
impl DraftPane {
|
||||
pub fn next_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
DraftField::Cwd => DraftField::Command,
|
||||
DraftField::Command => DraftField::Submit,
|
||||
DraftField::Submit => DraftField::Cwd,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
DraftField::Cwd => DraftField::Submit,
|
||||
DraftField::Command => DraftField::Cwd,
|
||||
DraftField::Submit => DraftField::Command,
|
||||
};
|
||||
}
|
||||
|
||||
/// Handle one keystroke while this draft owns input (Insert mode, pane
|
||||
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
|
||||
/// the caller fires the POST and replaces the draft with the session.
|
||||
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
|
||||
match key {
|
||||
InputKey::Tab => {
|
||||
self.next_field();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::BackTab => {
|
||||
self.prev_field();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => match self.focused_field {
|
||||
DraftField::Submit => DraftInputResult::Submit,
|
||||
_ => {
|
||||
self.next_field();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
},
|
||||
// Everything else — characters, motion, editing, paste — goes to
|
||||
// the focused field's text input.
|
||||
other => {
|
||||
match self.focused_field {
|
||||
DraftField::Cwd => self.cwd.handle_key(other),
|
||||
DraftField::Command => self.command.handle_key(other),
|
||||
DraftField::Submit => {}
|
||||
}
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw into the pane area. `pane_focused` gates the field highlight and
|
||||
/// cursor placement the same way a session pane hides its cursor when
|
||||
/// unfocused.
|
||||
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
|
||||
let block = Block::default().title(" draft ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let style_for = |field: DraftField| {
|
||||
if pane_focused && self.focused_field == field {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Each text field renders through the shared `TextInput`, which
|
||||
// handles horizontal scrolling and reports where the terminal cursor
|
||||
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
|
||||
let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} "))));
|
||||
f.render_widget(label("cwd:"), rows[0]);
|
||||
f.render_widget(label("command:"), rows[1]);
|
||||
let draw_field =
|
||||
|f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 {
|
||||
let area = Rect {
|
||||
x: row.x.saturating_add(9),
|
||||
y: row.y,
|
||||
width: row.width.saturating_sub(9),
|
||||
height: 1,
|
||||
};
|
||||
input.render(area, f.buffer_mut(), style)
|
||||
};
|
||||
let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd));
|
||||
let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command));
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
"[ Spawn ]",
|
||||
style_for(DraftField::Submit),
|
||||
)])),
|
||||
rows[2],
|
||||
);
|
||||
|
||||
if pane_focused {
|
||||
match self.focused_field {
|
||||
DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
|
||||
DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
|
||||
DraftField::Submit => {
|
||||
// The submit button is indicated by the REVERSED style; no
|
||||
// cursor placement.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(draw).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_pane_renders_field_labels_and_values() {
|
||||
let draft = DraftPane {
|
||||
cwd: crate::text_input::TextInput::with_value("/x"),
|
||||
command: crate::text_input::TextInput::with_value("bash"),
|
||||
focused_field: DraftField::Cwd,
|
||||
};
|
||||
let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true));
|
||||
for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] {
|
||||
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_rotation_wraps_in_both_directions() {
|
||||
let mut draft = DraftPane::default();
|
||||
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||
draft.next_field();
|
||||
assert_eq!(draft.focused_field, DraftField::Command);
|
||||
draft.next_field();
|
||||
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||
draft.next_field();
|
||||
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||
draft.prev_field();
|
||||
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||
|
||||
draft.on_input(&InputKey::BackTab);
|
||||
assert_eq!(draft.focused_field, DraftField::Command);
|
||||
draft.on_input(&InputKey::Tab);
|
||||
draft.on_input(&InputKey::Tab);
|
||||
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||
}
|
||||
|
||||
/// Editing keys must reach the focused field's text input, not a bare
|
||||
/// String — this is what gives the operator cursor motion and word ops.
|
||||
#[test]
|
||||
fn editing_keys_reach_the_focused_text_input() {
|
||||
let mut draft = DraftPane::default();
|
||||
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||
for k in [
|
||||
InputKey::char('/'),
|
||||
InputKey::char('t'),
|
||||
InputKey::char('m'),
|
||||
InputKey::char('p'),
|
||||
] {
|
||||
draft.on_input(&k);
|
||||
}
|
||||
draft.on_input(&InputKey::Left);
|
||||
draft.on_input(&InputKey::Backspace);
|
||||
assert_eq!(draft.cwd.value(), "/tp");
|
||||
|
||||
// Tab to command, then a paste and ctrl-w land there, not in cwd.
|
||||
draft.on_input(&InputKey::Tab);
|
||||
assert_eq!(draft.focused_field, DraftField::Command);
|
||||
draft.on_input(&InputKey::Paste("/tmp/foo".into()));
|
||||
assert_eq!(draft.command.value(), "/tmp/foo");
|
||||
draft.on_input(&InputKey::ctrl_char('w'));
|
||||
assert_eq!(draft.command.value(), "");
|
||||
assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused");
|
||||
}
|
||||
|
||||
/// Enter walks forward until the submit field, where it submits.
|
||||
#[test]
|
||||
fn enter_walks_fields_then_submits_from_submit() {
|
||||
let mut draft = DraftPane::default();
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
|
||||
assert_eq!(draft.focused_field, DraftField::Command);
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
|
||||
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
/// The terminal cursor must sit right after the typed text in the focused
|
||||
/// field (label is 8 chars + 1 space, block border takes column 0).
|
||||
#[test]
|
||||
fn render_places_the_cursor_after_the_typed_text_when_focused() {
|
||||
let draft = DraftPane {
|
||||
cwd: crate::text_input::TextInput::with_value("abc"),
|
||||
..DraftPane::default()
|
||||
};
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
||||
let pos = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1));
|
||||
|
||||
// Unfocused pane: no field highlight either.
|
||||
let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false));
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| draft.render(f, f.area(), false)).unwrap();
|
||||
let buf = terminal.backend().buffer();
|
||||
assert!(
|
||||
!buf.content()
|
||||
.iter()
|
||||
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)),
|
||||
"unfocused draft must not highlight fields:\n{rendered_unfocused}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod app;
|
||||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod draft;
|
||||
pub mod emulators;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
|
||||
@@ -1,161 +1,16 @@
|
||||
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
|
||||
//! transient toast.
|
||||
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
|
||||
//!
|
||||
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
||||
//! panes ([`crate::draft::DraftPane`]).
|
||||
//!
|
||||
//! All overlays are centered popups drawn over the rest of the UI; the
|
||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpawnField {
|
||||
#[default]
|
||||
Cwd,
|
||||
Command,
|
||||
Submit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SpawnModal {
|
||||
pub cwd: crate::text_input::TextInput,
|
||||
pub command: crate::text_input::TextInput,
|
||||
pub focused_field: SpawnField,
|
||||
}
|
||||
|
||||
impl SpawnModal {
|
||||
pub fn next_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
SpawnField::Cwd => SpawnField::Command,
|
||||
SpawnField::Command => SpawnField::Submit,
|
||||
SpawnField::Submit => SpawnField::Cwd,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
SpawnField::Cwd => SpawnField::Submit,
|
||||
SpawnField::Command => SpawnField::Cwd,
|
||||
SpawnField::Submit => SpawnField::Command,
|
||||
};
|
||||
}
|
||||
|
||||
/// Handle one keystroke while this modal owns input. Returns
|
||||
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
|
||||
/// the POST and dismisses the modal.
|
||||
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
|
||||
use crate::input::InputKey;
|
||||
match key {
|
||||
InputKey::Tab => {
|
||||
self.next_field();
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
InputKey::BackTab => {
|
||||
self.prev_field();
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
InputKey::Esc => SpawnInputResult::Cancel,
|
||||
InputKey::Enter => match self.focused_field {
|
||||
SpawnField::Submit => SpawnInputResult::Submit,
|
||||
_ => {
|
||||
self.next_field();
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
},
|
||||
// Everything else — characters, motion, editing, paste — goes to
|
||||
// the focused field's text input.
|
||||
other => {
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => self.cwd.handle_key(other),
|
||||
SpawnField::Command => self.command.handle_key(other),
|
||||
SpawnField::Submit => {}
|
||||
}
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 60, 10);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" spawn ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
let highlight = |field: SpawnField| {
|
||||
if self.focused_field == field {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Each text field renders through the shared `TextInput`, which
|
||||
// handles horizontal scrolling and reports where the terminal cursor
|
||||
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
|
||||
let label =
|
||||
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
|
||||
f.render_widget(label("cwd:"), rows[0]);
|
||||
f.render_widget(label("command:"), rows[1]);
|
||||
let draw_field = |f: &mut Frame,
|
||||
input: &crate::text_input::TextInput,
|
||||
row: Rect,
|
||||
focused: bool|
|
||||
-> u16 {
|
||||
let style = if focused {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let area = Rect {
|
||||
x: row.x.saturating_add(9),
|
||||
y: row.y,
|
||||
width: row.width.saturating_sub(9),
|
||||
height: 1,
|
||||
};
|
||||
input.render(area, f.buffer_mut(), style)
|
||||
};
|
||||
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
|
||||
let cmd_col = draw_field(
|
||||
f,
|
||||
&self.command,
|
||||
rows[1],
|
||||
self.focused_field == SpawnField::Command,
|
||||
);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
|
||||
"[ Spawn ]",
|
||||
highlight(SpawnField::Submit),
|
||||
)])),
|
||||
rows[2],
|
||||
);
|
||||
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
|
||||
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
|
||||
SpawnField::Submit => {
|
||||
// The submit button is indicated by the REVERSED style; no
|
||||
// cursor placement.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpawnInputResult {
|
||||
Consumed,
|
||||
Cancel,
|
||||
Submit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KillConfirm {
|
||||
pub label: String,
|
||||
@@ -262,33 +117,6 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_modal_renders_field_labels() {
|
||||
let modal = SpawnModal {
|
||||
cwd: crate::text_input::TextInput::with_value("/x"),
|
||||
command: crate::text_input::TextInput::with_value("bash"),
|
||||
focused_field: SpawnField::Cwd,
|
||||
};
|
||||
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
|
||||
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
|
||||
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_modal_field_rotation() {
|
||||
let mut modal = SpawnModal::default();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
modal.prev_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_confirm_renders_prompt() {
|
||||
let modal = KillConfirm {
|
||||
@@ -306,81 +134,11 @@ mod tests {
|
||||
let help = HelpOverlay {
|
||||
lines: vec![
|
||||
"i EnterInsert".into(),
|
||||
"s OpenSpawnModal".into(),
|
||||
"s NewDraftPane".into(),
|
||||
],
|
||||
};
|
||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||
assert!(rendered.contains("EnterInsert"));
|
||||
assert!(rendered.contains("OpenSpawnModal"));
|
||||
}
|
||||
/// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the
|
||||
/// spawn modal could only be cycled forwards.
|
||||
#[test]
|
||||
fn shift_tab_walks_the_spawn_modal_backwards() {
|
||||
let mut modal = SpawnModal::default();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
|
||||
modal.on_input(&crate::input::InputKey::BackTab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
||||
|
||||
modal.on_input(&crate::input::InputKey::BackTab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_shift_tab_are_inverses() {
|
||||
let mut modal = SpawnModal::default();
|
||||
for _ in 0..3 {
|
||||
modal.on_input(&crate::input::InputKey::Tab);
|
||||
modal.on_input(&crate::input::InputKey::BackTab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing keys must reach the focused field's text input, not a bare
|
||||
/// String — this is what gives the operator cursor motion and word ops.
|
||||
#[test]
|
||||
fn editing_keys_reach_the_focused_text_input() {
|
||||
let mut modal = SpawnModal::default();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
for k in [
|
||||
crate::input::InputKey::char('/'),
|
||||
crate::input::InputKey::char('t'),
|
||||
crate::input::InputKey::char('m'),
|
||||
crate::input::InputKey::char('p'),
|
||||
] {
|
||||
modal.on_input(&k);
|
||||
}
|
||||
modal.on_input(&crate::input::InputKey::Left);
|
||||
modal.on_input(&crate::input::InputKey::Backspace);
|
||||
assert_eq!(modal.cwd.value(), "/tp");
|
||||
|
||||
// Tab to command, then a paste and ctrl-w land there, not in cwd.
|
||||
modal.on_input(&crate::input::InputKey::Tab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
||||
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
|
||||
assert_eq!(modal.command.value(), "/tmp/foo");
|
||||
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
|
||||
assert_eq!(modal.command.value(), "");
|
||||
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
|
||||
}
|
||||
|
||||
/// The terminal cursor must sit right after the typed text in the focused
|
||||
/// field (label is 8 chars + 1 space, modal is centered on screen).
|
||||
#[test]
|
||||
fn render_places_the_cursor_after_the_typed_text() {
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
let modal = SpawnModal {
|
||||
cwd: crate::text_input::TextInput::with_value("abc"),
|
||||
..SpawnModal::default()
|
||||
};
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| modal.render(f, f.area())).unwrap();
|
||||
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
|
||||
let pos = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
|
||||
assert!(rendered.contains("NewDraftPane"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::input::Region;
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
@@ -63,8 +63,9 @@ pub struct RenderInputs<'a> {
|
||||
pub sidebar: &'a Sidebar,
|
||||
pub tab_bar: &'a TabBar,
|
||||
pub pane: Option<&'a Pane>,
|
||||
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
pub spawn: &'a SpawnModal,
|
||||
pub help: &'a HelpOverlay,
|
||||
}
|
||||
|
||||
@@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_area);
|
||||
}
|
||||
if let Some(d) = inputs.draft {
|
||||
d.render(f, pane_area, pane_focused);
|
||||
}
|
||||
map.pane = Some(pane_area);
|
||||
|
||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||
@@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
render_status_bar(f, status_area, inputs.state);
|
||||
|
||||
match inputs.state.overlay() {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
inputs.spawn.render(f, area);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
Some(OverlayKind::KillConfirm(id)) => {
|
||||
let kc = KillConfirm {
|
||||
label: id.to_string(),
|
||||
@@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
Focus::Pane => "pane",
|
||||
};
|
||||
let hint = match state.mode {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: back to normal",
|
||||
};
|
||||
let mode_style = match state.mode {
|
||||
@@ -266,4 +266,57 @@ mod tests {
|
||||
assert_eq!(map.region_at(15, 10), Region::Modal);
|
||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||
}
|
||||
|
||||
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||
/// form where the terminal would otherwise be.
|
||||
#[test]
|
||||
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::state::PaneRef;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
let draft = match &state.tabs[0].pane {
|
||||
Some(PaneRef::Draft(d)) => d,
|
||||
other => panic!("expected a draft pane, got {other:?}"),
|
||||
};
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let tab_bar = TabBar {
|
||||
tab_names: vec!["1".into()],
|
||||
active_idx: 0,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
pane: None,
|
||||
draft: Some(draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
};
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
|
||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-15
@@ -1,14 +1,16 @@
|
||||
//! `AppState` — the top-level mutable state of the running TUI.
|
||||
//!
|
||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
||||
//! session), an active tab index, focus split between sidebar and pane, the
|
||||
//! current input mode, and an optional overlay (spawn modal, kill confirm,
|
||||
//! help, transient toast).
|
||||
//! session or a draft form), an active tab index, focus split between sidebar
|
||||
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
||||
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
||||
//! pane is a draft pane (issue #63).
|
||||
//!
|
||||
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
|
||||
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::draft::DraftPane;
|
||||
use crate::keymap::Mode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -17,15 +19,31 @@ pub enum Focus {
|
||||
Pane,
|
||||
}
|
||||
|
||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PaneRef {
|
||||
Session(Uuid),
|
||||
Draft(DraftPane),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tab {
|
||||
pub name: String,
|
||||
pub pane: Option<Uuid>,
|
||||
pub pane: Option<PaneRef>,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
/// The session this tab shows, if it shows one. Draft panes have none.
|
||||
pub fn session(&self) -> Option<Uuid> {
|
||||
match &self.pane {
|
||||
Some(PaneRef::Session(sid)) => Some(*sid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OverlayKind {
|
||||
Spawn,
|
||||
KillConfirm(Uuid),
|
||||
Help,
|
||||
Toast(String),
|
||||
@@ -108,7 +126,8 @@ impl AppState {
|
||||
///
|
||||
/// Returns the session that tab was showing, if any, so the caller can
|
||||
/// release its emulator. Without that the tab's WebSocket task and vt100
|
||||
/// parser outlive the tab forever.
|
||||
/// parser outlive the tab forever. A draft pane holds no session and
|
||||
/// releases nothing.
|
||||
pub fn close_tab(&mut self) -> Option<Uuid> {
|
||||
if self.tabs.len() <= 1 {
|
||||
return None;
|
||||
@@ -117,9 +136,10 @@ impl AppState {
|
||||
if self.active_tab_idx >= self.tabs.len() {
|
||||
self.active_tab_idx = self.tabs.len() - 1;
|
||||
}
|
||||
let sid = closed.session()?;
|
||||
// Only release it if no other tab still shows the same session.
|
||||
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
|
||||
closed.pane.filter(|_| !still_shown)
|
||||
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
||||
(!still_shown).then_some(sid)
|
||||
}
|
||||
|
||||
pub fn next_tab(&mut self) {
|
||||
@@ -147,11 +167,37 @@ impl AppState {
|
||||
/// drop straight into Insert mode so keystrokes go to the session
|
||||
/// without an extra `i` press. The leader returns to Normal.
|
||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||
self.active_tab_mut().pane = Some(session_id);
|
||||
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
||||
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
||||
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
||||
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
||||
pub fn open_draft_pane(&mut self) {
|
||||
if self.active_tab().pane.is_some() {
|
||||
self.new_tab();
|
||||
}
|
||||
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// A draft submit succeeded: replace whatever the active tab showed with
|
||||
/// the new session in place, matching the web client's behaviour (#57).
|
||||
pub fn complete_spawn(&mut self, sid: Uuid) {
|
||||
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// The session shown by the active tab's pane, if any.
|
||||
pub fn active_session(&self) -> Option<Uuid> {
|
||||
self.active_tab().session()
|
||||
}
|
||||
|
||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
||||
@@ -288,7 +334,7 @@ mod tests {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(7);
|
||||
state.attach_to_focused(sid);
|
||||
assert_eq!(state.tabs[0].pane, Some(sid));
|
||||
assert_eq!(state.tabs[0].session(), Some(sid));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
// The user expects to type into the session right away; the leader
|
||||
// (default ctrl-a) is the way back.
|
||||
@@ -314,7 +360,7 @@ mod tests {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(3);
|
||||
state.new_tab();
|
||||
state.active_tab_mut().pane = Some(sid);
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
|
||||
assert_eq!(state.close_tab(), Some(sid));
|
||||
}
|
||||
@@ -324,9 +370,9 @@ mod tests {
|
||||
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(4);
|
||||
state.active_tab_mut().pane = Some(sid);
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
state.new_tab();
|
||||
state.active_tab_mut().pane = Some(sid);
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
|
||||
assert_eq!(state.close_tab(), None);
|
||||
}
|
||||
@@ -334,7 +380,7 @@ mod tests {
|
||||
#[test]
|
||||
fn close_tab_is_a_no_op_on_the_last_tab() {
|
||||
let mut state = AppState::new();
|
||||
state.active_tab_mut().pane = Some(Uuid::from_u128(5));
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
||||
assert_eq!(state.close_tab(), None);
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
@@ -347,4 +393,52 @@ mod tests {
|
||||
assert!(state.dismiss_overlay());
|
||||
assert!(state.overlay().is_none());
|
||||
}
|
||||
|
||||
/// Issue #63: `s` over an empty pane turns that pane into a draft —
|
||||
/// focused, Insert mode on, typing starts immediately.
|
||||
#[test]
|
||||
fn open_draft_reuses_the_empty_active_pane() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
|
||||
/// A tab already showing a session gets a fresh tab for the draft; v1
|
||||
/// has no split tree, so a new tab is the "split".
|
||||
#[test]
|
||||
fn open_draft_over_a_session_opens_a_new_tab() {
|
||||
let mut state = AppState::new();
|
||||
state.attach_to_focused(Uuid::from_u128(9));
|
||||
state.open_draft_pane();
|
||||
assert_eq!(state.tabs.len(), 2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||
// The session tab is untouched.
|
||||
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
||||
}
|
||||
|
||||
/// Web parity (#57): submitting fills the pane in place.
|
||||
#[test]
|
||||
fn complete_spawn_replaces_the_draft_with_the_session() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
let sid = Uuid::from_u128(21);
|
||||
state.complete_spawn(sid);
|
||||
assert_eq!(state.active_tab().session(), Some(sid));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
}
|
||||
|
||||
/// A closed draft tab holds no session, so nothing is released.
|
||||
#[test]
|
||||
fn close_tab_reports_nothing_for_a_draft_pane() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
state.new_tab();
|
||||
state.active_tab_idx = 0;
|
||||
assert_eq!(state.close_tab(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use ratatui::style::Style;
|
||||
|
||||
use crate::input::InputKey;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct TextInput {
|
||||
value: String,
|
||||
/// Cursor position as a char offset into `value`.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.11.6",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.9"
|
||||
|
||||
Generated
+93
-11
@@ -15,15 +15,18 @@ importers:
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
devDependencies:
|
||||
happy-dom:
|
||||
specifier: ^20.11.6
|
||||
version: 20.11.6
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^5.4.10
|
||||
version: 5.4.21
|
||||
version: 5.4.21(@types/node@26.2.0)
|
||||
vitest:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9
|
||||
version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -296,6 +299,15 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/node@26.2.0':
|
||||
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
@@ -337,6 +349,10 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -362,6 +378,10 @@ packages:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
@@ -382,6 +402,10 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
happy-dom@20.11.6:
|
||||
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@@ -451,6 +475,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -512,11 +539,27 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
@@ -667,6 +710,16 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/node@26.2.0':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
@@ -674,13 +727,13 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21)':
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21
|
||||
vite: 5.4.21(@types/node@26.2.0)
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
dependencies:
|
||||
@@ -715,6 +768,10 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
chai@5.3.3:
|
||||
@@ -733,6 +790,8 @@ snapshots:
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
@@ -770,6 +829,19 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
happy-dom@20.11.6:
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
buffer-image-size: 0.6.4
|
||||
entities: 7.0.1
|
||||
whatwg-mimetype: 3.0.0
|
||||
ws: 8.21.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
@@ -843,13 +915,15 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite-node@2.1.9:
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
vite-node@2.1.9(@types/node@26.2.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21
|
||||
vite: 5.4.21(@types/node@26.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
@@ -861,18 +935,19 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite@5.4.21:
|
||||
vite@5.4.21(@types/node@26.2.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.15
|
||||
rollup: 4.60.4
|
||||
optionalDependencies:
|
||||
'@types/node': 26.2.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@2.1.9:
|
||||
vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21)
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0))
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
@@ -888,9 +963,12 @@ snapshots:
|
||||
tinyexec: 0.3.2
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.21
|
||||
vite-node: 2.1.9
|
||||
vite: 5.4.21(@types/node@26.2.0)
|
||||
vite-node: 2.1.9(@types/node@26.2.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.2.0
|
||||
happy-dom: 20.11.6
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
@@ -902,7 +980,11 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
+8
-2
@@ -5,6 +5,7 @@ import type {
|
||||
DaemonInfo,
|
||||
DirListing,
|
||||
GitRoot,
|
||||
Projects,
|
||||
RecentDirs,
|
||||
Session,
|
||||
Uuid,
|
||||
@@ -61,12 +62,17 @@ export async function getDaemonInfo(): Promise<DaemonInfo> {
|
||||
return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the
|
||||
* spawn form's recents picker. */
|
||||
/** Recently-used working dirs, newest-first and deduped by dir (#26). */
|
||||
export async function recentDirs(): Promise<RecentDirs> {
|
||||
return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Every discovered git repository / worktree, newest-activity-first (#54).
|
||||
* Drives the spawn form's project picker. */
|
||||
export async function projects(): Promise<Projects> {
|
||||
return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
|
||||
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
|
||||
* it against its home dir. */
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
|
||||
|
||||
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
|
||||
import { listDir } from "./api.js";
|
||||
|
||||
const listDirMock = vi.mocked(listDir);
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let nextField: HTMLInputElement;
|
||||
let docKeydown: ReturnType<typeof vi.fn>;
|
||||
let ac: DirAutocompleteHandle | undefined;
|
||||
|
||||
function popup(): HTMLUListElement {
|
||||
return document.querySelector(".dir-ac") as HTMLUListElement;
|
||||
}
|
||||
|
||||
function items(): HTMLElement[] {
|
||||
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
|
||||
}
|
||||
|
||||
async function type(value: string): Promise<void> {
|
||||
input.focus();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
|
||||
}
|
||||
|
||||
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
|
||||
* default browser behaviour (focus move, form submit) was suppressed. */
|
||||
function press(key: string): { defaultPrevented: boolean } {
|
||||
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(evt);
|
||||
return { defaultPrevented: evt.defaultPrevented };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
document.body.replaceChildren();
|
||||
input = document.createElement("input");
|
||||
nextField = document.createElement("input");
|
||||
document.body.append(input, nextField);
|
||||
docKeydown = vi.fn();
|
||||
document.addEventListener("keydown", docKeydown);
|
||||
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ac?.destroy();
|
||||
document.removeEventListener("keydown", docKeydown);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("dirAutocomplete dismissal", () => {
|
||||
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
expect(popup().hidden).toBe(false);
|
||||
|
||||
const { defaultPrevented } = press("Escape");
|
||||
|
||||
expect(defaultPrevented).toBe(true);
|
||||
expect(popup().hidden).toBe(true);
|
||||
expect(input.value).toBe("/tmp/pro");
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
|
||||
});
|
||||
|
||||
it("blur dismisses the popup", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
expect(popup().hidden).toBe(false);
|
||||
|
||||
input.blur();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
expect(popup().hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reopen on refocus until the operator types again", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
input.blur();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(popup().hidden).toBe(true);
|
||||
|
||||
await type("/tmp/ot");
|
||||
expect(popup().hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirAutocomplete keyboard model", () => {
|
||||
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
|
||||
|
||||
nextField.focus();
|
||||
const { defaultPrevented } = press("Tab");
|
||||
|
||||
expect(input.value).toBe("/tmp/proj-a/");
|
||||
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
|
||||
expect(document.activeElement).toBe(nextField); // focus was not yanked back
|
||||
});
|
||||
|
||||
it("Tab with nothing highlighted just moves on", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/nope-matches-nothing");
|
||||
expect(popup().hidden).toBe(true);
|
||||
|
||||
const { defaultPrevented } = press("Tab");
|
||||
|
||||
expect(defaultPrevented).toBe(false);
|
||||
expect(input.value).toBe("/tmp/nope-matches-nothing");
|
||||
});
|
||||
|
||||
it("arrows move the highlight and Enter commits it without submitting", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/");
|
||||
|
||||
press("ArrowDown");
|
||||
expect(items()[1].classList.contains("is-active")).toBe(true);
|
||||
|
||||
const enter = press("Enter");
|
||||
expect(enter.defaultPrevented).toBe(true); // no form submit
|
||||
expect(input.value).toBe("/tmp/proj-b/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirAutocomplete opening", () => {
|
||||
it("still opens on focus when not previously dismissed", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(popup().hidden).toBe(false);
|
||||
expect(listDirMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+44
-27
@@ -4,15 +4,19 @@
|
||||
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
|
||||
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
|
||||
//
|
||||
// Tab complete the longest common prefix; if one match, accept it (and
|
||||
// append `/` so the next level loads); if nothing to extend, cycle.
|
||||
// Tab commit the highlighted entry and let focus move on; with nothing
|
||||
// highlighted, plain tab navigation
|
||||
// ↑/↓ move the highlight
|
||||
// Enter accept the highlighted entry (and suppress form submit)
|
||||
// Esc close the dropdown
|
||||
// Esc close the dropdown, refocus the input, and swallow the key so it
|
||||
// can't also discard a surrounding draft pane
|
||||
//
|
||||
// Once dismissed (Esc or blur) the popup stays closed until the operator types
|
||||
// again — refocusing alone doesn't reopen it.
|
||||
//
|
||||
// Failures (offline daemon, permission denied) degrade to no suggestions.
|
||||
|
||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { listDir } from "./api.js";
|
||||
|
||||
export interface DirAutocompleteHandle {
|
||||
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
let fetchSeq = 0;
|
||||
let debounce: number | undefined;
|
||||
let open = false;
|
||||
// Set when the operator dismissed the popup (Esc, blur, outside click); a
|
||||
// refocus must not reopen it — only new typing does.
|
||||
let dismissed = false;
|
||||
|
||||
|
||||
function close(): void {
|
||||
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
window.removeEventListener("resize", reposition);
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
dismissed = true;
|
||||
close();
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
const r = input.getBoundingClientRect();
|
||||
dropdown.style.left = `${r.left}px`;
|
||||
@@ -97,6 +109,14 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
}
|
||||
|
||||
function applyFilter(entries: string[], prefix: string): void {
|
||||
// A fetch can outlive the dismissal that was meant to close this popup
|
||||
// (Tab-commit re-arms a refresh, then blur dismisses 120ms later). Showing
|
||||
// it now would anchor a dropdown under an unfocused input that nothing
|
||||
// closes until the next interaction.
|
||||
if (dismissed || document.activeElement !== input) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const lower = prefix.toLowerCase();
|
||||
filtered = entries.filter((e) => e.toLowerCase().startsWith(lower));
|
||||
highlight = filtered.length > 0 ? 0 : -1;
|
||||
@@ -132,21 +152,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
}
|
||||
|
||||
// Accept `name` as the next path segment: complete it and append `/` so the
|
||||
// child directory's listing loads immediately (the drill-in gesture).
|
||||
function accept(name: string): void {
|
||||
// child directory's listing loads on the next interaction (the drill-in
|
||||
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
|
||||
// and move to the next field in one gesture.
|
||||
function commit(name: string): void {
|
||||
const { dir } = splitPath(input.value);
|
||||
input.value = `${dir}${name}/`;
|
||||
input.focus();
|
||||
// Notify other listeners (spawn.ts marks the field user-edited) and load
|
||||
// the child level.
|
||||
// Notify other listeners (spawnForm marks the field user-edited).
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function accept(name: string): void {
|
||||
commit(name);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
dismissed = false;
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (dismissed) return;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -154,27 +181,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
// Swallow the key: an Escape that bubbles would also discard the
|
||||
// draft pane hosting this form.
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
input.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
if (!open || filtered.length === 0) return; // let focus move normally
|
||||
e.preventDefault();
|
||||
if (filtered.length === 1) {
|
||||
accept(filtered[0]);
|
||||
return;
|
||||
}
|
||||
const { dir, prefix } = splitPath(input.value);
|
||||
const lcp = longestCommonPrefix(filtered);
|
||||
if (lcp.length > prefix.length) {
|
||||
input.value = `${dir}${lcp}`;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
} else {
|
||||
// Nothing to extend — cycle the highlight like a second shell <tab>.
|
||||
highlight = (highlight + 1) % filtered.length;
|
||||
render();
|
||||
}
|
||||
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
|
||||
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
@@ -196,7 +213,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
|
||||
function onBlur(): void {
|
||||
// Delay so a dropdown mousedown can complete first.
|
||||
window.setTimeout(close, 120);
|
||||
window.setTimeout(dismiss, 120);
|
||||
}
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
|
||||
+35
-12
@@ -33,8 +33,8 @@ function sess(
|
||||
};
|
||||
}
|
||||
|
||||
/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full
|
||||
* tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only;
|
||||
/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the
|
||||
* full flow — presets, projects, gitroot — with no daemon running. Dev-only;
|
||||
* delegates every other request to the real `fetch`. */
|
||||
function stubSpawnApi(): void {
|
||||
const real = globalThis.fetch.bind(globalThis);
|
||||
@@ -61,16 +61,39 @@ function stubSpawnApi(): void {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path.includes("/recent-dirs")) {
|
||||
if (path.includes("/projects")) {
|
||||
const now = Date.now();
|
||||
const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
|
||||
return Promise.resolve(
|
||||
json({
|
||||
dirs: [
|
||||
{ cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" },
|
||||
{ cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" },
|
||||
{ cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" },
|
||||
],
|
||||
truncated: false,
|
||||
}),
|
||||
json([
|
||||
{
|
||||
path: "/home/dev/git/maestro",
|
||||
name: "maestro",
|
||||
is_worktree: false,
|
||||
last_activity: iso(0),
|
||||
last_command: "claude",
|
||||
},
|
||||
{
|
||||
path: "/home/dev/worktrees/maestro-59",
|
||||
name: "maestro-59",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/dev/git/maestro",
|
||||
last_activity: iso(1),
|
||||
last_command: "opencode",
|
||||
},
|
||||
{
|
||||
path: "/home/dev/git/dotfiles",
|
||||
name: "dotfiles",
|
||||
is_worktree: false,
|
||||
last_activity: iso(30),
|
||||
last_command: "zsh",
|
||||
},
|
||||
{
|
||||
path: "/home/dev/work/acme/api",
|
||||
name: "api",
|
||||
is_worktree: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (path.includes("/gitroot")) {
|
||||
@@ -113,7 +136,7 @@ export function seedMock(store: CatalogStore): void {
|
||||
});
|
||||
|
||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
||||
// backend endpoints stubbed so presets/recents/gitroot all render.
|
||||
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||
if (location.search.includes("spawn")) {
|
||||
stubSpawnApi();
|
||||
void import("./spawn.js").then((m) => m.openSpawnModal());
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const NOW = new Date("2026-08-23T12:00:00Z");
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
path: `/home/user/${overrides.name ?? "proj"}`,
|
||||
name: "proj",
|
||||
is_worktree: false,
|
||||
last_activity: undefined,
|
||||
last_command: undefined,
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
describe("isStale", () => {
|
||||
it("marks projects with no known activity stale", () => {
|
||||
expect(isStale(project({ name: "never" }), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("is fresh within 7 days and stale beyond", () => {
|
||||
const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" });
|
||||
const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" });
|
||||
expect(isStale(fresh, NOW)).toBe(false);
|
||||
expect(isStale(stale, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortProjects", () => {
|
||||
it("sorts newest-activity first", () => {
|
||||
const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" });
|
||||
const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" });
|
||||
expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("sorts stale rows after fresh ones, each block newest first", () => {
|
||||
const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" });
|
||||
const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" });
|
||||
const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" });
|
||||
const staleNone = project({ name: "stale-none" });
|
||||
const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW);
|
||||
expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" });
|
||||
const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" });
|
||||
sortProjects([a, b], NOW);
|
||||
expect(a.name).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupProjects", () => {
|
||||
it("keeps plain repositories as their own single-entry group", () => {
|
||||
const solo = project({ name: "solo" });
|
||||
expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository even when fresher than it", () => {
|
||||
const main = project({ name: "maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
last_activity: "2026-08-23T00:00:00Z",
|
||||
});
|
||||
const groups = groupProjects(sortProjects([wt, main], NOW));
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].main).toBe(main);
|
||||
expect(groups[0].worktrees).toEqual([wt]);
|
||||
});
|
||||
|
||||
it("orders groups by the freshest member and worktrees by activity", () => {
|
||||
const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" });
|
||||
const wtA = project({
|
||||
name: "a-wt",
|
||||
path: "/w/a-wt",
|
||||
is_worktree: true,
|
||||
base_repo: "/w/a",
|
||||
last_activity: "2026-08-20T00:00:00Z",
|
||||
});
|
||||
const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" });
|
||||
const wtB1 = project({
|
||||
name: "b-wt1",
|
||||
path: "/w/b-wt1",
|
||||
is_worktree: true,
|
||||
base_repo: "/w/b",
|
||||
last_activity: "2026-08-19T00:00:00Z",
|
||||
});
|
||||
const wtB2 = project({
|
||||
name: "b-wt2",
|
||||
path: "/w/b-wt2",
|
||||
is_worktree: true,
|
||||
base_repo: "/w/b",
|
||||
last_activity: "2026-08-21T00:00:00Z",
|
||||
});
|
||||
|
||||
const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW));
|
||||
expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]);
|
||||
const gA = groups.find((g) => g.main.name === "a")!;
|
||||
const gB = groups.find((g) => g.main.name === "b")!;
|
||||
expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]);
|
||||
expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterProjects", () => {
|
||||
const maestro = project({ name: "maestro", path: "/home/u/git/maestro" });
|
||||
const tea = project({ name: "tea", path: "/home/u/work/tea" });
|
||||
|
||||
it("matches on name case-insensitively", () => {
|
||||
expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]);
|
||||
});
|
||||
|
||||
it("matches on the full path", () => {
|
||||
expect(filterProjects([maestro, tea], "work")).toEqual([tea]);
|
||||
});
|
||||
|
||||
it("returns everything unchanged for an empty query", () => {
|
||||
expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Pure helpers behind the spawn form's project picker (#59): staleness,
|
||||
// recency ordering, worktree grouping, and free-text filtering. The daemon
|
||||
// reports the timestamps; what "stale" means is a client decision (#54).
|
||||
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
/** Projects whose last activity is older than this are dimmed and sort after
|
||||
* fresh ones — still reachable, visibly stale (#59). */
|
||||
export const STALE_AFTER_DAYS = 7;
|
||||
|
||||
export function isStale(project: Project, now: Date): boolean {
|
||||
if (!project.last_activity) return true;
|
||||
const age = now.getTime() - Date.parse(project.last_activity);
|
||||
return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/** Fresh rows first, each block newest-activity-first; unknown or future
|
||||
* timestamps sort as stalest. Ties fall back to name so the order is stable. */
|
||||
export function sortProjects(projects: Project[], now: Date): Project[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0);
|
||||
if (staleDiff !== 0) return staleDiff;
|
||||
const ta = timeOf(a);
|
||||
const tb = timeOf(b);
|
||||
if (ta !== tb) return tb - ta;
|
||||
return a.name.localeCompare(b.name) || a.path.localeCompare(b.path);
|
||||
});
|
||||
}
|
||||
|
||||
function timeOf(project: Project): number {
|
||||
return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** One repository group: the main checkout followed by its linked worktrees.
|
||||
* A group's position follows its freshest member, since a worktree worked on
|
||||
* today should pull its (dormant) base repo up with it. */
|
||||
export interface ProjectGroup {
|
||||
main: Project;
|
||||
worktrees: Project[];
|
||||
}
|
||||
|
||||
/** Group `sorted` projects by their base repository. A worktree nests under
|
||||
* its main checkout even when the main sorts later; a main checkout missing
|
||||
* from the list doesn't drop its worktrees. */
|
||||
export function groupProjects(sorted: Project[]): ProjectGroup[] {
|
||||
const members = new Map<string, Project[]>();
|
||||
const keys: string[] = [];
|
||||
for (const p of sorted) {
|
||||
const key = p.base_repo ?? p.path;
|
||||
let list = members.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
members.set(key, list);
|
||||
keys.push(key);
|
||||
}
|
||||
list.push(p);
|
||||
}
|
||||
return keys.map((key) => {
|
||||
const list = members.get(key)!;
|
||||
const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0];
|
||||
// Keep the daemon's newest-first order within the group.
|
||||
const worktrees = list
|
||||
.filter((p) => p !== main)
|
||||
.sort((a, b) => timeOf(b) - timeOf(a));
|
||||
return { main, worktrees };
|
||||
});
|
||||
}
|
||||
|
||||
/** Substring filter over project name and full path, case-insensitive. */
|
||||
export function filterProjects(projects: Project[], query: string): Project[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [...projects];
|
||||
return projects.filter(
|
||||
(p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const NOW = "2026-08-23T12:00:00Z";
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
path: `/home/user/${overrides.name ?? "proj"}`,
|
||||
name: "proj",
|
||||
is_worktree: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Two fetches gate the form's initial load: /api/info then /api/projects. */
|
||||
function stubFetch(info: unknown, projects: Project[]): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? info
|
||||
: url.includes("/api/projects")
|
||||
? projects
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rowPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
||||
(r) => r.dataset.cwd ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm project picker", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse(NOW) });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
||||
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
|
||||
handle = createSpawnForm({
|
||||
seed,
|
||||
onSuccess() {},
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
return handle;
|
||||
}
|
||||
|
||||
it("renders one row per project, fresh before stale", async () => {
|
||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||
const stale = project({
|
||||
name: "stale",
|
||||
path: "/home/user/stale",
|
||||
last_activity: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const form = (await mount([stale, fresh])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository", async () => {
|
||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
});
|
||||
const form = (await mount([wt, main])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
||||
const [mainRow, wtRow] = [
|
||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
||||
];
|
||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows as the operator types", async () => {
|
||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||
const form = (await mount([a, b])).el;
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "tea";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
filter.value = "zzz";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual([]);
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
||||
const p = project({
|
||||
name: "maestro",
|
||||
path: "/home/user/maestro",
|
||||
last_command: "claude",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
stubFetch(
|
||||
{
|
||||
hostname: "h",
|
||||
version: "0",
|
||||
home_dir: "/home/user",
|
||||
presets: [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
],
|
||||
},
|
||||
[p],
|
||||
);
|
||||
handle = createSpawnForm({ onSuccess() {}, onChange });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
onChange.mockClear();
|
||||
|
||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
||||
row.click();
|
||||
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked row is the source of truth.
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||
expect(row.classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||
const p = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const form = (await mount([p])).el;
|
||||
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
|
||||
other.click();
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the raw input when no projects exist", async () => {
|
||||
const form = (await mount([])).el;
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
|
||||
expect(raw.value).toBe("/home/user");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const SESSIONS = "/api/sessions";
|
||||
export const INFO = "/api/info";
|
||||
export const DIRS = "/api/dirs";
|
||||
export const RECENT_DIRS = "/api/recent-dirs";
|
||||
export const PROJECTS = "/api/projects";
|
||||
export const GITROOT = "/api/gitroot";
|
||||
export const EVENTS = "/api/events";
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ const base: SpawnFormValues = {
|
||||
name: "amber-fox",
|
||||
worktreeEnabled: false,
|
||||
worktreeBranch: "",
|
||||
worktreeCleanup: false,
|
||||
};
|
||||
|
||||
describe("buildSpawnRequest", () => {
|
||||
@@ -24,9 +23,9 @@ describe("buildSpawnRequest", () => {
|
||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||
});
|
||||
|
||||
it("attaches a trimmed branch and cleanup flag when worktree is enabled", () => {
|
||||
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
||||
const res = buildSpawnRequest(
|
||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo ", worktreeCleanup: true },
|
||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
||||
"/home/user/project",
|
||||
);
|
||||
expect(res).toEqual({
|
||||
|
||||
+91
-62
@@ -1,8 +1,9 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
|
||||
import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import { generateName } from "./names.js";
|
||||
import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js";
|
||||
import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
||||
@@ -14,7 +15,6 @@ export interface SpawnFormSeed {
|
||||
name?: string;
|
||||
worktreeEnabled?: boolean;
|
||||
worktreeBranch?: string;
|
||||
worktreeCleanup?: boolean;
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface SpawnFormValues {
|
||||
name: string;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
worktreeCleanup: boolean;
|
||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
@@ -55,7 +54,9 @@ export function buildSpawnRequest(
|
||||
req.worktree = {
|
||||
base_repo: baseRepo,
|
||||
branch,
|
||||
cleanup_on_kill: values.worktreeCleanup,
|
||||
// The daemon always cleans up, guarded against unsaved work (#56);
|
||||
// the wire field stays for older daemons.
|
||||
cleanup_on_kill: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +88,7 @@ export interface SpawnFormOptions {
|
||||
onSuccess: (session: Session) => void;
|
||||
}
|
||||
|
||||
/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree.
|
||||
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
||||
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
|
||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const prefill = opts.seed ?? {};
|
||||
@@ -103,7 +104,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Working directory</span>
|
||||
<div class="picker recents"></div>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
autocomplete="off" />
|
||||
<div class="picker projects"></div>
|
||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
@@ -130,10 +133,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_cleanup_on_kill" />
|
||||
Remove worktree when session is killed
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||
@@ -147,7 +146,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
||||
const recentsEl = form.querySelector(".recents") as HTMLElement;
|
||||
const recentsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
||||
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;
|
||||
@@ -155,9 +155,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||
const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
|
||||
const cleanupInput = form.querySelector(
|
||||
"input[name=worktree_cleanup_on_kill]",
|
||||
) as HTMLInputElement;
|
||||
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
// ---- form state ------------------------------------------------------
|
||||
@@ -168,18 +165,26 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
let cwd = ""; // chosen working directory
|
||||
let inferredBaseRepo: string | null = null; // from the gitroot probe
|
||||
let probeToken = 0; // guards async gitroot probes against races
|
||||
// A seed's cwd, applied once the (async) recents load for the initial host
|
||||
// A seed's cwd, applied once the (async) projects load for the initial host
|
||||
// settles — so the fetch can't race past and clobber the seeded directory.
|
||||
let seedCwd: string | null = prefill.cwd ?? null;
|
||||
|
||||
// `emit` stays silent until the initial load settles and goes silent again
|
||||
// once the form is destroyed. Construction runs with `cwd === ""` and the
|
||||
// seed is only restored two fetches later, so emitting in between would
|
||||
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the
|
||||
// old one's fetches are still in flight, so a destroyed form emitting would
|
||||
// clobber the live one's leaf (#57 review).
|
||||
let ready = false;
|
||||
let destroyed = false;
|
||||
const emit = () => {
|
||||
if (!ready || destroyed) return;
|
||||
opts.onChange?.({
|
||||
command,
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchInput.value,
|
||||
worktreeCleanup: cleanupInput.checked,
|
||||
cwdRawVisible: !cwdRawWrap.hidden,
|
||||
});
|
||||
};
|
||||
@@ -291,11 +296,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwdRawWrap.hidden = false;
|
||||
if (prefillValue != null) cwd = prefillValue;
|
||||
cwdRaw.value = cwd;
|
||||
markActiveRecent(null);
|
||||
markActiveProject(null);
|
||||
if (focus) {
|
||||
cwdRaw.focus();
|
||||
cwdRaw.select();
|
||||
}
|
||||
// Both the visibility and the directory are draft state: without this a
|
||||
// render before the operator types snaps back to the project picker.
|
||||
emit();
|
||||
}
|
||||
|
||||
cwdRaw.addEventListener("input", () => {
|
||||
@@ -304,8 +312,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
});
|
||||
|
||||
function markActiveRecent(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".recent-row")) {
|
||||
function markActiveProject(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
||||
row.classList.toggle(
|
||||
"is-active",
|
||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
||||
@@ -313,78 +321,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
}
|
||||
}
|
||||
|
||||
function selectRecent(dir: RecentDir): void {
|
||||
cwd = dir.cwd;
|
||||
function selectProject(project: Project): void {
|
||||
cwd = project.path;
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveRecent(dir.cwd);
|
||||
markActiveProject(project.path);
|
||||
emit();
|
||||
// Picking a recent dir can pre-select the command it was last used with.
|
||||
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
|
||||
// Picking a project pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
}
|
||||
|
||||
async function loadRecents(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
let dirs: RecentDir[] = [];
|
||||
try {
|
||||
dirs = (await recentDirs()).dirs ?? [];
|
||||
} catch {
|
||||
dirs = [];
|
||||
}
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
|
||||
function renderProjects(): void {
|
||||
recentsEl.replaceChildren();
|
||||
for (const dir of dirs) {
|
||||
recentsEl.appendChild(recentRow(dir));
|
||||
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
||||
for (const group of groupProjects(sorted)) {
|
||||
recentsEl.appendChild(projectRow(group.main));
|
||||
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
||||
}
|
||||
if (discovered.length > 0 && sorted.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "picker-empty";
|
||||
empty.textContent = "No projects match.";
|
||||
recentsEl.appendChild(empty);
|
||||
}
|
||||
// Persistent escape hatch + empty-history fallback.
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
||||
}
|
||||
|
||||
filterInput.addEventListener("input", renderProjects);
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
try {
|
||||
discovered = await fetchProjects();
|
||||
} catch {
|
||||
discovered = [];
|
||||
}
|
||||
if (destroyed) return;
|
||||
renderProjects();
|
||||
if (seedCwd) {
|
||||
// A seed sets the directory verbatim, regardless of recorded history.
|
||||
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
||||
// picked recent row: keep the picker and don't steal focus. Otherwise
|
||||
// land focused in the raw input (clone / empty-history flows).
|
||||
// picked row: keep the picker and don't steal focus. Otherwise land
|
||||
// focused in the raw input (clone / no-match flows).
|
||||
if (prefill.cwdRawVisible === false) {
|
||||
cwd = seedCwd;
|
||||
cwdRaw.value = seedCwd;
|
||||
markActiveRecent(cwd);
|
||||
markActiveProject(cwd);
|
||||
} else {
|
||||
showRawCwd(seedCwd, false);
|
||||
}
|
||||
seedCwd = null;
|
||||
} else if (dirs.length === 0) {
|
||||
// No history: default to raw input prefilled with the daemon's home dir.
|
||||
} else if (discovered.length === 0) {
|
||||
// Nothing discovered yet: default to raw input prefilled with home.
|
||||
showRawCwd(homeDir, false);
|
||||
} else {
|
||||
cwdRawWrap.hidden = true;
|
||||
}
|
||||
// The seeded cwd is in place — a restored worktree can probe now.
|
||||
// Everything seeded: the form may publish from here on.
|
||||
ready = true;
|
||||
if (wtEnabled.checked) void probeGitRoot();
|
||||
// Publish it: construction ran `emit()` with an empty cwd, so without this
|
||||
// the leaf's draft keeps `cwd: ""` and the next render reseeds it blank.
|
||||
emit();
|
||||
}
|
||||
|
||||
function recentRow(dir: RecentDir): HTMLElement {
|
||||
function projectRow(project: Project): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "recent-row";
|
||||
row.dataset.cwd = dir.cwd;
|
||||
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
||||
isStale(project, new Date()) ? " is-stale" : ""
|
||||
}`;
|
||||
row.dataset.cwd = project.path;
|
||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
||||
// and `dataset.cwd` (what's actually submitted).
|
||||
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
|
||||
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
||||
row.innerHTML = `
|
||||
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
|
||||
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
|
||||
row.addEventListener("click", () => selectRecent(dir));
|
||||
<span class="project-info">
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
||||
</span>
|
||||
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
||||
row.addEventListener("click", () => selectProject(project));
|
||||
return row;
|
||||
}
|
||||
|
||||
function otherDirRow(): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "recent-row recent-other";
|
||||
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
|
||||
row.className = "project-row project-other";
|
||||
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
return row;
|
||||
}
|
||||
@@ -392,7 +420,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
function loadingRow(): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "picker-loading";
|
||||
p.textContent = "Loading recent directories…";
|
||||
p.textContent = "Loading projects…";
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -423,7 +451,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
wtRepo.textContent = `repo: ${root} (inferred)`;
|
||||
} else {
|
||||
inferredBaseRepo = null;
|
||||
// Publish the untick too, or the next render seeds the box checked with
|
||||
// no inferred repo and submitting fails.
|
||||
wtEnabled.checked = false;
|
||||
emit();
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
|
||||
@@ -440,22 +471,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
});
|
||||
cleanupInput.addEventListener("change", emit);
|
||||
|
||||
// A seeded worktree restores its checkbox/body up front; its gitroot probe
|
||||
// waits for loadRecents to apply the seeded cwd.
|
||||
if (prefill.worktreeEnabled || prefill.worktreeBranch || prefill.worktreeCleanup) {
|
||||
if (prefill.worktreeEnabled || prefill.worktreeBranch) {
|
||||
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
|
||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||
cleanupInput.checked = Boolean(prefill.worktreeCleanup);
|
||||
wtBody.hidden = !wtEnabled.checked;
|
||||
wtHint.hidden = true;
|
||||
emit();
|
||||
}
|
||||
|
||||
// Seed ordering: the raw-cwd application races the async info load (which
|
||||
// provides homeDir for the recents' abbreviations), so recents wait for it.
|
||||
void infoLoad.then(() => loadRecents());
|
||||
// provides homeDir for the projects' abbreviations), so projects wait for it.
|
||||
void infoLoad.then(() => loadProjects());
|
||||
|
||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
||||
@@ -471,8 +500,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchInput.value,
|
||||
worktreeCleanup: cleanupInput.checked,
|
||||
},
|
||||
},
|
||||
inferredBaseRepo,
|
||||
);
|
||||
if ("error" in built) {
|
||||
@@ -494,6 +522,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return {
|
||||
el: form,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
autocomplete.destroy();
|
||||
form.remove();
|
||||
},
|
||||
|
||||
+40
-7
@@ -1448,7 +1448,8 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Recent-dirs picker rows. */
|
||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1459,7 +1460,11 @@ textarea {
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.recent-row {
|
||||
.project-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
@@ -1477,18 +1482,40 @@ textarea {
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.recent-row:hover {
|
||||
.project-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.recent-row.is-active {
|
||||
.project-row.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-row.is-worktree {
|
||||
margin-left: var(--sp-4);
|
||||
}
|
||||
|
||||
.project-row.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-dir {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-14);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -1501,12 +1528,18 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.recent-other .recent-dir {
|
||||
color: var(--muted);
|
||||
.project-other .recent-dir {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
margin: 0;
|
||||
padding: 6px var(--sp-3);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.picker-loading {
|
||||
margin: 0;
|
||||
padding: 6px var(--sp-3);
|
||||
|
||||
@@ -83,6 +83,27 @@ export interface GitRoot {
|
||||
root?: string;
|
||||
}
|
||||
|
||||
/** One discovered git repository (or linked worktree) for `GET /api/projects`
|
||||
* (#54). Mirrors `maestro_protocol::Project`. */
|
||||
export interface Project {
|
||||
/** Absolute path of the repository/worktree toplevel. */
|
||||
path: string;
|
||||
/** Basename of `path`. */
|
||||
name: string;
|
||||
is_worktree?: boolean;
|
||||
/** The main checkout this worktree belongs to; absent on plain repos. */
|
||||
base_repo?: string;
|
||||
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||
* any directory inside it; absent when neither is known. */
|
||||
last_activity?: IsoTimestamp;
|
||||
/** Command last spawned in this project, from spawn history. */
|
||||
last_command?: string;
|
||||
}
|
||||
|
||||
/** `GET /api/projects` — every discovered project, sorted by the daemon
|
||||
* newest-activity-first (#54). */
|
||||
export type Projects = Project[];
|
||||
|
||||
export interface WorktreeSpec {
|
||||
base_repo: string;
|
||||
branch: string;
|
||||
|
||||
Reference in New Issue
Block a user