Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b260b5c20 | ||
|
|
3eb1d7ddbe | ||
|
|
9d6b69cc49 |
+451
-31
@@ -23,7 +23,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::catalog::CatalogStore;
|
use crate::catalog::CatalogStore;
|
||||||
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
|
use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId};
|
||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
use crate::daemon_link::DaemonLink;
|
use crate::daemon_link::DaemonLink;
|
||||||
use crate::draft::{DraftInputResult, DraftPane};
|
use crate::draft::{DraftInputResult, DraftPane};
|
||||||
@@ -168,6 +168,14 @@ pub enum Effect {
|
|||||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||||
/// spawn with no form; unresolvable defaults open a pre-filled form.
|
/// spawn with no form; unresolvable defaults open a pre-filled form.
|
||||||
FastForward,
|
FastForward,
|
||||||
|
/// The same fast path pinned to one directory — the command bar's `~`
|
||||||
|
/// (#154). Nothing may override the directory the operator named.
|
||||||
|
FastForwardIn(PathBuf),
|
||||||
|
/// Fetch the command bar's `~` candidates: recent dirs and the project
|
||||||
|
/// list. `bar` guards a load landing in a bar that was reopened since.
|
||||||
|
LoadBarDirectories {
|
||||||
|
bar: u64,
|
||||||
|
},
|
||||||
/// The form's target changed: probe `/api/gitroot` so the worktree
|
/// The form's target changed: probe `/api/gitroot` so the worktree
|
||||||
/// section knows its base repo (#65). `seq` guards stale answers.
|
/// section knows its base repo (#65). `seq` guards stale answers.
|
||||||
ProbeGitRoot {
|
ProbeGitRoot {
|
||||||
@@ -386,6 +394,8 @@ impl App {
|
|||||||
Effect::Kill(id) => self.submit_kill(id).await,
|
Effect::Kill(id) => self.submit_kill(id).await,
|
||||||
Effect::LoadDraft => self.spawn_load_draft(),
|
Effect::LoadDraft => self.spawn_load_draft(),
|
||||||
Effect::FastForward => self.spawn_fast_forward(),
|
Effect::FastForward => self.spawn_fast_forward(),
|
||||||
|
Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir),
|
||||||
|
Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar),
|
||||||
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
|
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
|
||||||
Effect::LoadBranches => self.spawn_load_branches(),
|
Effect::LoadBranches => self.spawn_load_branches(),
|
||||||
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||||
@@ -459,6 +469,35 @@ impl App {
|
|||||||
self.dispatch(Action::AttachHighlighted),
|
self.dispatch(Action::AttachHighlighted),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The bar picks the action and hands off; it never
|
||||||
|
// reimplements one.
|
||||||
|
BarOutcome::Open(BarTarget::Command(action)) => {
|
||||||
|
self.close_command_bar();
|
||||||
|
(OverlayDecision::Consumed, self.dispatch(action))
|
||||||
|
}
|
||||||
|
BarOutcome::Open(BarTarget::Directory(dir)) => {
|
||||||
|
self.close_command_bar();
|
||||||
|
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
|
||||||
|
}
|
||||||
|
// The action page raises the flow this client already has
|
||||||
|
// — for kill that is `KillConfirm`, which the TUI puts in
|
||||||
|
// front of every kill (#160). The bar names the target;
|
||||||
|
// it never reimplements the flow.
|
||||||
|
BarOutcome::Open(BarTarget::SessionAction { session, action }) => {
|
||||||
|
self.close_command_bar();
|
||||||
|
let effects = match action {
|
||||||
|
SessionActionId::Kill => {
|
||||||
|
self.confirm_kill(session);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
SessionActionId::Rename => {
|
||||||
|
self.prompt_rename(session);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
SessionActionId::SpawnSimilar => self.open_spawn_similar(session),
|
||||||
|
};
|
||||||
|
(OverlayDecision::Consumed, effects)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
@@ -506,7 +545,7 @@ impl App {
|
|||||||
};
|
};
|
||||||
self.state.show_overlay(OverlayKind::Help);
|
self.state.show_overlay(OverlayKind::Help);
|
||||||
}
|
}
|
||||||
Action::OpenCommandBar => self.open_command_bar(),
|
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
||||||
Action::OpenSpawnModal => {
|
Action::OpenSpawnModal => {
|
||||||
self.open_spawn_modal();
|
self.open_spawn_modal();
|
||||||
effects.push(Effect::LoadDraft);
|
effects.push(Effect::LoadDraft);
|
||||||
@@ -535,24 +574,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
Action::KillHighlighted => {
|
Action::KillHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
self.confirm_kill(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::RenameHighlighted => {
|
Action::RenameHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
// Pre-fill with the custom name only: a session that has
|
self.prompt_rename(sid);
|
||||||
// none starts empty rather than with its command already
|
|
||||||
// typed in, which Enter would then pin as the name.
|
|
||||||
self.rename_input = TextInput::with_value(
|
|
||||||
self.catalog
|
|
||||||
.snapshot()
|
|
||||||
.sessions
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.id == sid)
|
|
||||||
.and_then(crate::sidebar::custom_name_for)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
self.state.show_overlay(OverlayKind::Rename(sid));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,12 +655,21 @@ impl App {
|
|||||||
/// while a session is previewed discards the preview first, like any
|
/// while a session is previewed discards the preview first, like any
|
||||||
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
|
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
|
||||||
/// so closing gives the keyboard back to whatever held it.
|
/// so closing gives the keyboard back to whatever held it.
|
||||||
fn open_command_bar(&mut self) {
|
fn open_command_bar(&mut self) -> Vec<Effect> {
|
||||||
let snapshot = self.catalog.snapshot();
|
let snapshot = self.catalog.snapshot();
|
||||||
self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir));
|
// The keymap for the mode the bar was opened from, which is the one
|
||||||
|
// the operator would be typing into behind it. Same source the help
|
||||||
|
// overlay reads, so the two can never disagree.
|
||||||
|
let keymap = self.cfg.keymap.entries(self.state.mode);
|
||||||
|
let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir);
|
||||||
|
let id = bar.id();
|
||||||
|
self.command_bar = Some(bar);
|
||||||
self.pending_preview = None;
|
self.pending_preview = None;
|
||||||
self.clear_preview();
|
self.clear_preview();
|
||||||
self.state.show_overlay(OverlayKind::CommandBar);
|
self.state.show_overlay(OverlayKind::CommandBar);
|
||||||
|
// `~`'s candidates are a network round trip; the bar opens without
|
||||||
|
// them and fills in when they land.
|
||||||
|
vec![Effect::LoadBarDirectories { bar: id }]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Take the bar down, dropping its query. Every exit goes through here so
|
/// Take the bar down, dropping its query. Every exit goes through here so
|
||||||
@@ -829,6 +865,30 @@ impl App {
|
|||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raise the kill confirmation for one session (`docs/WORKTREES.md`).
|
||||||
|
/// Shared by the keymap's own kill and the command bar's action page
|
||||||
|
/// (#155), which picks the action and hands off rather than reimplementing
|
||||||
|
/// it; the blockers the dialog names are read at render time.
|
||||||
|
fn confirm_kill(&mut self, sid: Uuid) {
|
||||||
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raise the rename prompt for one session, pre-filled with its custom
|
||||||
|
/// name only: a session that has none starts empty rather than with its
|
||||||
|
/// command already typed in, which Enter would then pin as the name.
|
||||||
|
fn prompt_rename(&mut self, sid: Uuid) {
|
||||||
|
self.rename_input = TextInput::with_value(
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == sid)
|
||||||
|
.and_then(crate::sidebar::custom_name_for)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||||
|
}
|
||||||
|
|
||||||
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
||||||
/// project of the session the operator was last on — so `n` then Enter
|
/// project of the session the operator was last on — so `n` then Enter
|
||||||
/// spawns another session there, the way "spawn similar" would. A
|
/// spawns another session there, the way "spawn similar" would. A
|
||||||
@@ -842,6 +902,30 @@ impl App {
|
|||||||
self.state.open_spawn_modal();
|
self.state.open_spawn_modal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Spawn similar" (#155): the spawn form seeded from one session's
|
||||||
|
/// directory and command, rather than from whatever the operator was last
|
||||||
|
/// looking at. Same base-repository rule as the plain seed, and the name
|
||||||
|
/// is left out on purpose — a clone is a new session and takes its own
|
||||||
|
/// generated handle, like `web/src/spawnSimilar.ts`.
|
||||||
|
fn open_spawn_similar(&mut self, sid: Uuid) -> Vec<Effect> {
|
||||||
|
let session = self
|
||||||
|
.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == sid)
|
||||||
|
.cloned();
|
||||||
|
self.open_spawn_modal();
|
||||||
|
if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) {
|
||||||
|
draft.seed_cwd = Some(match &session.worktree {
|
||||||
|
Some(wt) => wt.base_repo.clone(),
|
||||||
|
None => session.cwd.clone(),
|
||||||
|
});
|
||||||
|
draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone());
|
||||||
|
}
|
||||||
|
vec![Effect::LoadDraft]
|
||||||
|
}
|
||||||
|
|
||||||
/// The project of the session the operator was last on — its base
|
/// The project of the session the operator was last on — its base
|
||||||
/// repository for a worktree session, its cwd otherwise. Shared by the
|
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||||
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
||||||
@@ -990,18 +1074,27 @@ impl App {
|
|||||||
projects,
|
projects,
|
||||||
names,
|
names,
|
||||||
focused_cwd,
|
focused_cwd,
|
||||||
|
pinned,
|
||||||
} = update
|
} = update
|
||||||
{
|
{
|
||||||
self.fast_forward_spawn(info, projects, names, focused_cwd)
|
self.fast_forward_spawn(info, projects, names, focused_cwd, pinned)
|
||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Addressed to the bar, not to a form: a load that outlived its bar is
|
||||||
|
// dropped rather than filling in the next one.
|
||||||
|
if let DraftUpdate::BarDirectories { bar, paths } = update {
|
||||||
|
if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) {
|
||||||
|
open.set_directories(&paths);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let want = match &update {
|
let want = match &update {
|
||||||
DraftUpdate::Data { draft, .. }
|
DraftUpdate::Data { draft, .. }
|
||||||
| DraftUpdate::GitRoot { draft, .. }
|
| DraftUpdate::GitRoot { draft, .. }
|
||||||
| DraftUpdate::Branches { draft, .. } => *draft,
|
| DraftUpdate::Branches { draft, .. } => *draft,
|
||||||
// Handled above; it targets no particular draft.
|
// Handled above; they target no particular draft.
|
||||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(),
|
||||||
};
|
};
|
||||||
// Match by identity: dismissing the form and opening another while a
|
// Match by identity: dismissing the form and opening another while a
|
||||||
// load is in flight must not let the old answer land in the new form.
|
// load is in flight must not let the old answer land in the new form.
|
||||||
@@ -1036,7 +1129,9 @@ impl App {
|
|||||||
DraftUpdate::Branches { cwd, branches, .. } => {
|
DraftUpdate::Branches { cwd, branches, .. } => {
|
||||||
draft.set_branches(&cwd, branches);
|
draft.set_branches(&cwd, branches);
|
||||||
}
|
}
|
||||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if follow_up_branches {
|
if follow_up_branches {
|
||||||
self.spawn_load_branches();
|
self.spawn_load_branches();
|
||||||
@@ -1064,16 +1159,52 @@ impl App {
|
|||||||
projects: projects.ok(),
|
projects: projects.ok(),
|
||||||
names,
|
names,
|
||||||
focused_cwd,
|
focused_cwd,
|
||||||
|
pinned: None,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same fast path, pinned to the directory the command bar's `~` row
|
||||||
|
/// named (#154). Off the loop for the same reason as [`Self::
|
||||||
|
/// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`.
|
||||||
|
fn spawn_fast_forward_in(&self, dir: PathBuf) {
|
||||||
|
let (daemon, http, base, tx) = self.loader_handles();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
||||||
|
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
||||||
|
let _ = tx.send(DraftUpdate::FastForward {
|
||||||
|
info: info.ok(),
|
||||||
|
projects: projects.ok(),
|
||||||
|
names,
|
||||||
|
focused_cwd: None,
|
||||||
|
pinned: Some(dir),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs
|
||||||
|
/// (newest first) ahead of the project list. Either failing is a shorter
|
||||||
|
/// list, not an error state.
|
||||||
|
fn spawn_load_bar_directories(&self, bar: u64) {
|
||||||
|
let daemon = self.daemon.clone();
|
||||||
|
let tx = self.draft_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects());
|
||||||
|
let mut paths: Vec<PathBuf> = recents
|
||||||
|
.map(|r| r.dirs.into_iter().map(|d| d.cwd).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path));
|
||||||
|
let _ = tx.send(DraftUpdate::BarDirectories { bar, paths });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async fn fast_forward_spawn(
|
async fn fast_forward_spawn(
|
||||||
&mut self,
|
&mut self,
|
||||||
info: Option<maestro_protocol::DaemonInfo>,
|
info: Option<maestro_protocol::DaemonInfo>,
|
||||||
projects: Option<Vec<maestro_protocol::Project>>,
|
projects: Option<Vec<maestro_protocol::Project>>,
|
||||||
names: Vec<String>,
|
names: Vec<String>,
|
||||||
focused_cwd: Option<PathBuf>,
|
focused_cwd: Option<PathBuf>,
|
||||||
|
pinned: Option<PathBuf>,
|
||||||
) {
|
) {
|
||||||
// Keep the home dir: without it the pre-filled draft renders
|
// Keep the home dir: without it the pre-filled draft renders
|
||||||
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
||||||
@@ -1087,13 +1218,21 @@ impl App {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
let discovered = projects.unwrap_or_default();
|
let discovered = projects.unwrap_or_default();
|
||||||
let resolved = crate::fast_forward::resolve(
|
// A pinned directory is the operator's own pick, so it takes no
|
||||||
&discovered,
|
// fallback: a directory outside the project scan resolves no project,
|
||||||
&presets,
|
// which is an unresolvable default and opens the form seeded with it.
|
||||||
names.first().cloned(),
|
let resolved = match pinned.as_deref() {
|
||||||
Utc::now(),
|
Some(dir) => {
|
||||||
focused_cwd.as_deref(),
|
crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned())
|
||||||
);
|
}
|
||||||
|
None => crate::fast_forward::resolve(
|
||||||
|
&discovered,
|
||||||
|
&presets,
|
||||||
|
names.first().cloned(),
|
||||||
|
Utc::now(),
|
||||||
|
focused_cwd.as_deref(),
|
||||||
|
),
|
||||||
|
};
|
||||||
if let Some(req) = resolved.build_request() {
|
if let Some(req) = resolved.build_request() {
|
||||||
self.submit_spawn(req).await;
|
self.submit_spawn(req).await;
|
||||||
return;
|
return;
|
||||||
@@ -1105,8 +1244,10 @@ impl App {
|
|||||||
};
|
};
|
||||||
draft.home_dir = home_dir;
|
draft.home_dir = home_dir;
|
||||||
// The fast path resolved its own project; its pick wins over the
|
// The fast path resolved its own project; its pick wins over the
|
||||||
// focused session's, so the seed never lands on top of it.
|
// focused session's, so the seed never lands on top of it. A pinned
|
||||||
draft.seed_cwd = None;
|
// directory is the exception: it is the seed, whether or not the scan
|
||||||
|
// knew it as a project.
|
||||||
|
draft.seed_cwd = pinned;
|
||||||
if !presets.is_empty() {
|
if !presets.is_empty() {
|
||||||
draft.set_presets(presets);
|
draft.set_presets(presets);
|
||||||
}
|
}
|
||||||
@@ -1463,7 +1604,12 @@ enum DraftUpdate {
|
|||||||
projects: Option<Vec<maestro_protocol::Project>>,
|
projects: Option<Vec<maestro_protocol::Project>>,
|
||||||
names: Vec<String>,
|
names: Vec<String>,
|
||||||
focused_cwd: Option<PathBuf>,
|
focused_cwd: Option<PathBuf>,
|
||||||
|
/// The command bar pinned the spawn to this directory (#154): nothing
|
||||||
|
/// — not the focused session, not the recency ranking — may move it.
|
||||||
|
pinned: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
/// The command bar's `~` candidates arrived (#154).
|
||||||
|
BarDirectories { bar: u64, paths: Vec<PathBuf> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
||||||
@@ -2222,6 +2368,102 @@ mod tests {
|
|||||||
assert_eq!(req["worktree"]["create_branch"], json!(true));
|
assert_eq!(req["worktree"]["create_branch"], json!(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command bar's `~` pins the spawn: the directory the operator named
|
||||||
|
/// wins over the recency ranking that plain fast-forward would follow.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() {
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
async fn info() -> Json<serde_json::Value> {
|
||||||
|
Json(json!({
|
||||||
|
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
||||||
|
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
async fn projects() -> Json<serde_json::Value> {
|
||||||
|
Json(json!([
|
||||||
|
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
|
||||||
|
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"},
|
||||||
|
{"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned",
|
||||||
|
"last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(),
|
||||||
|
"last_command": "zsh"}
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
async fn names() -> Json<serde_json::Value> {
|
||||||
|
Json(json!({"names": ["crimson-otter"]}))
|
||||||
|
}
|
||||||
|
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
|
||||||
|
let seen = posted.clone();
|
||||||
|
async fn spawn_session(
|
||||||
|
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
|
||||||
|
Json(body): Json<serde_json::Value>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
seen.lock().unwrap().push(body);
|
||||||
|
Json(json!({
|
||||||
|
"id": uuid::Uuid::from_u128(78).to_string(),
|
||||||
|
"cwd": "/repos/pinned", "command": "zsh",
|
||||||
|
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
.route(
|
||||||
|
maestro_protocol::routes::SESSIONS,
|
||||||
|
post(spawn_session).with_state(seen),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))])
|
||||||
|
.await;
|
||||||
|
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("load completed")
|
||||||
|
.expect("channel open");
|
||||||
|
app.apply_draft_update(update).await;
|
||||||
|
|
||||||
|
let posts = posted.lock().unwrap();
|
||||||
|
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
|
||||||
|
assert_eq!(posts[0]["cwd"], json!("/repos/pinned"));
|
||||||
|
assert_eq!(posts[0]["command"], json!("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory the project scan does not know resolves no project, which
|
||||||
|
/// is an unresolvable default: the form opens seeded with it rather than
|
||||||
|
/// the spawn silently landing somewhere else.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.fast_forward_spawn(
|
||||||
|
None,
|
||||||
|
Some(vec![maestro_protocol::Project {
|
||||||
|
path: PathBuf::from("/repos/fresh"),
|
||||||
|
name: "fresh".into(),
|
||||||
|
is_worktree: false,
|
||||||
|
base_repo: None,
|
||||||
|
last_activity: Some(Utc::now()),
|
||||||
|
last_command: Some("aider".into()),
|
||||||
|
}]),
|
||||||
|
vec!["crimson-otter".into()],
|
||||||
|
None,
|
||||||
|
Some(PathBuf::from("/elsewhere")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||||
|
let draft = app.draft.as_ref().expect("form is up");
|
||||||
|
// The seed is dropped by the picker when no project matches it, so
|
||||||
|
// the operator picks by hand — but nothing spawned in `/repos/fresh`.
|
||||||
|
assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh")));
|
||||||
|
}
|
||||||
|
|
||||||
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
|
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
|
||||||
/// switching which session the pane shows, and leaving the pane back to
|
/// switching which session the pane shows, and leaving the pane back to
|
||||||
/// the sidebar. A sidebar highlight alone must report nothing.
|
/// the sidebar. A sidebar highlight alone must report nothing.
|
||||||
@@ -2850,12 +3092,115 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|r| match r.target {
|
.map(|r| match r.target {
|
||||||
BarTarget::Session(id) => id,
|
BarTarget::Session(id) => id,
|
||||||
|
ref other => panic!("expected a session row, got {other:?}"),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![bravo, alpha]
|
vec![bravo, alpha]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `~`'s candidates are a network round trip, so opening asks for them
|
||||||
|
/// rather than blocking the keystroke on one.
|
||||||
|
#[test]
|
||||||
|
fn opening_the_bar_asks_for_its_directories() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let effects = app.dispatch(Action::OpenCommandBar);
|
||||||
|
let bar = app.command_bar.as_ref().unwrap().id();
|
||||||
|
assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_into_bar(app: &mut App, text: &str) {
|
||||||
|
for c in text.chars() {
|
||||||
|
app.handle_overlay_input(&InputKey::Char {
|
||||||
|
c,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `>` lists the live keymap for the current mode, matched on the label
|
||||||
|
/// alone, and Enter hands the action to the ordinary dispatcher.
|
||||||
|
#[test]
|
||||||
|
fn enter_on_a_command_row_dispatches_the_action() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::OpenCommandBar);
|
||||||
|
type_into_bar(&mut app, ">defaults");
|
||||||
|
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
|
||||||
|
assert_eq!(row.name, "Create session with defaults");
|
||||||
|
assert_eq!(
|
||||||
|
row.target,
|
||||||
|
BarTarget::Command(Action::FastForwardSpawn),
|
||||||
|
"chords: {:?}",
|
||||||
|
row.chords
|
||||||
|
);
|
||||||
|
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert_eq!(effects, vec![Effect::FastForward]);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
assert!(app.command_bar.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row shows the chord bound to it, and both chords when the map binds
|
||||||
|
/// two — but the chord is never what matched.
|
||||||
|
#[test]
|
||||||
|
fn a_command_row_carries_its_chords() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::OpenCommandBar);
|
||||||
|
type_into_bar(&mut app, ">open selected");
|
||||||
|
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
|
||||||
|
assert_eq!(row.name, "Open selected item");
|
||||||
|
assert!(
|
||||||
|
row.chords.iter().any(|c| c == "enter"),
|
||||||
|
"chords: {:?}",
|
||||||
|
row.chords
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
row.chords.len() > 1,
|
||||||
|
"the default map binds more than one chord to it: {:?}",
|
||||||
|
row.chords
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_a_directory_row_fast_forward_spawns_there() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::OpenCommandBar);
|
||||||
|
app.command_bar
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.set_directories(&[PathBuf::from("/repos/api")]);
|
||||||
|
type_into_bar(&mut app, "~api");
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert_eq!(
|
||||||
|
effects,
|
||||||
|
vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))]
|
||||||
|
);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A load that outlived its bar fills in nothing: the reopened bar has its
|
||||||
|
/// own identity.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_stale_directory_load_is_dropped() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::OpenCommandBar);
|
||||||
|
let stale = app.command_bar.as_ref().unwrap().id();
|
||||||
|
app.handle_overlay_input(&InputKey::Esc);
|
||||||
|
app.dispatch(Action::OpenCommandBar);
|
||||||
|
|
||||||
|
app.apply_draft_update(DraftUpdate::BarDirectories {
|
||||||
|
bar: stale,
|
||||||
|
paths: vec![PathBuf::from("/repos/api")],
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
type_into_bar(&mut app, "~");
|
||||||
|
assert!(app.command_bar.as_ref().unwrap().rows().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// Opening from Insert mode leaves the keyboard where it was, so closing
|
/// Opening from Insert mode leaves the keyboard where it was, so closing
|
||||||
/// gives it back — the bar is a view over the app, not a place in it.
|
/// gives it back — the bar is a view over the app, not a place in it.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3089,4 +3434,79 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the session action page (#155) ----
|
||||||
|
|
||||||
|
/// Drill into the second row's session, then pick an action by name.
|
||||||
|
fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec<Effect>) {
|
||||||
|
app.dispatch(Action::OpenCommandBar);
|
||||||
|
app.handle_overlay_input(&InputKey::Down);
|
||||||
|
app.handle_overlay_input(&InputKey::Tab);
|
||||||
|
type_into_bar(app, action);
|
||||||
|
app.handle_overlay_input(&InputKey::Enter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar picks the action and hands off: kill goes through the confirm
|
||||||
|
/// this client puts in front of every kill, not straight to the daemon.
|
||||||
|
#[test]
|
||||||
|
fn the_action_page_raises_the_existing_kill_confirm() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||||
|
let (decision, effects) = drill_and_pick(&mut app, "kill");
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty(), "the confirm is the whole effect");
|
||||||
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha)));
|
||||||
|
assert!(app.command_bar.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_action_page_raises_the_existing_rename_prompt_prefilled() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||||
|
let (decision, effects) = drill_and_pick(&mut app, "rename");
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha)));
|
||||||
|
assert_eq!(app.rename_input.value(), "alpha");
|
||||||
|
assert!(app.command_bar.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn similar opens the spawn form seeded from that session's directory
|
||||||
|
/// and command — not from whatever the operator was last looking at.
|
||||||
|
#[test]
|
||||||
|
fn the_action_page_raises_the_spawn_form_seeded_from_the_session() {
|
||||||
|
let mut app = make_app();
|
||||||
|
seed_two_sessions(&mut app);
|
||||||
|
let (decision, effects) = drill_and_pick(&mut app, "spawn");
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert_eq!(effects, vec![Effect::LoadDraft]);
|
||||||
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||||
|
let draft = app.draft.as_ref().unwrap();
|
||||||
|
assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/")));
|
||||||
|
assert_eq!(draft.seed_command.as_deref(), Some("bash"));
|
||||||
|
assert!(app.command_bar.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backing out is free: the result-list query survives the round trip.
|
||||||
|
#[test]
|
||||||
|
fn escape_on_the_action_page_returns_to_the_result_list() {
|
||||||
|
let mut app = make_app();
|
||||||
|
seed_two_sessions(&mut app);
|
||||||
|
app.dispatch(Action::OpenCommandBar);
|
||||||
|
type_into_bar(&mut app, "alp");
|
||||||
|
app.handle_overlay_input(&InputKey::Tab);
|
||||||
|
assert!(app.command_bar.as_ref().unwrap().on_action_page());
|
||||||
|
|
||||||
|
let (decision, _) = app.handle_overlay_input(&InputKey::Esc);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
let bar = app.command_bar.as_ref().expect("escape backs out, not out");
|
||||||
|
assert!(!bar.on_action_page());
|
||||||
|
assert_eq!(bar.query(), "alp");
|
||||||
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
||||||
|
|
||||||
|
// A second escape is the way out again.
|
||||||
|
app.handle_overlay_input(&InputKey::Esc);
|
||||||
|
assert!(app.command_bar.is_none());
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -228,6 +228,10 @@ pub struct DraftPane {
|
|||||||
/// respawns there without touching the picker. Consumed by the first
|
/// respawns there without touching the picker. Consumed by the first
|
||||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||||
pub seed_cwd: Option<PathBuf>,
|
pub seed_cwd: Option<PathBuf>,
|
||||||
|
/// Command to preselect once the preset list lands — "spawn similar"'s
|
||||||
|
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
|
||||||
|
/// `None` once applied or when nothing seeded it.
|
||||||
|
pub seed_command: Option<String>,
|
||||||
/// The repository picked in step one ([`group_key`] of its main box),
|
/// The repository picked in step one ([`group_key`] of its main box),
|
||||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||||
/// cached, so a project-list refresh can't leave it stale.
|
/// cached, so a project-list refresh can't leave it stale.
|
||||||
@@ -291,6 +295,7 @@ impl Default for DraftPane {
|
|||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
|
seed_command: None,
|
||||||
custom_command: crate::text_input::TextInput::default(),
|
custom_command: crate::text_input::TextInput::default(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
filter: crate::text_input::TextInput::default(),
|
filter: crate::text_input::TextInput::default(),
|
||||||
@@ -342,6 +347,27 @@ impl DraftPane {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
self.apply_seed_command();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
|
/// daemon offers one, otherwise the custom escape hatch with the command
|
||||||
|
/// typed in.
|
||||||
|
fn apply_seed_command(&mut self) {
|
||||||
|
let Some(command) = self.seed_command.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match self.presets.iter().position(|p| p.command == command) {
|
||||||
|
Some(index) => self.selected_preset = index,
|
||||||
|
None => {
|
||||||
|
self.selected_preset = self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.command.is_empty())
|
||||||
|
.unwrap_or(self.selected_preset);
|
||||||
|
self.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the project snapshot: sort fresh-first (#54), group by
|
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||||
@@ -1688,6 +1714,48 @@ mod tests {
|
|||||||
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
|
||||||
|
/// the tile that runs it when the daemon offers one.
|
||||||
|
#[test]
|
||||||
|
fn a_seeded_command_selects_its_own_preset() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_command: Some("aider".into()),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "aider".into(),
|
||||||
|
label: "Aider".into(),
|
||||||
|
command: "aider".into(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert_eq!(draft.command(), "aider");
|
||||||
|
assert!(!draft.is_custom());
|
||||||
|
assert_eq!(draft.seed_command, None, "consumed by the first load");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command no preset runs falls back to the custom tile with the command
|
||||||
|
/// typed in, so the clone still spawns what it cloned.
|
||||||
|
#[test]
|
||||||
|
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_command: Some("bash -l".into()),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
}]);
|
||||||
|
assert!(draft.is_custom());
|
||||||
|
assert_eq!(draft.command(), "bash -l");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
@@ -49,6 +49,33 @@ pub fn resolve(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
});
|
});
|
||||||
|
defaults(project, presets, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the defaults for a spawn pinned to one directory — the command
|
||||||
|
/// bar's `~` (#154), where the operator named the directory and nothing may
|
||||||
|
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
|
||||||
|
/// the project scan does not know leaves `project` `None`, which is an
|
||||||
|
/// unresolvable default, so the keystroke opens the pre-filled form instead of
|
||||||
|
/// spawning somewhere else.
|
||||||
|
pub fn resolve_at(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
projects: &[Project],
|
||||||
|
presets: &[SpawnPreset],
|
||||||
|
handle: Option<String>,
|
||||||
|
) -> FastForwardDefaults {
|
||||||
|
let project = projects.iter().find(|p| p.path == dir).cloned();
|
||||||
|
defaults(project, presets, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The project's last command, or the first non-custom preset, wrapped up with
|
||||||
|
/// the rest of the defaults. Shared by both resolvers so the two entry points
|
||||||
|
/// can never settle on different commands.
|
||||||
|
fn defaults(
|
||||||
|
project: Option<Project>,
|
||||||
|
presets: &[SpawnPreset],
|
||||||
|
handle: Option<String>,
|
||||||
|
) -> FastForwardDefaults {
|
||||||
let command = project
|
let command = project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.last_command.clone())
|
.and_then(|p| p.last_command.clone())
|
||||||
@@ -159,6 +186,46 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command bar's `~` names the directory; nothing may override it.
|
||||||
|
#[test]
|
||||||
|
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
|
||||||
|
let projects = vec![
|
||||||
|
project("/repos/fresh", 0, Some("aider")),
|
||||||
|
project("/repos/pinned", 30, Some("zsh")),
|
||||||
|
];
|
||||||
|
let resolved = resolve_at(
|
||||||
|
std::path::Path::new("/repos/pinned"),
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
Some("otter".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.project.as_ref().map(|p| p.path.clone()),
|
||||||
|
Some(PathBuf::from("/repos/pinned"))
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.command.as_deref(), Some("zsh"));
|
||||||
|
let req = resolved.build_request().expect("every default resolved");
|
||||||
|
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory the scan does not know resolves no project, so there is no
|
||||||
|
/// request to build — the caller opens the pre-filled form instead of
|
||||||
|
/// spawning somewhere else.
|
||||||
|
#[test]
|
||||||
|
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
|
||||||
|
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
|
||||||
|
let resolved = resolve_at(
|
||||||
|
std::path::Path::new("/elsewhere"),
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
Some("otter".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.project, None);
|
||||||
|
// The preset still resolves; the missing project is what blocks it.
|
||||||
|
assert_eq!(resolved.command.as_deref(), Some("claude"));
|
||||||
|
assert_eq!(resolved.build_request(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_every_default_from_the_most_recent_project() {
|
fn resolves_every_default_from_the_most_recent_project() {
|
||||||
let projects = vec![
|
let projects = vec![
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
//
|
||||||
|
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||||
|
// kill or rename and raises the flow the card already has, so what the page
|
||||||
|
// runs is what the ✕ button and the `r` key run.
|
||||||
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector(".kill-dialog")?.remove();
|
||||||
|
mounted?.dispose();
|
||||||
|
mounted = null;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||||
|
cwd: "/home/naps/tea/maestro",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: { name: "amber-falcon" },
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWith(sessions: Session[]): CatalogStore {
|
||||||
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||||
|
return {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
fn(state);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(s: Session): HTMLElement {
|
||||||
|
const handle = renderSidebar(storeWith([s]));
|
||||||
|
mounted = handle;
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function raise(name: string, s: Session): void {
|
||||||
|
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("kill from the action page", () => {
|
||||||
|
it("goes through the daemon, without a first confirmation", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
mount(s);
|
||||||
|
raise(KILL_SESSION_EVENT, s);
|
||||||
|
await flush();
|
||||||
|
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||||
|
window.fetch = ((url: string) => {
|
||||||
|
if (String(url).includes("force=true")) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: "kill_guarded",
|
||||||
|
message: "worktree has unsaved work",
|
||||||
|
unsaved_work: { untracked_files: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||||
|
mount(s);
|
||||||
|
raise(KILL_SESSION_EVENT, s);
|
||||||
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||||
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rename from the action page", () => {
|
||||||
|
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
const el = mount(s);
|
||||||
|
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||||
|
folder?.click();
|
||||||
|
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||||
|
raise(RENAME_SESSION_EVENT, s);
|
||||||
|
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
const el = mount(s);
|
||||||
|
raise(RENAME_SESSION_EVENT, s);
|
||||||
|
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||||
|
expect(input?.value).toBe("amber-falcon");
|
||||||
|
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { CommandSet, defineCommands } from "./commands.js";
|
|||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { InTheWay, Session } from "./types.js";
|
import type { InTheWay, Session } from "./types.js";
|
||||||
import {
|
import {
|
||||||
|
abbreviateCwd,
|
||||||
abbreviateFolderName,
|
abbreviateFolderName,
|
||||||
blockerStats,
|
blockerStats,
|
||||||
customSessionName,
|
customSessionName,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
sessionBlockers,
|
sessionBlockers,
|
||||||
isProvisionalName,
|
isProvisionalName,
|
||||||
sessionName,
|
sessionName,
|
||||||
|
sessionTreePath,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +52,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
|||||||
* ("Spawn similar"). Detail is the session being cloned. */
|
* ("Spawn similar"). Detail is the session being cloned. */
|
||||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||||
|
|
||||||
|
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||||
|
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||||
|
* through the same confirm-and-blockers flow the card's ✕ does
|
||||||
|
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||||
|
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||||
|
|
||||||
|
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||||
|
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||||
|
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||||
|
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||||
|
|
||||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||||
* hash navigation, but a split/drag can move focus without ever touching
|
* hash navigation, but a split/drag can move focus without ever touching
|
||||||
* `location.hash` — so a click back onto a session whose hash still
|
* `location.hash` — so a click back onto a session whose hash still
|
||||||
@@ -745,6 +758,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
};
|
};
|
||||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
|
||||||
|
// The command bar's action page (#151) picks an action and hands it here:
|
||||||
|
// both flows are the ones the card already has, reached by session rather
|
||||||
|
// than by row.
|
||||||
|
const killHandler = (ev: Event) => {
|
||||||
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
|
void requestKill(s, sessionName(s));
|
||||||
|
};
|
||||||
|
const renameHandler = (ev: Event) => {
|
||||||
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
|
const row = rowForSession(s);
|
||||||
|
if (row) beginRename(row, s);
|
||||||
|
};
|
||||||
|
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||||
|
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||||
|
|
||||||
|
/** The card a session is rendered as, expanding whatever folder hides it
|
||||||
|
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||||
|
* would otherwise swallow the request. */
|
||||||
|
function rowForSession(s: Session): HTMLElement | null {
|
||||||
|
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||||
|
const found = find();
|
||||||
|
if (found) return found;
|
||||||
|
// Folder paths are the abbreviated directory's segments joined back up
|
||||||
|
// (see buildTree), so the session's own path is normalised the same way
|
||||||
|
// before it is compared against them.
|
||||||
|
const cwd = sessionTreePath(s);
|
||||||
|
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||||
|
for (const folder of [...collapsed]) {
|
||||||
|
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||||
|
}
|
||||||
|
rerenderHandler();
|
||||||
|
return find();
|
||||||
|
}
|
||||||
|
|
||||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||||
|
|
||||||
@@ -794,6 +841,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
// refusing to refresh.
|
// refusing to refresh.
|
||||||
renamingId = null;
|
renamingId = null;
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||||
|
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+180
-1
@@ -1,7 +1,12 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "./catalog.js";
|
||||||
import {
|
import {
|
||||||
barGroups,
|
barGroups,
|
||||||
barRows,
|
barRows,
|
||||||
@@ -15,6 +20,7 @@ import {
|
|||||||
orderSessionsForBar,
|
orderSessionsForBar,
|
||||||
parseBarQuery,
|
parseBarQuery,
|
||||||
sessionBarCandidates,
|
sessionBarCandidates,
|
||||||
|
sessionActionCandidates,
|
||||||
sessionCandidate,
|
sessionCandidate,
|
||||||
SPAWN_DIRECTORY_EVENT,
|
SPAWN_DIRECTORY_EVENT,
|
||||||
} from "./commandBar.js";
|
} from "./commandBar.js";
|
||||||
@@ -542,3 +548,176 @@ describe("the focus round-trip over a pane (#152)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the session action page (#151)", () => {
|
||||||
|
const sessions = [
|
||||||
|
session("api", {
|
||||||
|
metadata: { name: "api" },
|
||||||
|
current_cwd: "/home/me/dev/api",
|
||||||
|
last_focused_at: "2026-01-01T11:00:00Z",
|
||||||
|
}),
|
||||||
|
session("worker", {
|
||||||
|
metadata: { name: "worker" },
|
||||||
|
current_cwd: "/home/me/dev/worker",
|
||||||
|
last_focused_at: "2026-01-01T10:00:00Z",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||||
|
return openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands: [],
|
||||||
|
onOpenSession: () => {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerText(): string | undefined {
|
||||||
|
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||||
|
expect(activeName()).toBe("Kill session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the session being acted on", () => {
|
||||||
|
open();
|
||||||
|
press("ArrowDown");
|
||||||
|
press("Tab");
|
||||||
|
expect(headerText()).toContain("worker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the kind headers, since the page is one kind", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays fuzzy-searchable over the three actions", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type("ren");
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
type("spwn");
|
||||||
|
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||||
|
type("zzz");
|
||||||
|
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a command row", () => {
|
||||||
|
openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||||
|
onOpenSession: () => {},
|
||||||
|
});
|
||||||
|
type(">");
|
||||||
|
const before = rowNames();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(before);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a directory row", async () => {
|
||||||
|
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||||
|
type("~");
|
||||||
|
await Promise.resolve();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||||
|
open();
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
expect(headerText()).toContain("worker");
|
||||||
|
press("Escape");
|
||||||
|
expect(isCommandBarOpen()).toBe(true);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on backspace at an empty query", () => {
|
||||||
|
open();
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
type("");
|
||||||
|
press("Backspace");
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps backspace ordinary while the page's query has text", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type("ren");
|
||||||
|
press("Backspace");
|
||||||
|
expect(headerText()).toContain("api");
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the bar when the page's Escape has already been used", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
press("Escape");
|
||||||
|
press("Escape");
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (const [hook, key] of [
|
||||||
|
["onKillSession", "Kill session"],
|
||||||
|
["onRenameSession", "Rename session"],
|
||||||
|
["onSpawnSimilar", "Spawn similar"],
|
||||||
|
] as const) {
|
||||||
|
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
type(key);
|
||||||
|
press("Enter");
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual([
|
||||||
|
"onKillSession:worker",
|
||||||
|
"onRenameSession:worker",
|
||||||
|
"onSpawnSimilar:worker",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands each action to the flow it already has, by default", () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const listeners: Array<[string, EventListener]> = [
|
||||||
|
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
];
|
||||||
|
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||||
|
try {
|
||||||
|
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type(action);
|
||||||
|
press("Enter");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no action beyond the three", () => {
|
||||||
|
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||||
|
"kill",
|
||||||
|
"rename",
|
||||||
|
"spawn-similar",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+152
-10
@@ -2,11 +2,16 @@
|
|||||||
// list, opened from the sidebar, closed with Escape.
|
// list, opened from the sidebar, closed with Escape.
|
||||||
//
|
//
|
||||||
// Three kinds live here: sessions and commands, ranked together by default,
|
// Three kinds live here: sessions and commands, ranked together by default,
|
||||||
// and directories, which need the `~` sigil. The action page and the
|
// and directories, which need the `~` sigil. `tab` on a session drills into
|
||||||
// terminal-focus binding land in #151-#152.
|
// its action page (#151), which is the same overlay over three actions.
|
||||||
|
|
||||||
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "./catalog.js";
|
||||||
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||||
import {
|
import {
|
||||||
matchAndRank,
|
matchAndRank,
|
||||||
@@ -30,7 +35,13 @@ export type CommandBarCandidate = CommandCandidate & { command: Command };
|
|||||||
* ranks; `path` is the one a spawn is made in. */
|
* ranks; `path` is the one a spawn is made in. */
|
||||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||||
|
|
||||||
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
|
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||||
|
* single `label` field, ranked exactly like a registry command — and carries
|
||||||
|
* the action it stands for instead of a registry entry. */
|
||||||
|
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||||
|
|
||||||
|
export type BarCandidate =
|
||||||
|
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
|
||||||
export type BarResult = MatchResult<BarCandidate>;
|
export type BarResult = MatchResult<BarCandidate>;
|
||||||
|
|
||||||
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||||
@@ -73,6 +84,40 @@ export function parseBarQuery(raw: string): BarQuery {
|
|||||||
return { sigil: null, query: raw };
|
return { sigil: null, query: raw };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the session action page ----
|
||||||
|
|
||||||
|
/** The actions a session's action page offers, and no others: the bar picks
|
||||||
|
* one and hands off to the flow that already implements it
|
||||||
|
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||||
|
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||||
|
|
||||||
|
export interface SessionAction {
|
||||||
|
id: SessionActionId;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||||
|
{ id: "kill", label: "Kill session" },
|
||||||
|
{ id: "rename", label: "Rename session" },
|
||||||
|
{ id: "spawn-similar", label: "Spawn similar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The action page's candidates, in the order above — which is the order an
|
||||||
|
* empty query lists them in, since the matcher keeps input order. */
|
||||||
|
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||||
|
return SESSION_ACTIONS.map((action) => ({
|
||||||
|
kind: "command",
|
||||||
|
action,
|
||||||
|
fields: { label: action.label },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||||
|
* `command` kind, so what separates them is what they carry. */
|
||||||
|
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||||
|
return candidate.kind === "command" && "action" in candidate;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- candidates ----
|
// ---- candidates ----
|
||||||
|
|
||||||
function focusedAt(s: Session): number | null {
|
function focusedAt(s: Session): number | null {
|
||||||
@@ -306,6 +351,8 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
|||||||
if (candidate.kind === "session") {
|
if (candidate.kind === "session") {
|
||||||
row.dataset.sessionId = candidate.session.id;
|
row.dataset.sessionId = candidate.session.id;
|
||||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||||
|
} else if (isActionCandidate(candidate)) {
|
||||||
|
row.dataset.actionId = candidate.action.id;
|
||||||
} else if (candidate.kind === "command") {
|
} else if (candidate.kind === "command") {
|
||||||
row.dataset.commandId = candidate.command.id;
|
row.dataset.commandId = candidate.command.id;
|
||||||
} else {
|
} else {
|
||||||
@@ -322,7 +369,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
|||||||
|
|
||||||
// The chord is teaching material, not a match target: it is rendered from
|
// The chord is teaching material, not a match target: it is rendered from
|
||||||
// the live registry entry and never fed to the matcher.
|
// the live registry entry and never fed to the matcher.
|
||||||
if (candidate.kind === "command") {
|
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
|
||||||
const keys = document.createElement("span");
|
const keys = document.createElement("span");
|
||||||
keys.className = "cmdbar-row-keys";
|
keys.className = "cmdbar-row-keys";
|
||||||
for (const cap of keyCaps(candidate.command.keys[0])) {
|
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||||
@@ -374,6 +421,13 @@ export interface CommandBarOptions {
|
|||||||
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||||
* spawn. */
|
* spawn. */
|
||||||
onSpawnDirectory?: (path: string) => void;
|
onSpawnDirectory?: (path: string) => void;
|
||||||
|
/** The action page's three actions (#151). Each defaults to raising the
|
||||||
|
* event the flow that already implements it listens on — the sidebar's
|
||||||
|
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
||||||
|
* seeded from the session. The bar picks the action and nothing more. */
|
||||||
|
onKillSession?: (session: Session) => void;
|
||||||
|
onRenameSession?: (session: Session) => void;
|
||||||
|
onSpawnSimilar?: (session: Session) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenBar {
|
interface OpenBar {
|
||||||
@@ -406,6 +460,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
opts.onSpawnDirectory ??
|
opts.onSpawnDirectory ??
|
||||||
((path: string) =>
|
((path: string) =>
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||||
|
const sessionEvent =
|
||||||
|
(name: string) =>
|
||||||
|
(session: Session): void => {
|
||||||
|
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
||||||
|
};
|
||||||
|
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
||||||
|
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
||||||
|
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
||||||
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||||
const listEntries =
|
const listEntries =
|
||||||
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||||
@@ -450,6 +512,13 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
let rows: BarResult[] = [];
|
let rows: BarResult[] = [];
|
||||||
let highlighted = 0;
|
let highlighted = 0;
|
||||||
|
|
||||||
|
// The action page (#151). `page` is the session being acted on, `stashed`
|
||||||
|
// the result-list query it was drilled into from — backing out restores it,
|
||||||
|
// so the round trip costs the operator nothing.
|
||||||
|
let page: Session | null = null;
|
||||||
|
let stashed = "";
|
||||||
|
let header: HTMLElement | null = null;
|
||||||
|
|
||||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||||
let directories: DirectoryBarCandidate[] = [];
|
let directories: DirectoryBarCandidate[] = [];
|
||||||
@@ -516,6 +585,7 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
* list is mixed — sessions and commands together — except on an empty query,
|
* list is mixed — sessions and commands together — except on an empty query,
|
||||||
* where the bar is a session switcher. */
|
* where the bar is a session switcher. */
|
||||||
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||||
|
if (page) return sessionActionCandidates();
|
||||||
switch (parsed.sigil) {
|
switch (parsed.sigil) {
|
||||||
case ">":
|
case ">":
|
||||||
return [...commandCandidates];
|
return [...commandCandidates];
|
||||||
@@ -547,9 +617,27 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The line naming the session being acted on, so the target is never
|
||||||
|
* ambiguous. Present only while the action page is up. */
|
||||||
|
function paintHeader(): void {
|
||||||
|
header?.remove();
|
||||||
|
header = null;
|
||||||
|
if (!page) return;
|
||||||
|
header = document.createElement("div");
|
||||||
|
header.className = "cmdbar-page-header";
|
||||||
|
header.setAttribute("role", "presentation");
|
||||||
|
header.textContent = `Actions for "${sessionName(page)}"`;
|
||||||
|
panel.insertBefore(header, results);
|
||||||
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
const parsed = parseBarQuery(input.value);
|
// On the action page the query line is over the three actions, so there is
|
||||||
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||||
|
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
||||||
|
input.placeholder = page
|
||||||
|
? `Act on ${sessionName(page)}…`
|
||||||
|
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||||
|
paintHeader();
|
||||||
rows = barRows(parsed.query, candidatesFor(parsed));
|
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||||
// The highlight starts on the globally best row every time the list
|
// The highlight starts on the globally best row every time the list
|
||||||
// changes: an old index would point at a different result.
|
// changes: an old index would point at a different result.
|
||||||
@@ -567,8 +655,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
for (const group of barGroups(rows)) {
|
for (const group of barGroups(rows)) {
|
||||||
// While a sigil is in force there is one kind, so the header is dropped.
|
// While a sigil is in force there is one kind, so the header is
|
||||||
if (parsed.sigil === null) {
|
// dropped — and the action page is one kind too.
|
||||||
|
if (parsed.sigil === null && !page) {
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "cmdbar-group-title";
|
header.className = "cmdbar-group-title";
|
||||||
header.setAttribute("role", "presentation");
|
header.setAttribute("role", "presentation");
|
||||||
@@ -593,10 +682,50 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
paintHighlight();
|
paintHighlight();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||||
|
* other kind, and none on the page itself, which has no session rows. */
|
||||||
|
function drillIn(): void {
|
||||||
|
const candidate = rows[highlighted]?.candidate;
|
||||||
|
if (page || candidate?.kind !== "session") return;
|
||||||
|
page = candidate.session;
|
||||||
|
stashed = input.value;
|
||||||
|
input.value = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape, or backspace at an empty query: back to the result list with the
|
||||||
|
* query the drill-in was made from. */
|
||||||
|
function leavePage(): void {
|
||||||
|
page = null;
|
||||||
|
input.value = stashed;
|
||||||
|
stashed = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action: SessionAction, session: Session): void {
|
||||||
|
close();
|
||||||
|
switch (action.id) {
|
||||||
|
case "kill":
|
||||||
|
killSession(session);
|
||||||
|
return;
|
||||||
|
case "rename":
|
||||||
|
renameSession(session);
|
||||||
|
return;
|
||||||
|
case "spawn-similar":
|
||||||
|
spawnSimilar(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function activate(index: number): void {
|
function activate(index: number): void {
|
||||||
const result = rows[index];
|
const result = rows[index];
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const candidate = result.candidate;
|
const candidate = result.candidate;
|
||||||
|
if (isActionCandidate(candidate)) {
|
||||||
|
// The page cannot be up without a session to act on.
|
||||||
|
if (page) runAction(candidate.action, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
switch (candidate.kind) {
|
switch (candidate.kind) {
|
||||||
case "session":
|
case "session":
|
||||||
@@ -615,7 +744,20 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "Escape":
|
case "Escape":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
close();
|
// On an action page Escape is one level up, not the way out.
|
||||||
|
if (page) leavePage();
|
||||||
|
else close();
|
||||||
|
return;
|
||||||
|
case "Tab":
|
||||||
|
e.preventDefault();
|
||||||
|
drillIn();
|
||||||
|
return;
|
||||||
|
case "Backspace":
|
||||||
|
// Ordinary editing, except at an empty query on the action page.
|
||||||
|
if (page && input.value === "") {
|
||||||
|
e.preventDefault();
|
||||||
|
leavePage();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case "Enter":
|
case "Enter":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1472,6 +1472,15 @@ input.leaf-rename:focus {
|
|||||||
padding: var(--sp-2) 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The action page's header (#151): names the session being acted on, so the
|
||||||
|
target of kill/rename/spawn-similar is never ambiguous. */
|
||||||
|
.cmdbar-page-header {
|
||||||
|
padding: var(--sp-2) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.cmdbar-group-title {
|
.cmdbar-group-title {
|
||||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
|
|||||||
Reference in New Issue
Block a user