Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74967e45b4 | ||
|
|
7a2cc30bf9 | ||
|
|
8ed44e785d | ||
|
|
63da845614 | ||
|
|
d336bd802d | ||
|
|
67d17f48d0 | ||
|
|
ec45312400 | ||
|
|
525dc9a04a | ||
|
|
264332266c | ||
|
|
37338def71 | ||
|
|
51f91b3f5d | ||
|
|
5c3f9cab9f | ||
|
|
117f371c41 |
@@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
|
|||||||
`connecting` state pill pulses.
|
`connecting` state pill pulses.
|
||||||
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
||||||
rotation, theme-toggle sun/moon swap.
|
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,
|
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
||||||
transitions near-instant).
|
transitions near-instant).
|
||||||
|
|
||||||
## Layout & structure
|
## Layout & structure
|
||||||
|
|
||||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||||
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
|
breakpoint stacks the header (full-width search, wrapped actions). A faint
|
||||||
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
|
||||||
terminal atmosphere (very low contrast, not decoration).
|
not decoration).
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class:
|
|||||||
no-sessions, no-search-match.
|
no-sessions, no-search-match.
|
||||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||||
a framed dark xterm with a green cursor.
|
a framed dark xterm with a green cursor.
|
||||||
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
|
- **Draft pane** — the spawn form mounted inline in an empty split pane,
|
||||||
ring; grouped worktree fieldset.
|
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
|
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||||
states; the accent green is used only for action, selection, focus, and live
|
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)
|
## Dev review aids (DEV-only, stripped from production)
|
||||||
|
|
||||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
`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`.
|
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
|
use maestro_protocol::{
|
||||||
|
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
|
||||||
|
};
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||||
@@ -62,6 +64,14 @@ impl TestClient {
|
|||||||
Ok(self.daemon.kill_session(id).await?)
|
Ok(self.daemon.kill_session(id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||||
|
Ok(self.daemon.list_projects().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||||
|
Ok(self.daemon.list_branches(path).await?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to the daemon's per-session WS and return the stream. The
|
/// Connect to the daemon's per-session WS and return the stream. The
|
||||||
/// caller drives the conversation.
|
/// caller drives the conversation.
|
||||||
pub async fn attach(
|
pub async fn attach(
|
||||||
|
|||||||
@@ -29,11 +29,32 @@ impl Daemon {
|
|||||||
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn with a config file pre-seeded at the daemon's expected path
|
||||||
|
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
|
||||||
|
pub async fn spawn_with_config(toml: &str) -> Self {
|
||||||
|
Self::spawn_config_at(
|
||||||
|
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
|
||||||
|
Some(toml),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn on a caller-supplied bind address.
|
/// Spawn on a caller-supplied bind address.
|
||||||
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
||||||
|
Self::spawn_config_at(bind, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn on a caller-supplied bind address, with an optional config file.
|
||||||
|
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
|
||||||
let home = TempDir::new().expect("home tmpdir");
|
let home = TempDir::new().expect("home tmpdir");
|
||||||
let tmux = TempDir::new().expect("tmux tmpdir");
|
let tmux = TempDir::new().expect("tmux tmpdir");
|
||||||
|
|
||||||
|
if let Some(toml) = config.into() {
|
||||||
|
let dir = home.path().join(".config").join("maestro");
|
||||||
|
std::fs::create_dir_all(&dir).expect("config dir");
|
||||||
|
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
|
||||||
|
}
|
||||||
|
|
||||||
let child = launch(bind, home.path(), tmux.path());
|
let child = launch(bind, home.path(), tmux.path());
|
||||||
let me = Self {
|
let me = Self {
|
||||||
bind_addr: bind,
|
bind_addr: bind,
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ pub mod daemon;
|
|||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use build::workspace_target_bin;
|
pub use build::workspace_target_bin;
|
||||||
pub use client::TestClient;
|
pub use client::{ClientError, TestClient};
|
||||||
pub use daemon::Daemon;
|
pub use daemon::Daemon;
|
||||||
|
|||||||
@@ -35,6 +35,22 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn request carrying a worktree spec against `base`.
|
||||||
|
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||||
|
CreateSessionRequest {
|
||||||
|
cwd: base.to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: base.to_path_buf(),
|
||||||
|
branch: branch.into(),
|
||||||
|
cleanup_on_kill: true,
|
||||||
|
create_branch,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn lists_spawned_sessions() {
|
async fn lists_spawned_sessions() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
@@ -204,6 +220,19 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
|
|||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"guarded worktree must be left on disk after kill"
|
"guarded worktree must be left on disk after kill"
|
||||||
);
|
);
|
||||||
|
// Left findable, not silently half-deleted: git still registers it and
|
||||||
|
// the uncommitted edit survives byte for byte (#66).
|
||||||
|
assert!(
|
||||||
|
git_worktree_list(base.path())
|
||||||
|
.iter()
|
||||||
|
.any(|w| w.contains("feature/unsaved")),
|
||||||
|
"guarded worktree must stay registered in git worktree list"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(wt_path.join("README")).unwrap(),
|
||||||
|
b"unsaved edits\n",
|
||||||
|
"uncommitted changes must survive the guarded kill"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
@@ -240,6 +269,276 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #66: a fresh branch spawns a worktree at `<repo>/worktrees/<branch>` and
|
||||||
|
/// the session actually runs inside it — `current_cwd` comes from tmux's live
|
||||||
|
/// pane state, not from the spawn request.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
let wt_path = base.path().join("worktrees").join("feature-fresh");
|
||||||
|
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/fresh", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn new-branch worktree");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
s.cwd, wt_path,
|
||||||
|
"session must report the worktree as its cwd"
|
||||||
|
);
|
||||||
|
assert!(wt_path.is_dir(), "worktree directory must exist on disk");
|
||||||
|
|
||||||
|
// The activity watcher fills `current_cwd` from tmux's pane_current_path,
|
||||||
|
// so this is the daemon confirming where the process really runs.
|
||||||
|
wait_for_async(
|
||||||
|
"session's live cwd is the worktree",
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let id = s.id;
|
||||||
|
let want = wt_path.clone();
|
||||||
|
async move {
|
||||||
|
client
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|ss| ss.iter().find(|s| s.id == id).cloned())
|
||||||
|
.map(|s| s.current_cwd == Some(want.clone()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #66: a second spawn onto a branch that already has a worktree is rejected
|
||||||
|
/// with a structured worktree error; the first worktree stays intact.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn second_spawn_on_same_branch_is_rejected() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
|
||||||
|
let first = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||||
|
.await
|
||||||
|
.expect("first spawn");
|
||||||
|
let dup = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||||
|
.await;
|
||||||
|
match dup {
|
||||||
|
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
||||||
|
assert_eq!(
|
||||||
|
info.code, "worktree_failed",
|
||||||
|
"expected worktree error: {info:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one registration, one directory, still usable.
|
||||||
|
let holders = git_worktree_list(base.path())
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.contains("feature/dup"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||||
|
assert!(
|
||||||
|
base.path().join("worktrees").join("feature-dup").is_dir(),
|
||||||
|
"original worktree must be untouched"
|
||||||
|
);
|
||||||
|
|
||||||
|
client.kill_session(first.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||||
|
/// resolves through `--git-common-dir` to the main checkout, so the new tree
|
||||||
|
/// lands at `<main>/worktrees/<branch>` instead of nesting inside another
|
||||||
|
/// worktree.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
let linked = base.path().join("linked");
|
||||||
|
run_git(
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"-q",
|
||||||
|
"-b",
|
||||||
|
"other",
|
||||||
|
&linked.to_string_lossy(),
|
||||||
|
],
|
||||||
|
base.path(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(&linked, "feature/nested", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn from linked worktree");
|
||||||
|
|
||||||
|
let want = base.path().join("worktrees").join("feature-nested");
|
||||||
|
assert_eq!(
|
||||||
|
s.cwd, want,
|
||||||
|
"worktree must be placed under the main checkout"
|
||||||
|
);
|
||||||
|
assert!(want.is_dir());
|
||||||
|
assert!(
|
||||||
|
!s.cwd.starts_with(&linked),
|
||||||
|
"worktrees must never nest inside another worktree"
|
||||||
|
);
|
||||||
|
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/projects + /api/branches (#54/#55/#66)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// #66: two repos under a configured `[spawn].project_roots` root are both
|
||||||
|
/// discovered by `GET /api/projects`; git activity gives every repo a
|
||||||
|
/// `last_activity`, and spawning inside one merges its command into the
|
||||||
|
/// project entry via spawn history.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn projects_endpoint_discovers_roots_and_merges_activity() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let alpha = make_repo(root.path(), "alpha");
|
||||||
|
make_repo(root.path(), "beta");
|
||||||
|
|
||||||
|
let daemon = Daemon::spawn_with_config(&format!(
|
||||||
|
"[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n",
|
||||||
|
root.path().display()
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = TestClient::new(&daemon.http_base());
|
||||||
|
|
||||||
|
let projects = client.list_projects().await.expect("list projects");
|
||||||
|
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
names.contains(&"alpha") && names.contains(&"beta"),
|
||||||
|
"both repos must be discovered, got: {names:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let find = |name: &str| {
|
||||||
|
projects
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name == name)
|
||||||
|
.cloned()
|
||||||
|
.expect("project present")
|
||||||
|
};
|
||||||
|
// Both have commits, so git activity alone populates last_activity…
|
||||||
|
assert!(
|
||||||
|
find("alpha").last_activity.is_some(),
|
||||||
|
"git activity must surface"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
find("beta").last_activity.is_some(),
|
||||||
|
"git activity must surface"
|
||||||
|
);
|
||||||
|
// …and neither has been spawned into yet.
|
||||||
|
assert_eq!(find("alpha").last_command, None);
|
||||||
|
assert_eq!(find("beta").last_command, None);
|
||||||
|
|
||||||
|
// Spawning inside alpha writes durable history; /api/projects folds it in.
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&create_req(&alpha, "sleep 30"))
|
||||||
|
.await
|
||||||
|
.expect("spawn in alpha");
|
||||||
|
|
||||||
|
wait_for_async(
|
||||||
|
"alpha's project entry reflects spawn history",
|
||||||
|
Duration::from_secs(5),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
async move {
|
||||||
|
client
|
||||||
|
.list_projects()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|ps| {
|
||||||
|
ps.iter()
|
||||||
|
.find(|p| p.name == "alpha")
|
||||||
|
.map(|p| p.last_command.as_deref() == Some("sleep 30"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// beta was never spawned into: no command recorded there.
|
||||||
|
let beta_after = client
|
||||||
|
.list_projects()
|
||||||
|
.await
|
||||||
|
.expect("list after spawn")
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.name == "beta")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(beta_after.last_command, None);
|
||||||
|
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #66: `GET /api/branches` lists every local branch, flags the main
|
||||||
|
/// checkout's HEAD with `is_head`, and reports which linked worktree holds an
|
||||||
|
/// occupied branch.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn branches_endpoint_lists_branches_and_flags_occupied() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
run_git(&["branch", "feature/taken"], base.path());
|
||||||
|
run_git(&["branch", "feature/free"], base.path());
|
||||||
|
let holder = base.path().join("holder-wt");
|
||||||
|
run_git(
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"-q",
|
||||||
|
&holder.to_string_lossy(),
|
||||||
|
"feature/taken",
|
||||||
|
],
|
||||||
|
base.path(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let branches = client
|
||||||
|
.list_branches(&base.path().to_string_lossy())
|
||||||
|
.await
|
||||||
|
.expect("list branches");
|
||||||
|
let by_name = |name: &str| {
|
||||||
|
branches
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.name == name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| panic!("branch {name} missing from {branches:?}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let main = by_name("main");
|
||||||
|
assert!(main.is_head, "main is the main checkout's HEAD");
|
||||||
|
assert_eq!(main.worktree, None);
|
||||||
|
|
||||||
|
let taken = by_name("feature/taken");
|
||||||
|
assert!(!taken.is_head);
|
||||||
|
assert_eq!(
|
||||||
|
taken.worktree.as_deref(),
|
||||||
|
Some(holder.canonicalize().unwrap().as_path()),
|
||||||
|
"occupied branch must report its holding worktree"
|
||||||
|
);
|
||||||
|
|
||||||
|
let free = by_name("feature/free");
|
||||||
|
assert!(!free.is_head);
|
||||||
|
assert_eq!(free.worktree, None, "unoccupied branch must be spawnable");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn daemon_restart_recovers_session() {
|
async fn daemon_restart_recovers_session() {
|
||||||
let (mut daemon, client) = bring_up().await;
|
let (mut daemon, client) = bring_up().await;
|
||||||
@@ -428,6 +727,15 @@ fn init_repo(dir: &std::path::Path) {
|
|||||||
run_git(&["commit", "-q", "-m", "initial"], dir);
|
run_git(&["commit", "-q", "-m", "initial"], dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real git repo at `<root>/<name>`, canonicalized (the scanner canonicalizes
|
||||||
|
/// discovered paths too, so comparisons must agree on the real path).
|
||||||
|
fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf {
|
||||||
|
let dir = root.join(name);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
init_repo(&dir);
|
||||||
|
dir.canonicalize().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
RecentDirs, Session,
|
Project, RecentDirs, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -143,6 +143,11 @@ impl DaemonClient {
|
|||||||
self.get(routes::PROJECTS, &[]).await
|
self.get(routes::PROJECTS, &[]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||||
|
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// -- plumbing -----------------------------------------------------------
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
async fn get<T: DeserializeOwned>(
|
async fn get<T: DeserializeOwned>(
|
||||||
|
|||||||
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Stdout;
|
use std::io::Stdout;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch};
|
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -88,6 +89,15 @@ pub enum Effect {
|
|||||||
LoadDraft(usize),
|
LoadDraft(usize),
|
||||||
/// A reroll drained the draft's local name batch (#64).
|
/// A reroll drained the draft's local name batch (#64).
|
||||||
RefillNames(usize),
|
RefillNames(usize),
|
||||||
|
/// The draft's target changed: probe `/api/gitroot` so the worktree
|
||||||
|
/// section knows its base repo (#65). `seq` guards stale answers.
|
||||||
|
ProbeGitRoot {
|
||||||
|
tab: usize,
|
||||||
|
seq: u32,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||||
|
LoadBranches(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -193,6 +203,10 @@ impl App {
|
|||||||
Effect::Kill(id) => self.submit_kill(id).await,
|
Effect::Kill(id) => self.submit_kill(id).await,
|
||||||
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
||||||
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
||||||
|
Effect::ProbeGitRoot { tab, seq, path } => {
|
||||||
|
self.probe_git_root(tab, seq, path).await
|
||||||
|
}
|
||||||
|
Effect::LoadBranches(tab) => self.load_branches(tab).await,
|
||||||
Effect::Forward { id, bytes } => {
|
Effect::Forward { id, bytes } => {
|
||||||
if let Some(handle) = self.emulators.get(id) {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
@@ -349,6 +363,16 @@ impl App {
|
|||||||
if draft.take_name_refill() {
|
if draft.take_name_refill() {
|
||||||
effects.push(Effect::RefillNames(tab_idx));
|
effects.push(Effect::RefillNames(tab_idx));
|
||||||
}
|
}
|
||||||
|
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||||
|
effects.push(Effect::ProbeGitRoot {
|
||||||
|
tab: tab_idx,
|
||||||
|
seq,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if draft.take_branch_load().is_some() {
|
||||||
|
effects.push(Effect::LoadBranches(tab_idx));
|
||||||
|
}
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +446,72 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the draft target's enclosing repository via `/api/gitroot`
|
||||||
|
/// (#65). A miss unticks the worktree toggle inside `resolve_gitroot`;
|
||||||
|
/// a hit may schedule a branch load (Existing mode), served inline.
|
||||||
|
async fn probe_git_root(&mut self, tab_idx: usize, seq: u32, path: PathBuf) {
|
||||||
|
let root = self
|
||||||
|
.daemon
|
||||||
|
.git_root(&path.to_string_lossy())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|g| g.root);
|
||||||
|
let Some(PaneRef::Draft(draft)) = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.get_mut(tab_idx)
|
||||||
|
.and_then(|t| t.pane.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
draft.resolve_gitroot(seq, root);
|
||||||
|
if draft.take_branch_load().is_some() {
|
||||||
|
self.load_branches(tab_idx).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `/api/branches` batch for the draft's current cwd (#65). Same
|
||||||
|
/// reqwest route as [`Self::fetch_names`]: no typed client call exists.
|
||||||
|
async fn load_branches(&mut self, tab_idx: usize) {
|
||||||
|
let Some(path) = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.get(tab_idx)
|
||||||
|
.and_then(|t| t.pane.as_ref())
|
||||||
|
.and_then(|p| match p {
|
||||||
|
PaneRef::Draft(d) => d.cwd(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let url = format!(
|
||||||
|
"{}{}",
|
||||||
|
maestro_protocol::routes::normalize_base(self.daemon.base()),
|
||||||
|
routes::BRANCHES
|
||||||
|
);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(url)
|
||||||
|
.query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
// On failure the picker stays empty; "new" mode still works.
|
||||||
|
let branches = match resp {
|
||||||
|
Ok(resp) => resp.json::<Vec<BranchInfo>>().await.unwrap_or_default(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let Some(PaneRef::Draft(draft)) = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.get_mut(tab_idx)
|
||||||
|
.and_then(|t| t.pane.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
draft.set_branches(&path, branches);
|
||||||
|
}
|
||||||
|
|
||||||
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||||
match self.daemon.create_session(&req).await {
|
match self.daemon.create_session(&req).await {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
@@ -613,6 +703,7 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use maestro_config::TuiSection;
|
use maestro_config::TuiSection;
|
||||||
|
|
||||||
@@ -767,6 +858,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||||
for c in "claude".chars() {
|
for c in "claude".chars() {
|
||||||
@@ -777,6 +870,9 @@ mod tests {
|
|||||||
for c in "crimson-otter".chars() {
|
for c in "crimson-otter".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
}
|
}
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
||||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||||
|
|
||||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||||
@@ -793,6 +889,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Editing the raw path with the worktree toggle on schedules a
|
||||||
|
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
||||||
|
#[test]
|
||||||
|
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
draft.cwd_raw_visible = true;
|
||||||
|
draft.focused_field = DraftField::CwdRaw;
|
||||||
|
|
||||||
|
let effects = app.handle_draft_input(&InputKey::char('/'));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
effects.as_slice(),
|
||||||
|
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
||||||
|
),
|
||||||
|
"{effects:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switching the worktree branch source to Existing schedules one
|
||||||
|
/// `/api/branches` load for the current target (#65).
|
||||||
|
#[test]
|
||||||
|
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||||
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
|
||||||
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||||
|
draft.focused_field = DraftField::BranchMode;
|
||||||
|
|
||||||
|
let effects = app.handle_draft_input(&InputKey::Right);
|
||||||
|
assert!(
|
||||||
|
matches!(effects.as_slice(), [Effect::LoadBranches(0)]),
|
||||||
|
"{effects:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Submitting with no directory names the missing field inside the form
|
/// Submitting with no directory names the missing field inside the form
|
||||||
/// and posts nothing (#64).
|
/// and posts nothing (#64).
|
||||||
#[test]
|
#[test]
|
||||||
@@ -800,7 +939,7 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::NewDraftPane);
|
app.dispatch(Action::NewDraftPane);
|
||||||
// Walk to Submit without picking anything.
|
// Walk to Submit without picking anything.
|
||||||
for _ in 0..3 {
|
for _ in 0..4 {
|
||||||
app.handle_draft_input(&InputKey::Tab);
|
app.handle_draft_input(&InputKey::Tab);
|
||||||
}
|
}
|
||||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -20,9 +20,10 @@ src/
|
|||||||
main.ts # entry point, hash-route dispatch
|
main.ts # entry point, hash-route dispatch
|
||||||
api.ts # daemon HTTP API client
|
api.ts # daemon HTTP API client
|
||||||
events.ts # `/api/events` WebSocket subscriber
|
events.ts # `/api/events` WebSocket subscriber
|
||||||
catalog.ts # session tree view (groups by working directory)
|
catalog.ts # session tree view (groups by working directory)
|
||||||
attach.ts # xterm.js + attach WS view
|
attach.ts # xterm.js + attach WS view
|
||||||
spawn.ts # spawn-session modal
|
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
|
theme.ts # dark/light theme toggle + persistence
|
||||||
types.ts # mirror of `maestro-protocol` JSON shapes
|
types.ts # mirror of `maestro-protocol` JSON shapes
|
||||||
styles.css # design-system tokens + component styles
|
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
|
`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,
|
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:
|
Routing is hash-based:
|
||||||
- `#/` → catalog
|
- `#/` → catalog
|
||||||
|
|||||||
+11
-12
@@ -6,9 +6,15 @@ import type { Session } from "./types.js";
|
|||||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
import { abbreviateFolderName, sessionName } from "./types.js";
|
||||||
import type { TreeNode } from "./tree.js";
|
import type { TreeNode } from "./tree.js";
|
||||||
import { buildTree } from "./tree.js";
|
import { buildTree } from "./tree.js";
|
||||||
import { openSpawnModal } from "./spawn.js";
|
|
||||||
import { createThemeToggle } from "./theme.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>();
|
const collapsed = new Set<string>();
|
||||||
|
|
||||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||||
@@ -190,7 +196,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
spawnBtn.type = "button";
|
spawnBtn.type = "button";
|
||||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||||
spawnBtn.textContent = "+ Spawn";
|
spawnBtn.textContent = "+ Spawn";
|
||||||
spawnBtn.addEventListener("click", () => openSpawnModal());
|
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||||
actions.appendChild(spawnBtn);
|
actions.appendChild(spawnBtn);
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
@@ -342,7 +348,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
treeHost.replaceChildren();
|
treeHost.replaceChildren();
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
treeHost.appendChild(
|
treeHost.appendChild(
|
||||||
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
|
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -364,14 +370,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
const rerenderHandler = () => refresh();
|
const rerenderHandler = () => refresh();
|
||||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
|
||||||
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||||
// command (matched back to a preset by the form) — but with a freshly
|
// multi-view controller can seed a draft pane with the session's dir/command.
|
||||||
// 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);
|
|
||||||
|
|
||||||
const unsubscribe = store.subscribe(refresh);
|
const unsubscribe = store.subscribe(refresh);
|
||||||
|
|
||||||
@@ -391,7 +391,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
|
// One HTML escaper. Previously written four times, in catalog.ts, types.ts
|
||||||
// types.ts and attach.ts.
|
// and attach.ts.
|
||||||
|
|
||||||
export function escapeHtml(s: string): string {
|
export function escapeHtml(s: string): string {
|
||||||
return s
|
return s
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@
|
|||||||
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
|
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
.kit-slot .modal-body {
|
.kit-slot .spawn-form {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -135,7 +135,7 @@ export function renderKit(): HTMLElement {
|
|||||||
{
|
{
|
||||||
const { el, body } = section(
|
const { el, body } = section(
|
||||||
"Buttons — current",
|
"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(
|
body.append(
|
||||||
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
|
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
|
||||||
@@ -170,12 +170,12 @@ export function renderKit(): HTMLElement {
|
|||||||
search.type = "search";
|
search.type = "search";
|
||||||
search.className = "search";
|
search.className = "search";
|
||||||
search.placeholder = "Search sessions…";
|
search.placeholder = "Search sessions…";
|
||||||
const text = h("div", "modal-body");
|
const text = h("div", "spawn-form");
|
||||||
const ti = document.createElement("input");
|
const ti = document.createElement("input");
|
||||||
ti.type = "text";
|
ti.type = "text";
|
||||||
ti.placeholder = "branch-name";
|
ti.placeholder = "branch-name";
|
||||||
text.appendChild(ti);
|
text.appendChild(ti);
|
||||||
const sel = h("div", "modal-body");
|
const sel = h("div", "spawn-form");
|
||||||
const s = document.createElement("select");
|
const s = document.createElement("select");
|
||||||
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
|
||||||
sel.appendChild(s);
|
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 { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
import { loadLayout, saveLayout } from "./persistence.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 { initTheme } from "./theme.js";
|
||||||
import { sessionName } from "./types.js";
|
import { sessionName, type Session } from "./types.js";
|
||||||
import { installViewportSizing } from "./viewport.js";
|
import { installViewportSizing } from "./viewport.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ function renderWelcome(): HTMLElement {
|
|||||||
spawn.type = "button";
|
spawn.type = "button";
|
||||||
spawn.className = "btn btn-primary";
|
spawn.className = "btn btn-primary";
|
||||||
spawn.textContent = "+ Spawn session";
|
spawn.textContent = "+ Spawn session";
|
||||||
spawn.addEventListener("click", () => openSpawnModal());
|
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -251,6 +251,20 @@ function showMultiView(): void {
|
|||||||
updateShellRoute();
|
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 =
|
type Route =
|
||||||
| { kind: "catalog" }
|
| { kind: "catalog" }
|
||||||
| { kind: "attach"; sessionId: string }
|
| { kind: "attach"; sessionId: string }
|
||||||
|
|||||||
+2
-2
@@ -135,10 +135,10 @@ export function seedMock(store: CatalogStore): void {
|
|||||||
sessions,
|
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.
|
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||||
if (location.search.includes("spawn")) {
|
if (location.search.includes("spawn")) {
|
||||||
stubSpawnApi();
|
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
-29
@@ -13,6 +13,7 @@ import {
|
|||||||
closeTab,
|
closeTab,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
type Dir,
|
type Dir,
|
||||||
|
type Draft,
|
||||||
type Layout,
|
type Layout,
|
||||||
leaves,
|
leaves,
|
||||||
locateSession,
|
locateSession,
|
||||||
@@ -52,6 +53,10 @@ export interface MultiView {
|
|||||||
closeSession: (sessionId: string) => void;
|
closeSession: (sessionId: string) => void;
|
||||||
/** Every session currently shown in some pane, across all tabs. */
|
/** Every session currently shown in some pane, across all tabs. */
|
||||||
openSessions: () => Set<string>;
|
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
|
/** 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
|
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||||
* drag shows drop zones to place it. */
|
* 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 { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||||
const persist = () => opts.onLayoutChange?.();
|
const persist = () => opts.onLayoutChange?.();
|
||||||
|
|
||||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
|
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||||
// render tears down the previous generation before rebuilding the canvas.
|
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||||
const draftForms = new Set<SpawnFormHandle>();
|
// 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");
|
const el = document.createElement("div");
|
||||||
el.className = "mv";
|
el.className = "mv";
|
||||||
@@ -145,30 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
// 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;
|
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||||
// submitting fills this leaf with the new session in place.
|
// submitting fills this leaf with the new session in place.
|
||||||
const form = createSpawnForm({
|
const form = draftForms.get(leaf.id);
|
||||||
seed: leaf.draft ?? undefined,
|
if (form) {
|
||||||
class: "mv-draft",
|
host.appendChild(form.el);
|
||||||
onChange: (values) => {
|
} else {
|
||||||
leaf.draft = values;
|
const created = createSpawnForm({
|
||||||
},
|
seed: leaf.draft ?? undefined,
|
||||||
onSuccess: (session) => {
|
class: "mv-draft",
|
||||||
// The pane can be closed while the POST is in flight. Mounting the
|
onChange: (values) => {
|
||||||
// session onto a leaf no longer in the tree would strand it in no
|
leaf.draft = values;
|
||||||
// pane at all, so only claim the leaf while it is still there —
|
},
|
||||||
// searching every tab, since the operator may have switched tabs
|
onSuccess: (session) => {
|
||||||
// while the request was in flight.
|
// The pane can be closed while the POST is in flight. Mounting the
|
||||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
// session onto a leaf no longer in the tree would strand it in no
|
||||||
if (host) {
|
// pane at all, so only claim the leaf while it is still there —
|
||||||
leaf.sessionId = session.id;
|
// searching every tab, since the operator may have switched tabs
|
||||||
leaf.draft = null;
|
// while the request was in flight.
|
||||||
host.focusedLeafId = leaf.id;
|
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||||
}
|
if (host) {
|
||||||
render();
|
leaf.sessionId = session.id;
|
||||||
opts.onOpenSessionsChange?.();
|
leaf.draft = null;
|
||||||
},
|
host.focusedLeafId = leaf.id;
|
||||||
});
|
}
|
||||||
draftForms.add(form);
|
render();
|
||||||
host.appendChild(form.el);
|
opts.onOpenSessionsChange?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
draftForms.set(leaf.id, created);
|
||||||
|
host.appendChild(created.el);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = document.createElement("div");
|
const tools = document.createElement("div");
|
||||||
@@ -366,11 +378,49 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
render();
|
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 ----
|
// ---- 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 {
|
function render(): void {
|
||||||
for (const f of draftForms) f.destroy();
|
|
||||||
draftForms.clear();
|
|
||||||
renderTabs();
|
renderTabs();
|
||||||
canvas.replaceChildren();
|
canvas.replaceChildren();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
@@ -379,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||||
|
|
||||||
if (!tab.root) {
|
if (!tab.root) {
|
||||||
|
pruneForms(tab);
|
||||||
pool.setVisible([]);
|
pool.setVisible([]);
|
||||||
pool.setFocus(null);
|
pool.setFocus(null);
|
||||||
canvas.appendChild(renderWelcome());
|
canvas.appendChild(renderWelcome());
|
||||||
@@ -394,12 +445,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
renderMobile(tab);
|
renderMobile(tab);
|
||||||
|
pruneForms(tab);
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||||
canvas.appendChild(buildTree(tab.root, hosts));
|
canvas.appendChild(buildTree(tab.root, hosts));
|
||||||
|
pruneForms(tab);
|
||||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||||
syncFocus();
|
syncFocus();
|
||||||
persist();
|
persist();
|
||||||
@@ -640,6 +693,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return {
|
return {
|
||||||
el,
|
el,
|
||||||
openSession,
|
openSession,
|
||||||
|
openDraft,
|
||||||
closeSession,
|
closeSession,
|
||||||
openSessions: () => openSessions(layout),
|
openSessions: () => openSessions(layout),
|
||||||
beginSessionDrag,
|
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). */
|
* from the repository's existing branches (#61). */
|
||||||
export type WorktreeBranchMode = "new" | "existing";
|
export type WorktreeBranchMode = "new" | "existing";
|
||||||
|
|
||||||
/** Seed values for the spawn form — used by "clone" to reopen with the same
|
/** Seed values for the spawn form — used by "clone" to open a draft with the
|
||||||
* cwd, command, and name as an existing session, and by draft panes to restore
|
* same cwd, command, and name as an existing session, and by draft panes to
|
||||||
* their own half-filled state after a re-render. */
|
* restore their own half-filled state after a re-render. */
|
||||||
export interface SpawnFormSeed {
|
export interface SpawnFormSeed {
|
||||||
command?: string;
|
command?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -99,7 +99,7 @@ export interface SpawnFormHandle {
|
|||||||
|
|
||||||
export interface SpawnFormOptions {
|
export interface SpawnFormOptions {
|
||||||
seed?: SpawnFormSeed;
|
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;
|
class?: string;
|
||||||
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
/** Fired whenever any value changes (input, tile pick, checkbox). */
|
||||||
onChange?: (values: SpawnFormValues) => void;
|
onChange?: (values: SpawnFormValues) => void;
|
||||||
@@ -108,7 +108,7 @@ export interface SpawnFormOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The spawn form itself — preset tiles, project 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). */
|
* Mounted inline by draft panes (issue #57). */
|
||||||
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||||
const prefill = opts.seed ?? {};
|
const prefill = opts.seed ?? {};
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
// Field queries are scoped to this form instance — several forms can be
|
// 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 errEl = form.querySelector(".form-error") as HTMLElement;
|
||||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||||
|
|||||||
+11
-117
@@ -1205,72 +1205,9 @@ textarea {
|
|||||||
border-color: var(--accent-strong);
|
border-color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- modal ---------- */
|
/* ---------- forms ---------- */
|
||||||
|
|
||||||
.modal-backdrop {
|
.spawn-form {
|
||||||
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 {
|
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1280,7 +1217,6 @@ textarea {
|
|||||||
scrollbar-color: var(--border-strong) transparent;
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body label,
|
|
||||||
.spawn-form label {
|
.spawn-form label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1290,7 +1226,6 @@ textarea {
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body label.row-inline,
|
|
||||||
.spawn-form label.row-inline {
|
.spawn-form label.row-inline {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1300,10 +1235,9 @@ textarea {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="text"],
|
.spawn-form input[type="text"],
|
||||||
.modal-body input[type="search"],
|
.spawn-form input[type="search"],
|
||||||
.modal-body select,
|
.spawn-form select {
|
||||||
.spawn-form input[type="text"] {
|
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
@@ -1318,23 +1252,20 @@ textarea {
|
|||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="text"]:hover,
|
.spawn-form input[type="text"]:hover,
|
||||||
.modal-body select:hover,
|
.spawn-form select:hover {
|
||||||
.spawn-form input[type="text"]:hover {
|
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="text"]:focus,
|
.spawn-form input[type="text"]:focus,
|
||||||
.modal-body input[type="search"]:focus,
|
.spawn-form input[type="search"]:focus,
|
||||||
.modal-body select:focus,
|
.spawn-form select:focus {
|
||||||
.spawn-form input[type="text"]:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body input[type="checkbox"],
|
|
||||||
.spawn-form input[type="checkbox"] {
|
.spawn-form input[type="checkbox"] {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -1359,13 +1290,6 @@ textarea {
|
|||||||
font-size: var(--fs-13);
|
font-size: var(--fs-13);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1381,7 +1305,6 @@ textarea {
|
|||||||
|
|
||||||
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
||||||
|
|
||||||
.spawn-modal .field,
|
|
||||||
.spawn-form .field {
|
.spawn-form .field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1611,7 +1534,7 @@ textarea {
|
|||||||
|
|
||||||
.dir-ac {
|
.dir-ac {
|
||||||
position: fixed;
|
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;
|
margin: 0;
|
||||||
padding: var(--sp-1);
|
padding: var(--sp-1);
|
||||||
list-style: none;
|
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 ---------- */
|
/* ---------- responsive ---------- */
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
@@ -2119,15 +2022,6 @@ textarea {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 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
|
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||||
|
|||||||
Reference in New Issue
Block a user