Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
525dc9a04a | ||
|
|
37338def71 | ||
|
|
51f91b3f5d | ||
|
|
5c3f9cab9f | ||
|
|
117f371c41 | ||
|
|
ad690ab2fa | ||
|
|
7dcb87cd6e | ||
|
|
26b0fa5e8a | ||
|
|
8b3c5d73b6 |
Generated
+1
@@ -1193,6 +1193,7 @@ dependencies = [
|
||||
"maestro-protocol",
|
||||
"maestro-testing",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
|
||||
@@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
|
||||
`connecting` state pill pulses.
|
||||
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
||||
rotation, theme-toggle sun/moon swap.
|
||||
- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px.
|
||||
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
||||
transitions near-instant).
|
||||
|
||||
## Layout & structure
|
||||
|
||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
|
||||
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
||||
terminal atmosphere (very low contrast, not decoration).
|
||||
breakpoint stacks the header (full-width search, wrapped actions). A faint
|
||||
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
|
||||
not decoration).
|
||||
|
||||
## Components
|
||||
|
||||
@@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class:
|
||||
no-sessions, no-search-match.
|
||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||
a framed dark xterm with a green cursor.
|
||||
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
|
||||
ring; grouped worktree fieldset.
|
||||
- **Draft pane** — the spawn form mounted inline in an empty split pane,
|
||||
focusable like any session pane; mono inputs with green focus ring; grouped
|
||||
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
|
||||
splitting), and "Spawn similar" seeds one from a session's dir + command.
|
||||
|
||||
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||
states; the accent green is used only for action, selection, focus, and live
|
||||
@@ -111,5 +112,5 @@ status — never decoration.
|
||||
## Dev review aids (DEV-only, stripped from production)
|
||||
|
||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
|
||||
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||
|
||||
@@ -29,9 +29,14 @@ pub const DEFAULT_HOME_IGNORE: &[&str] =
|
||||
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
|
||||
|
||||
/// How long a discovery snapshot is served as-is before the next request
|
||||
/// triggers a rescan. Short: a scan is a bounded filesystem walk plus one
|
||||
/// `git worktree list` per hit.
|
||||
pub const CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
/// triggers a rescan.
|
||||
///
|
||||
/// A scan walks the roots and runs one `git worktree list` per hit, holding
|
||||
/// the scanner lock throughout, and clients re-request whenever a draft pane
|
||||
/// re-mounts — which `multiview.render()` does on unrelated events. Keep this
|
||||
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
|
||||
/// what refreshes, and a request only rescans when that timer has failed to.
|
||||
pub const CACHE_TTL: Duration = Duration::from_secs(180);
|
||||
/// Background refresh cadence. Repositories appear rarely; this keeps the
|
||||
/// served list eventually fresh without anyone asking.
|
||||
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
||||
@@ -154,9 +159,15 @@ fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
|
||||
(!paths.is_empty()).then_some(paths)
|
||||
}
|
||||
|
||||
/// Mtime of the checkout's HEAD file. For a linked worktree `.git` is a file
|
||||
/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD
|
||||
/// rather than nothing.
|
||||
/// Mtime of the checkout's reflog, falling back to `HEAD` itself.
|
||||
///
|
||||
/// `HEAD` is only rewritten on checkout — committing updates
|
||||
/// `refs/heads/<branch>` and appends to `logs/HEAD`, leaving `HEAD` alone. A
|
||||
/// repo worked on daily from one long-lived branch would otherwise report its
|
||||
/// last *branch switch* as its activity and get dimmed as stale (#54 review).
|
||||
///
|
||||
/// For a linked worktree `.git` is a file holding a `gitdir:` pointer;
|
||||
/// resolve it so worktrees report their own reflog rather than nothing.
|
||||
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
||||
let dot_git = repo.join(".git");
|
||||
let head = if dot_git.is_dir() {
|
||||
@@ -173,6 +184,9 @@ fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
||||
};
|
||||
p.join("HEAD")
|
||||
};
|
||||
// Prefer the reflog next to whichever HEAD we resolved.
|
||||
let reflog = head.with_file_name("logs").join("HEAD");
|
||||
let head = if reflog.exists() { reflog } else { head };
|
||||
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
|
||||
Some(DateTime::<Utc>::from(mtime))
|
||||
}
|
||||
|
||||
@@ -39,14 +39,32 @@ impl WorktreeManager {
|
||||
}
|
||||
|
||||
/// Where a worktree for `branch` will be created: the override root, or
|
||||
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
|
||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. The path is
|
||||
/// predictable (no UUID suffix), so an existing directory at the target
|
||||
/// is an error rather than a silently different location.
|
||||
///
|
||||
/// `base_repo` is whatever the client picked, which the project list makes
|
||||
/// easy to point at a *linked worktree* — placing the new tree inside it
|
||||
/// would nest one worktree in another, and git hides a registered
|
||||
/// worktree's contents from the outer `git status`, so the outer tree
|
||||
/// would later look clean and its removal would delete the inner one's
|
||||
/// uncommitted work (#54 review). Always place against the main checkout.
|
||||
///
|
||||
/// The override root gets the repo's basename too: without it two repos
|
||||
/// spawning the same branch name target one directory and the second
|
||||
/// fails with `PathExists`.
|
||||
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||
let slug = sanitize_branch_for_path(branch);
|
||||
let main = main_checkout(base_repo);
|
||||
match &self.root_override {
|
||||
Some(root) => root.join(slug),
|
||||
None => base_repo.join("worktrees").join(slug),
|
||||
Some(root) => {
|
||||
let repo = main
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "repo".into());
|
||||
root.join(repo).join(slug)
|
||||
}
|
||||
None => main.join("worktrees").join(slug),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +126,12 @@ impl WorktreeManager {
|
||||
if !path.exists() {
|
||||
return prune(base_repo);
|
||||
}
|
||||
if let Some(inner) = nested_worktree(path) {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
reason: format!("holds a nested worktree at {}", inner.display()),
|
||||
});
|
||||
}
|
||||
if let Some(reason) = unsafe_to_remove(path)? {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
@@ -124,6 +148,45 @@ impl WorktreeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||
/// back to the input when git can't answer.
|
||||
fn main_checkout(path: &Path) -> PathBuf {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||
.output();
|
||||
let Ok(out) = out else {
|
||||
return path.to_path_buf();
|
||||
};
|
||||
if !out.status.success() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
|
||||
common
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
|
||||
/// those directories from `git status`, so the dirty-work guard cannot see
|
||||
/// them and removal would delete them (#54 review).
|
||||
fn nested_worktree(path: &Path) -> Option<PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.filter_map(|l| l.strip_prefix("worktree "))
|
||||
.map(PathBuf::from)
|
||||
.find(|p| p != path && p.starts_with(path))
|
||||
}
|
||||
|
||||
fn sanitize_branch_for_path(branch: &str) -> String {
|
||||
let mut out = String::with_capacity(branch.len());
|
||||
for c in branch.chars() {
|
||||
|
||||
@@ -70,8 +70,11 @@ fn worktree_root_override_redirects_placement() {
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
// The repo's basename sits between the root and the branch slug, so two
|
||||
// repos spawning the same branch name don't target one directory.
|
||||
let repo_name = repo.file_name().unwrap();
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert_eq!(path, wt_root.path().join("feat-example"));
|
||||
assert_eq!(path, wt_root.path().join(repo_name).join("feat-example"));
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
@@ -288,3 +291,75 @@ fn add_fails_when_branch_already_exists() {
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("git exited"), "{msg}");
|
||||
}
|
||||
|
||||
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
|
||||
/// is easily a worktree rather than the main checkout. Placing inside it would
|
||||
/// nest one worktree in another — and git hides a registered worktree's
|
||||
/// contents from the outer `git status`, so the outer tree reads clean and
|
||||
/// removing it deletes the inner one's uncommitted work (#54 review).
|
||||
#[test]
|
||||
fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
|
||||
|
||||
// Spawn again with the *worktree* as the base, the way the picker would.
|
||||
let inner = mgr.add_new(&outer, "feat/inner").expect("add inner");
|
||||
assert!(
|
||||
!inner.starts_with(&outer),
|
||||
"must not nest: {inner:?} inside {outer:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
inner.parent().and_then(|p| p.parent()),
|
||||
Some(repo.canonicalize().unwrap().as_path()),
|
||||
"placed against the main checkout"
|
||||
);
|
||||
}
|
||||
|
||||
/// Belt and braces for a nesting that predates this daemon: refuse to remove
|
||||
/// a worktree that has another registered underneath it.
|
||||
#[test]
|
||||
fn remove_refuses_a_worktree_holding_a_nested_one() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
|
||||
// Register one by hand inside `outer`, bypassing our placement.
|
||||
let nested = outer.join("worktrees").join("hand-rolled");
|
||||
run_git(
|
||||
&repo,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
"feat/hand",
|
||||
nested.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap();
|
||||
|
||||
mgr.remove(&repo, &outer)
|
||||
.expect_err("a worktree holding a nested one must be refused");
|
||||
assert!(nested.join("notes.txt").exists(), "inner work survives");
|
||||
}
|
||||
|
||||
/// Two repos spawning the same branch name under one override root must not
|
||||
/// collide (#54 review).
|
||||
#[test]
|
||||
fn override_root_keeps_repos_apart() {
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
let a_dir = TempDir::new().unwrap();
|
||||
let b_dir = TempDir::new().unwrap();
|
||||
init_repo(a_dir.path());
|
||||
init_repo(b_dir.path());
|
||||
|
||||
let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf()));
|
||||
let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a");
|
||||
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
|
||||
assert_ne!(a, b, "same branch name in two repos must not collide");
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ publish.workspace = true
|
||||
description = "Terminal UI client over the local maestro daemon's `/api/*` surface."
|
||||
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
crossterm.workspace = true
|
||||
futures-util.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-protocol = { workspace = true, features = ["client"] }
|
||||
ratatui.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||
|
||||
+296
-36
@@ -14,9 +14,10 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::io::Stdout;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
use futures_util::StreamExt;
|
||||
use maestro_protocol::{CreateSessionRequest, DaemonClient};
|
||||
use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use uuid::Uuid;
|
||||
@@ -53,6 +54,9 @@ pub struct App {
|
||||
help: HelpOverlay,
|
||||
chord: ChordState,
|
||||
daemon: DaemonClient,
|
||||
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
||||
/// `GET /api/names` (#64).
|
||||
http: reqwest::Client,
|
||||
region: RegionMap,
|
||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||
clicks: ClickTracker,
|
||||
@@ -76,7 +80,14 @@ pub enum Effect {
|
||||
Attach(Uuid),
|
||||
Spawn(CreateSessionRequest),
|
||||
Kill(Uuid),
|
||||
Forward { id: Uuid, bytes: Vec<u8> },
|
||||
Forward {
|
||||
id: Uuid,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
/// Fetch presets / projects / names for a freshly opened draft (#64).
|
||||
LoadDraft(usize),
|
||||
/// A reroll drained the draft's local name batch (#64).
|
||||
RefillNames(usize),
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -85,6 +96,11 @@ impl App {
|
||||
let help = HelpOverlay {
|
||||
lines: render_help_lines(&cfg),
|
||||
};
|
||||
let http = reqwest::Client::builder()
|
||||
.connect_timeout(maestro_protocol::client::DEFAULT_CONNECT_TIMEOUT)
|
||||
.timeout(maestro_protocol::client::DEFAULT_REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.expect("build reqwest client");
|
||||
Self {
|
||||
cfg,
|
||||
catalog,
|
||||
@@ -93,6 +109,7 @@ impl App {
|
||||
help,
|
||||
chord: ChordState::new(chord_timeout),
|
||||
daemon,
|
||||
http,
|
||||
region: RegionMap::default(),
|
||||
last_sent_size: HashMap::new(),
|
||||
clicks: ClickTracker::default(),
|
||||
@@ -153,15 +170,13 @@ impl App {
|
||||
{
|
||||
// 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(_)))
|
||||
let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
||||
{
|
||||
self.handle_draft_input(&key)
|
||||
} else {
|
||||
self.forward_to_session(&key)
|
||||
self.forward_to_session(&key).into_iter().collect()
|
||||
};
|
||||
if let Some(effect) = effect {
|
||||
self.perform(vec![effect]).await;
|
||||
}
|
||||
self.perform(effects).await;
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -176,6 +191,8 @@ impl App {
|
||||
Effect::Attach(id) => self.attach(id),
|
||||
Effect::Spawn(req) => self.submit_spawn(req).await,
|
||||
Effect::Kill(id) => self.submit_kill(id).await,
|
||||
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
||||
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
@@ -252,7 +269,10 @@ impl App {
|
||||
};
|
||||
self.state.show_overlay(OverlayKind::Help);
|
||||
}
|
||||
Action::NewDraftPane => self.state.open_draft_pane(),
|
||||
Action::NewDraftPane => {
|
||||
self.state.open_draft_pane();
|
||||
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
||||
}
|
||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
effects.push(Effect::Attach(sid));
|
||||
@@ -309,21 +329,97 @@ impl App {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// effect when the user accepted a valid form; an invalid one names the
|
||||
/// missing field inside the form (#64) instead of posting.
|
||||
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
|
||||
let tab_idx = self.state.active_tab_idx;
|
||||
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if draft.on_input(key) != DraftInputResult::Submit {
|
||||
return None;
|
||||
let mut effects = Vec::new();
|
||||
if draft.on_input(key) == DraftInputResult::Submit {
|
||||
match draft.build_request() {
|
||||
Ok(req) => {
|
||||
draft.error = None;
|
||||
effects.push(Effect::Spawn(req));
|
||||
}
|
||||
Err(e) => draft.error = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
if draft.take_name_refill() {
|
||||
effects.push(Effect::RefillNames(tab_idx));
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
/// One `/api/names` batch. [`DaemonClient`] has no typed call for this
|
||||
/// route yet, so it goes through plain reqwest against the same base URL
|
||||
/// and query contract (`routes::NAMES` + `routes::COUNT_QUERY`).
|
||||
async fn fetch_names(&self, count: usize) -> Vec<String> {
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
maestro_protocol::routes::normalize_base(self.daemon.base()),
|
||||
routes::NAMES
|
||||
);
|
||||
match self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[(routes::COUNT_QUERY, count)])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp
|
||||
.json::<NameBatch>()
|
||||
.await
|
||||
.map(|batch| batch.names)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
||||
/// a first name batch. Applies only if that tab still shows the draft —
|
||||
/// a slow response must never clobber what replaced it.
|
||||
async fn load_draft_data(&mut self, tab_idx: usize) {
|
||||
let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects());
|
||||
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Ok(info) = info {
|
||||
draft.home_dir.clone_from(&info.home_dir);
|
||||
if !info.presets.is_empty() {
|
||||
draft.set_presets(info.presets);
|
||||
}
|
||||
}
|
||||
if let Ok(projects) = projects {
|
||||
draft.set_projects(projects, Utc::now());
|
||||
}
|
||||
if !names.is_empty() {
|
||||
draft.seed_names(names);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a fresh name batch after a reroll drained the local queue.
|
||||
async fn refill_names(&mut self, tab_idx: usize) {
|
||||
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
|
||||
let Some(PaneRef::Draft(draft)) = self
|
||||
.state
|
||||
.tabs
|
||||
.get_mut(tab_idx)
|
||||
.and_then(|t| t.pane.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !names.is_empty() {
|
||||
draft.extend_names(names);
|
||||
}
|
||||
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) {
|
||||
@@ -335,7 +431,19 @@ impl App {
|
||||
self.state.complete_spawn(sid);
|
||||
self.attach(sid);
|
||||
}
|
||||
Err(e) => self.state.toast(format!("spawn failed: {e}")),
|
||||
Err(e) => {
|
||||
// A failed POST belongs in the form it came from (#64), not a
|
||||
// toast that dismisses on the next keystroke.
|
||||
let message = e
|
||||
.daemon_message()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| e.to_string());
|
||||
if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() {
|
||||
draft.error = Some(format!("spawn failed: {message}"));
|
||||
} else {
|
||||
self.state.toast(format!("spawn failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,12 +617,12 @@ mod tests {
|
||||
use maestro_config::TuiSection;
|
||||
|
||||
fn make_app() -> App {
|
||||
make_app_at("http://127.0.0.1:7070")
|
||||
}
|
||||
|
||||
fn make_app_at(base: &str) -> App {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
App::new(
|
||||
cfg,
|
||||
CatalogStore::default(),
|
||||
DaemonClient::new("http://127.0.0.1:7070"),
|
||||
)
|
||||
App::new(cfg, CatalogStore::default(), DaemonClient::new(base))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -638,34 +746,186 @@ mod tests {
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_draft_pane_schedules_a_data_load() {
|
||||
let mut app = make_app();
|
||||
assert_eq!(
|
||||
app.dispatch(Action::NewDraftPane),
|
||||
vec![Effect::LoadDraft(0)]
|
||||
);
|
||||
}
|
||||
|
||||
/// A valid form flows through: pick a project (set directly — the picker
|
||||
/// itself is covered in `draft.rs`), select the custom tile, type a
|
||||
/// command, then submit from the Spawn row.
|
||||
#[test]
|
||||
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
|
||||
for c in "/srv/work".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // cwd → command
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||
for c in "claude".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // command → submit
|
||||
app.handle_draft_input(&InputKey::Tab); // → dir picker
|
||||
app.handle_draft_input(&InputKey::Tab); // → name
|
||||
for c in "crimson-otter".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||
|
||||
match app.handle_draft_input(&InputKey::Enter) {
|
||||
Some(Effect::Spawn(req)) => {
|
||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||
[Effect::Spawn(req)] => {
|
||||
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
||||
assert_eq!(req.command, "claude");
|
||||
assert_eq!(
|
||||
req.metadata.get("name").and_then(|v| v.as_str()),
|
||||
Some("crimson-otter"),
|
||||
"the generated name rides along as metadata.name"
|
||||
);
|
||||
}
|
||||
other => panic!("expected a Spawn effect, got {other:?}"),
|
||||
other => panic!("expected a single Spawn effect, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submitting with no directory names the missing field inside the form
|
||||
/// and posts nothing (#64).
|
||||
#[test]
|
||||
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
// Walk to Submit without picking anything.
|
||||
for _ in 0..3 {
|
||||
app.handle_draft_input(&InputKey::Tab);
|
||||
}
|
||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
assert_eq!(
|
||||
draft.error.as_deref(),
|
||||
Some("Pick a working directory."),
|
||||
"the refusal shows in the form"
|
||||
);
|
||||
|
||||
// With a directory but an empty custom command, it names the command.
|
||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
|
||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(
|
||||
draft.error.as_deref(),
|
||||
Some("Pick a command (or fill the custom command).")
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed POST lands back in the form as an error line (#64), not a
|
||||
/// toast that the next keystroke dismisses.
|
||||
#[tokio::test]
|
||||
async fn a_failed_spawn_shows_the_daemons_error_in_the_form() {
|
||||
use std::time::Duration;
|
||||
let mut app = App::new(
|
||||
TuiConfig::load(&TuiSection::default()).unwrap(),
|
||||
CatalogStore::default(),
|
||||
DaemonClient::with_timeouts(
|
||||
"http://127.0.0.1:9",
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(500),
|
||||
),
|
||||
);
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
let req = CreateSessionRequest {
|
||||
cwd: std::path::PathBuf::from("/srv/work"),
|
||||
command: "bash".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
};
|
||||
app.submit_spawn(req).await;
|
||||
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
let error = draft
|
||||
.error
|
||||
.as_deref()
|
||||
.expect("spawn failure must reach the form");
|
||||
assert!(error.starts_with("spawn failed"), "{error}");
|
||||
assert!(
|
||||
matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))),
|
||||
"no kill-confirm style overlay took over"
|
||||
);
|
||||
}
|
||||
|
||||
/// The full data load against a live daemon stub: presets replace the
|
||||
/// built-ins, projects land sorted, and the first generated name seeds
|
||||
/// the field.
|
||||
#[tokio::test]
|
||||
async fn load_draft_data_fills_presets_projects_and_names() {
|
||||
use axum::routing::get;
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
async fn info() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"hostname": "box",
|
||||
"version": "0.1.0",
|
||||
"home_dir": "/home/u",
|
||||
"presets": [
|
||||
{"id": "aider", "label": "Aider", "command": "aider"},
|
||||
{"id": "custom", "label": "Custom…", "command": ""}
|
||||
]
|
||||
}))
|
||||
}
|
||||
async fn projects() -> Json<serde_json::Value> {
|
||||
Json(json!([
|
||||
{"path": "/repos/maestro", "name": "maestro",
|
||||
"last_activity": chrono::Utc::now().to_rfc3339(),
|
||||
"last_command": "claude"}
|
||||
]))
|
||||
}
|
||||
async fn names() -> Json<serde_json::Value> {
|
||||
Json(json!({"names": ["crimson-otter", "amber-falcon"]}))
|
||||
}
|
||||
|
||||
let app_router = axum::Router::new()
|
||||
.route(maestro_protocol::routes::INFO, get(info))
|
||||
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
||||
.route(maestro_protocol::routes::NAMES, get(names));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
||||
|
||||
let mut app = make_app_at(&base);
|
||||
let effects = app.dispatch(Action::NewDraftPane);
|
||||
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
|
||||
app.perform(effects).await;
|
||||
|
||||
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
||||
panic!("expected a draft pane");
|
||||
};
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert_eq!(draft.rows.len(), 1);
|
||||
assert_eq!(draft.rows[0].project.name, "maestro");
|
||||
assert_eq!(draft.home_dir, "/home/u");
|
||||
assert_eq!(draft.name.value(), "crimson-otter");
|
||||
}
|
||||
|
||||
/// 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());
|
||||
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
|
||||
}
|
||||
|
||||
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
||||
|
||||
+1021
-143
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod overlays;
|
||||
pub mod pane;
|
||||
pub mod projects;
|
||||
pub mod render;
|
||||
pub mod session_task;
|
||||
pub mod sidebar;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Pure helpers behind the draft pane's project picker (#64): staleness,
|
||||
//! recency ordering, worktree grouping, and free-text filtering. Mirrors
|
||||
//! `web/src/projectList.ts` so both clients order and dim identically — the
|
||||
//! daemon reports the timestamps; what "stale" means is a client decision
|
||||
//! (#54).
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use maestro_protocol::Project;
|
||||
|
||||
/// Projects whose last activity is older than this are dimmed and sort after
|
||||
/// fresh ones — still reachable, visibly stale.
|
||||
pub const STALE_AFTER_DAYS: i64 = 7;
|
||||
|
||||
/// True when a project has no usable activity timestamp, one in the future,
|
||||
/// or its last activity is older than [`STALE_AFTER_DAYS`] days.
|
||||
pub fn is_stale(project: &Project, now: DateTime<Utc>) -> bool {
|
||||
match project.last_activity {
|
||||
None => true,
|
||||
Some(t) => {
|
||||
let age = now.signed_duration_since(t);
|
||||
age < TimeDelta::zero() || age > TimeDelta::days(STALE_AFTER_DAYS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn time_of(project: &Project) -> DateTime<Utc> {
|
||||
project.last_activity.unwrap_or(DateTime::UNIX_EPOCH)
|
||||
}
|
||||
|
||||
/// Fresh projects first, each block newest-activity-first; unknown or future
|
||||
/// timestamps sort as stalest. Ties fall back to name, then path, so the
|
||||
/// order is stable.
|
||||
pub fn sort_projects(mut projects: Vec<Project>, now: DateTime<Utc>) -> Vec<Project> {
|
||||
projects.sort_by(|a, b| {
|
||||
let stale = is_stale(a, now).cmp(&is_stale(b, now));
|
||||
stale
|
||||
.then_with(|| time_of(b).cmp(&time_of(a)))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
projects
|
||||
}
|
||||
|
||||
/// Substring filter over project name and full path, case-insensitive.
|
||||
pub fn matches_filter(project: &Project, query: &str) -> bool {
|
||||
let q = query.trim().to_lowercase();
|
||||
if q.is_empty() {
|
||||
return true;
|
||||
}
|
||||
project.name.to_lowercase().contains(&q)
|
||||
|| project.path.to_string_lossy().to_lowercase().contains(&q)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectGroup<'a> {
|
||||
pub main: &'a Project,
|
||||
pub worktrees: Vec<&'a 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.
|
||||
pub fn group_projects(sorted: &[Project]) -> Vec<ProjectGroup<'_>> {
|
||||
let mut members: Vec<(String, Vec<&Project>)> = Vec::new();
|
||||
for p in sorted {
|
||||
let key = p
|
||||
.base_repo
|
||||
.as_ref()
|
||||
.unwrap_or(&p.path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
match members.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some((_, list)) => list.push(p),
|
||||
None => members.push((key, vec![p])),
|
||||
}
|
||||
}
|
||||
members
|
||||
.into_iter()
|
||||
.map(|(key, mut list)| {
|
||||
let main_pos = list
|
||||
.iter()
|
||||
.position(|p| p.path.to_string_lossy() == key && !p.is_worktree)
|
||||
.unwrap_or(0);
|
||||
let main = list.remove(main_pos);
|
||||
// Keep the daemon's newest-first order within the group.
|
||||
list.sort_by_key(|p| std::cmp::Reverse(time_of(p)));
|
||||
ProjectGroup {
|
||||
main,
|
||||
worktrees: list,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn days_ago(days: i64) -> DateTime<Utc> {
|
||||
Utc::now() - TimeDelta::days(days)
|
||||
}
|
||||
|
||||
fn project(name: &str, path: &str, last_activity: Option<DateTime<Utc>>) -> Project {
|
||||
Project {
|
||||
path: PathBuf::from(path),
|
||||
name: name.into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
last_activity,
|
||||
last_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn worktree(
|
||||
name: &str,
|
||||
path: &str,
|
||||
base: &str,
|
||||
last_activity: Option<DateTime<Utc>>,
|
||||
) -> Project {
|
||||
Project {
|
||||
is_worktree: true,
|
||||
base_repo: Some(PathBuf::from(base)),
|
||||
..project(name, path, last_activity)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_future_activity_are_stale() {
|
||||
let now = Utc::now();
|
||||
assert!(is_stale(&project("x", "/x", None), now));
|
||||
assert!(is_stale(
|
||||
&project("x", "/x", Some(now + TimeDelta::hours(1))),
|
||||
now
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_within_a_week_is_fresh_beyond_is_stale() {
|
||||
let now = Utc::now();
|
||||
assert!(!is_stale(&project("x", "/x", Some(days_ago(6))), now));
|
||||
assert!(is_stale(&project("x", "/x", Some(days_ago(8))), now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_sorts_before_stale_then_newest_first() {
|
||||
let now = Utc::now();
|
||||
let sorted = sort_projects(
|
||||
vec![
|
||||
project("old-fresh", "/a", Some(days_ago(5))),
|
||||
project("stale", "/b", Some(days_ago(30))),
|
||||
project("newest", "/c", Some(days_ago(1))),
|
||||
project("unknown", "/d", None),
|
||||
],
|
||||
now,
|
||||
);
|
||||
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["newest", "old-fresh", "stale", "unknown"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_break_by_name_then_path() {
|
||||
let now = Utc::now();
|
||||
let t = Some(days_ago(2));
|
||||
let sorted = sort_projects(vec![project("b", "/b", t), project("a", "/a", t)], now);
|
||||
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_matches_name_or_path_case_insensitively() {
|
||||
let p = project("Maestro", "/home/u/tea/Maestro", None);
|
||||
assert!(matches_filter(&p, "maes"));
|
||||
assert!(matches_filter(&p, " tea "));
|
||||
assert!(!matches_filter(&p, "zzz"));
|
||||
assert!(matches_filter(&p, ""), "empty query matches everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_nest_under_their_main_checkout() {
|
||||
let now = Utc::now();
|
||||
let main = project("maestro", "/repos/maestro", Some(days_ago(10)));
|
||||
let wt = worktree("64-tui", "/repos/wt64", "/repos/maestro", Some(days_ago(1)));
|
||||
let other = project("other", "/repos/other", Some(days_ago(2)));
|
||||
let sorted = sort_projects(vec![main, wt, other], now);
|
||||
let groups = group_projects(&sorted);
|
||||
assert_eq!(groups.len(), 2);
|
||||
assert_eq!(
|
||||
groups[0].main.name, "maestro",
|
||||
"fresh worktree pulls its repo up"
|
||||
);
|
||||
assert_eq!(groups[0].worktrees.len(), 1);
|
||||
assert_eq!(groups[0].worktrees[0].name, "64-tui");
|
||||
assert_eq!(groups[1].main.name, "other");
|
||||
}
|
||||
|
||||
/// A main checkout absent from the scan must not drop its worktrees.
|
||||
#[test]
|
||||
fn worktrees_survive_a_missing_main_checkout() {
|
||||
let now = Utc::now();
|
||||
let wt = worktree(
|
||||
"solo-wt",
|
||||
"/repos/solo-wt",
|
||||
"/repos/solo",
|
||||
Some(days_ago(1)),
|
||||
);
|
||||
let sorted = sort_projects(vec![wt], now);
|
||||
let groups = group_projects(&sorted);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].main.name, "solo-wt");
|
||||
assert!(groups[0].worktrees.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,14 @@ mod tests {
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
|
||||
for s in [
|
||||
"draft",
|
||||
"cmd:",
|
||||
"dir:",
|
||||
"name:",
|
||||
"[ Spawn ]",
|
||||
"Other directory…",
|
||||
] {
|
||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ pub enum Focus {
|
||||
}
|
||||
|
||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
||||
// that boxing every access costs more than the copy does.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PaneRef {
|
||||
Session(Uuid),
|
||||
|
||||
+5
-4
@@ -20,9 +20,10 @@ src/
|
||||
main.ts # entry point, hash-route dispatch
|
||||
api.ts # daemon HTTP API client
|
||||
events.ts # `/api/events` WebSocket subscriber
|
||||
catalog.ts # session tree view (groups by working directory)
|
||||
attach.ts # xterm.js + attach WS view
|
||||
spawn.ts # spawn-session modal
|
||||
catalog.ts # session tree view (groups by working directory)
|
||||
attach.ts # xterm.js + attach WS view
|
||||
multiview.ts # tabs + split panes; draft panes host the spawn form
|
||||
spawnForm.ts # shared spawn form (preset tiles, project picker, worktree)
|
||||
theme.ts # dark/light theme toggle + persistence
|
||||
types.ts # mirror of `maestro-protocol` JSON shapes
|
||||
styles.css # design-system tokens + component styles
|
||||
@@ -40,7 +41,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in
|
||||
|
||||
`mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev
|
||||
server with `?mock` to seed a representative catalog without a running daemon,
|
||||
`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme.
|
||||
`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme.
|
||||
|
||||
Routing is hash-based:
|
||||
- `#/` → catalog
|
||||
|
||||
+11
-12
@@ -6,9 +6,15 @@ import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { openSpawnModal } from "./spawn.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
|
||||
/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the
|
||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
||||
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
||||
export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
@@ -190,7 +196,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
spawnBtn.addEventListener("click", () => openSpawnModal());
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
actions.appendChild(spawnBtn);
|
||||
return header;
|
||||
}
|
||||
@@ -342,7 +348,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
|
||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -364,14 +370,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const rerenderHandler = () => refresh();
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
|
||||
// command (matched back to a preset by the form) — but with a freshly
|
||||
// generated name rather than the original's, since it's a new session.
|
||||
const spawnSimilarHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
openSpawnModal({ cwd: s.cwd, command: s.command });
|
||||
};
|
||||
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed a draft pane with the session's dir/command.
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
@@ -391,7 +391,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
dispose() {
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
|
||||
// types.ts and attach.ts.
|
||||
// One HTML escaper. Previously written four times, in catalog.ts, types.ts
|
||||
// and attach.ts.
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@
|
||||
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
|
||||
}
|
||||
|
||||
.kit-slot .modal-body {
|
||||
.kit-slot .spawn-form {
|
||||
padding: 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
+3
-3
@@ -135,7 +135,7 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Buttons — current",
|
||||
"The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.",
|
||||
"The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.",
|
||||
);
|
||||
body.append(
|
||||
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
|
||||
@@ -170,12 +170,12 @@ export function renderKit(): HTMLElement {
|
||||
search.type = "search";
|
||||
search.className = "search";
|
||||
search.placeholder = "Search sessions…";
|
||||
const text = h("div", "modal-body");
|
||||
const text = h("div", "spawn-form");
|
||||
const ti = document.createElement("input");
|
||||
ti.type = "text";
|
||||
ti.placeholder = "branch-name";
|
||||
text.appendChild(ti);
|
||||
const sel = h("div", "modal-body");
|
||||
const sel = h("div", "spawn-form");
|
||||
const s = document.createElement("select");
|
||||
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
||||
sel.appendChild(s);
|
||||
|
||||
+17
-3
@@ -7,9 +7,9 @@ import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { openSpawnModal } from "./spawn.js";
|
||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||
import { initTheme } from "./theme.js";
|
||||
import { sessionName } from "./types.js";
|
||||
import { sessionName, type Session } from "./types.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -212,7 +212,7 @@ function renderWelcome(): HTMLElement {
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.addEventListener("click", () => openSpawnModal());
|
||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
@@ -251,6 +251,20 @@ function showMultiView(): void {
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
// Spawn affordances (#62): the header button, the sidebar's empty state, and
|
||||
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
|
||||
// cloned session's dir + command.
|
||||
function requestOpenSpawn(): void {
|
||||
showMultiView();
|
||||
mv.openDraft();
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
showMultiView();
|
||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
||||
});
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
|
||||
+2
-2
@@ -135,10 +135,10 @@ export function seedMock(store: CatalogStore): void {
|
||||
sessions,
|
||||
});
|
||||
|
||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
||||
// `?mock&spawn` auto-opens a draft pane for visual review, with the spawn
|
||||
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||
if (location.search.includes("spawn")) {
|
||||
stubSpawnApi();
|
||||
void import("./spawn.js").then((m) => m.openSpawnModal());
|
||||
document.dispatchEvent(new CustomEvent("catalog-open-spawn"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { emptyLayout, leaves, type Layout } from "./layout.js";
|
||||
import { splitLeaf } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import type { ConnectionPool } from "./connections.js";
|
||||
import type { SidebarHandle } from "./catalog.js";
|
||||
|
||||
function stubFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function fakePool(): ConnectionPool {
|
||||
return {
|
||||
setVisible() {},
|
||||
setFocus() {},
|
||||
} as unknown as ConnectionPool;
|
||||
}
|
||||
|
||||
function fakeSidebar(): SidebarHandle {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMv(layout: Layout): MultiView {
|
||||
return createMultiView({
|
||||
pool: fakePool(),
|
||||
layout,
|
||||
sidebar: fakeSidebar(),
|
||||
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Drain the microtask queue so the draft form's async seed application lands. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("multiview openDraft (#62)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("turns an empty tab into a draft pane", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(layout.tabs[0].root);
|
||||
expect(ls).toHaveLength(1);
|
||||
expect(ls[0].sessionId).toBeNull();
|
||||
expect(ls[0].draft).not.toBeNull();
|
||||
expect(mv.el.querySelector(".mv-draft")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("splits the focused session pane into a fresh draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
tab.focusedLeafId = first;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
expect(ls[0].sessionId).toBe("s1");
|
||||
const draftLeaf = ls.find((l) => l.sessionId === null)!;
|
||||
expect(draftLeaf.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(draftLeaf.id);
|
||||
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reuses the focused pane when it is already empty", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(2);
|
||||
const target = ls.find((l) => l.id === empty)!;
|
||||
expect(target.draft).not.toBeNull();
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
|
||||
tab.focusedLeafId = draftLeaf;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
|
||||
nameInput.value = "wip";
|
||||
nameInput.dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
|
||||
|
||||
const formBefore = mv.el.querySelector(".mv-draft");
|
||||
mv.closeSession("s1");
|
||||
await flush();
|
||||
|
||||
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
|
||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||
});
|
||||
|
||||
it("seeds a clone's cwd and command into the draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
|
||||
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||
await flush();
|
||||
|
||||
const leaf = leaves(layout.tabs[0].root)[0];
|
||||
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
});
|
||||
});
|
||||
+83
-28
@@ -13,6 +13,7 @@ import {
|
||||
closeTab,
|
||||
findLeaf,
|
||||
type Dir,
|
||||
type Draft,
|
||||
type Layout,
|
||||
leaves,
|
||||
locateSession,
|
||||
@@ -52,6 +53,10 @@ export interface MultiView {
|
||||
closeSession: (sessionId: string) => void;
|
||||
/** Every session currently shown in some pane, across all tabs. */
|
||||
openSessions: () => Set<string>;
|
||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||
* ("Spawn similar" clones a session's cwd + command). */
|
||||
openDraft: (seed?: Partial<Draft>) => void;
|
||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||
* drag shows drop zones to place it. */
|
||||
@@ -65,9 +70,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||
const persist = () => opts.onLayoutChange?.();
|
||||
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
|
||||
// render tears down the previous generation before rebuilding the canvas.
|
||||
const draftForms = new Set<SpawnFormHandle>();
|
||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||
// that survive the render are reused in place (#68): rebuilding one mid-edit
|
||||
// would drop focus, caret and selection, and re-fetch info/projects/names.
|
||||
const draftForms = new Map<string, SpawnFormHandle>();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "mv";
|
||||
@@ -145,29 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
||||
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||
// submitting fills this leaf with the new session in place.
|
||||
const form = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there.
|
||||
const t = activeTab(layout);
|
||||
const attached = leaves(t.root).includes(leaf);
|
||||
if (attached) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
t.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.add(form);
|
||||
host.appendChild(form.el);
|
||||
const form = draftForms.get(leaf.id);
|
||||
if (form) {
|
||||
host.appendChild(form.el);
|
||||
} else {
|
||||
const created = createSpawnForm({
|
||||
seed: leaf.draft ?? undefined,
|
||||
class: "mv-draft",
|
||||
onChange: (values) => {
|
||||
leaf.draft = values;
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
// The pane can be closed while the POST is in flight. Mounting the
|
||||
// session onto a leaf no longer in the tree would strand it in no
|
||||
// pane at all, so only claim the leaf while it is still there —
|
||||
// searching every tab, since the operator may have switched tabs
|
||||
// while the request was in flight.
|
||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||
if (host) {
|
||||
leaf.sessionId = session.id;
|
||||
leaf.draft = null;
|
||||
host.focusedLeafId = leaf.id;
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
},
|
||||
});
|
||||
draftForms.set(leaf.id, created);
|
||||
host.appendChild(created.el);
|
||||
}
|
||||
}
|
||||
|
||||
const tools = document.createElement("div");
|
||||
@@ -365,11 +378,49 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
render();
|
||||
}
|
||||
|
||||
function openDraft(seed?: Partial<Draft>): void {
|
||||
const tab = activeTab(layout);
|
||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||
findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId && !target.draft) {
|
||||
// An already-empty pane becomes the draft in place.
|
||||
target.draft = draft();
|
||||
} else {
|
||||
// Split a fresh draft beside the focused pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||
findLeaf(tab.root, tab.focusedLeafId)!.draft = draft();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- top-level render ----
|
||||
|
||||
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
|
||||
* a session). Surviving draft leaves keep their form instance (#68). */
|
||||
function pruneForms(tab: { root: Node | null }): void {
|
||||
const alive = new Set(
|
||||
tab.root
|
||||
? leaves(tab.root)
|
||||
.filter((l) => !l.sessionId)
|
||||
.map((l) => l.id)
|
||||
: [],
|
||||
);
|
||||
for (const [id, f] of draftForms) {
|
||||
if (!alive.has(id)) {
|
||||
f.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
for (const f of draftForms) f.destroy();
|
||||
draftForms.clear();
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
@@ -378,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||
|
||||
if (!tab.root) {
|
||||
pruneForms(tab);
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
@@ -393,12 +445,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
if (isMobile) {
|
||||
renderMobile(tab);
|
||||
pruneForms(tab);
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||
canvas.appendChild(buildTree(tab.root, hosts));
|
||||
pruneForms(tab);
|
||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||
syncFocus();
|
||||
persist();
|
||||
@@ -639,6 +693,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return {
|
||||
el,
|
||||
openSession,
|
||||
openDraft,
|
||||
closeSession,
|
||||
openSessions: () => openSessions(layout),
|
||||
beginSessionDrag,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// The spawn modal (issue #27): bottom-sheet/dialog chrome around the shared
|
||||
// spawn form (spawnForm.ts). The modal coexists with the inline draft panes
|
||||
// (#57) until a later issue deletes it.
|
||||
|
||||
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
|
||||
* the same cwd, command, and name as an existing session. */
|
||||
export type SpawnPrefill = SpawnFormSeed;
|
||||
|
||||
export function openSpawnModal(prefill?: SpawnPrefill): void {
|
||||
const existing = document.getElementById("modal-host");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.id = "modal-host";
|
||||
host.className = "modal-backdrop";
|
||||
host.innerHTML = `
|
||||
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="spawn-title">Spawn session</h2>
|
||||
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div class="form-slot"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(host);
|
||||
|
||||
const formHandle = createSpawnForm({
|
||||
seed: prefill,
|
||||
class: "modal-body",
|
||||
onSuccess(session) {
|
||||
close();
|
||||
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
|
||||
},
|
||||
});
|
||||
(host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el);
|
||||
|
||||
const close = () => {
|
||||
formHandle.destroy();
|
||||
host.remove();
|
||||
};
|
||||
// The modal keeps its Cancel affordance; the shared form ships only Submit.
|
||||
const actions = formHandle.el.querySelector(".form-actions") as HTMLElement;
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "btn btn-ghost cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", close);
|
||||
actions.prepend(cancel);
|
||||
|
||||
host.querySelector(".close")?.addEventListener("click", close);
|
||||
host.addEventListener("click", (e) => {
|
||||
if (e.target === host) close();
|
||||
});
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Escape") close();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// ---- clone seed ------------------------------------------------------
|
||||
if (prefill) {
|
||||
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@ import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||
* from the repository's existing branches (#61). */
|
||||
export type WorktreeBranchMode = "new" | "existing";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
||||
* cwd, command, and name as an existing session, and by draft panes to restore
|
||||
* their own half-filled state after a re-render. */
|
||||
/** Seed values for the spawn form — used by "clone" to open a draft with the
|
||||
* same cwd, command, and name as an existing session, and by draft panes to
|
||||
* restore their own half-filled state after a re-render. */
|
||||
export interface SpawnFormSeed {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
@@ -99,7 +99,7 @@ export interface SpawnFormHandle {
|
||||
|
||||
export interface SpawnFormOptions {
|
||||
seed?: SpawnFormSeed;
|
||||
/** Extra class(es) for the <form> root (e.g. `modal-body` in the modal). */
|
||||
/** Extra class(es) for the <form> root (e.g. `mv-draft` in a draft pane). */
|
||||
class?: string;
|
||||
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
||||
onChange?: (values: SpawnFormValues) => void;
|
||||
@@ -108,7 +108,7 @@ export interface SpawnFormOptions {
|
||||
}
|
||||
|
||||
/** The spawn form itself — preset tiles, project picker, name, worktree.
|
||||
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
|
||||
* Mounted inline by draft panes (issue #57). */
|
||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const prefill = opts.seed ?? {};
|
||||
|
||||
@@ -174,7 +174,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
`;
|
||||
// Field queries are scoped to this form instance — several forms can be
|
||||
// mounted at once (modal + draft panes), so ids would collide.
|
||||
// mounted at once (one per draft pane), so ids would collide.
|
||||
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
|
||||
+11
-117
@@ -1205,72 +1205,9 @@ textarea {
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
/* ---------- forms ---------- */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: oklch(0.12 0.01 256 / 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
z-index: var(--z-modal);
|
||||
animation: fade var(--dur) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.modal-backdrop {
|
||||
align-items: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border: 1px solid var(--border);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-2);
|
||||
animation: sheet-up var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.modal {
|
||||
border-radius: var(--r-3);
|
||||
animation: pop var(--dur) var(--ease-out);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-15);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.modal-header h2::before {
|
||||
content: "+";
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
.spawn-form {
|
||||
padding: var(--sp-3);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -1280,7 +1217,6 @@ textarea {
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.modal-body label,
|
||||
.spawn-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1290,7 +1226,6 @@ textarea {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.modal-body label.row-inline,
|
||||
.spawn-form label.row-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1300,10 +1235,9 @@ textarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body input[type="text"],
|
||||
.modal-body input[type="search"],
|
||||
.modal-body select,
|
||||
.spawn-form input[type="text"] {
|
||||
.spawn-form input[type="text"],
|
||||
.spawn-form input[type="search"],
|
||||
.spawn-form select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
@@ -1318,23 +1252,20 @@ textarea {
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.modal-body input[type="text"]:hover,
|
||||
.modal-body select:hover,
|
||||
.spawn-form input[type="text"]:hover {
|
||||
.spawn-form input[type="text"]:hover,
|
||||
.spawn-form select:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.modal-body input[type="text"]:focus,
|
||||
.modal-body input[type="search"]:focus,
|
||||
.modal-body select:focus,
|
||||
.spawn-form input[type="text"]:focus {
|
||||
.spawn-form input[type="text"]:focus,
|
||||
.spawn-form input[type="search"]:focus,
|
||||
.spawn-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
|
||||
.modal-body input[type="checkbox"],
|
||||
.spawn-form input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -1359,13 +1290,6 @@ textarea {
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
@@ -1381,7 +1305,6 @@ textarea {
|
||||
|
||||
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
||||
|
||||
.spawn-modal .field,
|
||||
.spawn-form .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1611,7 +1534,7 @@ textarea {
|
||||
|
||||
.dir-ac {
|
||||
position: fixed;
|
||||
z-index: var(--z-toast); /* above the modal it completes within */
|
||||
z-index: var(--z-toast); /* above the draft pane it completes within */
|
||||
margin: 0;
|
||||
padding: var(--sp-1);
|
||||
list-style: none;
|
||||
@@ -1759,26 +1682,6 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
transform: scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -2119,15 +2022,6 @@ textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
/* The modal form got its stacking and padding from `.modal-body`; in a pane
|
||||
the form is the host, so it carries them itself. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||
|
||||
Reference in New Issue
Block a user